AGENTS.md is a rulebook — every rule in it must be readable without having to guess which one wins.
If two passages turn out to conflict, or a rule admits two readings with different outcomes, do not quietly choose one: point out the contradiction where you hit it, name both passages, and resolve it here in the text so the next reader cannot stumble into the same fork.
This plugin exists to connect a Hermes Agent to a Chatto server. All Chatto
API interaction goes through chattolib (vendored under vendor/) — never
hand-roll ConnectRPC/WebSocket calls the library already covers. When behaviour
is unclear on our side, the Chatto server source is the reference:
git clone https://github.com/chattocorp/chatto.git
Doc references, distinct jobs — know which one answers your question:
The same repo's web frontend (apps/frontend) doubles as the architecture
reference for client-side concerns — presence handling, caches, realtime
consumption — because the bot is a Chatto client just like the browser. Read
how the platform itself models a problem before inventing our own shape (its
presence-overlay pattern is what shaped the roster projection below).
The vendored copy is upstream chattolib[realtime] 0.5.0b17 (realtime
protocol 4 — the revision chattolib sends in RealtimeSubscribe.protocol_version).
chattolib versions track the Chatto server release they target,
so this copy pairs with Chatto v0.5.0-beta.17, which is also the server floor
this build claims to support (a .postN re-upload keeps the server target and
moves only the library build). Refreshing vendor/ via vendor_chattolib.sh
can move that floor (see VENDORING.md) — after each refresh, restate the new
version here and in README.md.
Never edit vendored files. All changes to vendor/ must come through
vendor_chattolib.sh (a full refresh). If a vendored file needs a fix,
either update the upstream library and re-run the refresh, or apply the
fix in the adapter code (not in vendor/). The dead-field sweep command
excludes vendor/ for this reason.
_register_bundled_skills registers skills/<name>/SKILL.md and nothing
else — it requires a directory containing a SKILL.md, and it is the only
door: Hermes has no separate loader for .skill files in a plugin directory.
Verified: registering our two directories yields exactly
chatto-platform-plugin and chatto-plugin-update.
skills/update-chattolib.skill is therefore not a bundled skill and never
reaches an agent — it is a local development convenience for refreshing the
vendored tree, written as a script because the refresh is mechanical. The
instructions it wraps are vendor_chattolib.sh and VENDORING.md; those are the
documents to keep current. Nothing here depends on the .skill file being
loaded, so do not treat its absence from the registered list as a bug, and do
not "fix" it by moving it without deciding first whether a vendoring workflow
belongs in a skill shipped to every installer at all.
vendor/ exists, and what goes in itThe plugin ships its whole dependency tree because hermes plugin install does
not install Python packages. plugin.yaml may declare python_dependencies,
but Hermes only validates them and prints an install hint — it never resolves
them (hermes_cli/plugins.py, _warn_python_dependencies: "a declaration
seam ONLY … NEVER auto-installs them"). Without a vendored tree the plugin
dies on import chattolib at first gateway start, and only a log warning says
why. So vendoring is not a preference; it is what makes the one-line install
work.
Within that constraint, vendor/ carries what chattolib needs to be
importable, and it does not stop there. Two different rules decide what
lands in the tree, and they pull in opposite directions:
| Vendored | Why |
|---|---|
chattolib |
The library itself. Nothing else provides it. |
protobuf |
Hermes does not ship it in the default set — only optional extras (google, modal, qdrant-client, mem0ai, …) pull it in, so a plain install has none. It is also version-coupled: chattolib's generated _pb code is gencode 7.35.1, and protobuf ≥ 5.27 refuses to load gencode newer than its runtime at import time. A host protobuf is a guaranteed VersionError, not a soft mismatch. |
websockets |
The [realtime] extra's transport. Hermes pins 15.0.1, which satisfies chattolib's >=13.0, but it shares the binary-package branch with protobuf and costs little next to it. |
The remaining packages — httpx, httpcore, h11, anyio, idna,
certifi, typing_extensions (~4.2 MB) — Hermes does provide, so keeping
them in vendor/ looks like duplication and has been re-litigated more than
once. They stay because of the cron sender: hermes_standalone_sender_fn
delivers scheduled messages out of process, in no Hermes venv, and there
vendor/common is the only thing on sys.path. Dropping those copies to save
4 MB breaks exactly the path that has no other supply. vendor_path.py
appends common/ so that in-process the host's copies win anyway — the
duplicates are inert where Hermes runs and load-bearing where it does not.
So the invariant is not "vendor only what Hermes lacks". It is: whatever
lands in vendor/ must make chattolib importable in a bare Python process
with nothing else on sys.path. Every package vendor_chattolib.sh resolves
today satisfies that; any future pruning proposal has to argue against this
invariant, not against the repository size.
Do not "optimise" this by dropping protobuf or reaching for host packages:
the failure mode is an import-time crash, not a degraded feature. If Hermes
ever gains a protobuf default dependency, vendor_chattolib.sh and this
section need revisiting together — re-derive the answer rather than assuming
the pins quoted here still hold, since they drift with every Hermes release.
Two audiences: AGENTS.md is read by agents with this source tree open; README.md is read by humans who have not. That makes README.md the document where ambiguity is not allowed.
Everything a user meets first lives there — what the plugin is, how it behaves, how to configure it. Unambiguous in practice means:
vendor/ refresh can move the supported
server floor, and README.md has to move with it. ("Tested against Chatto
v0.4.19" outlived its truth the moment the vendored copy became 0.4.20.)A README is read by someone deciding whether to run this plugin, not by someone deciding how to change it. Precision about the plugin's own behaviour is the job; the mechanism behind it usually is not.
So before adding a paragraph, ask what the reader can do differently with it. A user needs to know that a reply into a thread reaches the agent with what the bot said earlier, and that a gateway restart does not lose that. They do not need to know that the frontier is a cursor stored under a session-metadata key, that persisting it costs a routing-index rewrite, or that the obvious shortcut for reconstructing it does not work. That last sentence is documentation of a decision, and a decision has an audience: whoever changes it next.
The audiences differ, and so do their documents:
| Reader | Document | Holds |
|---|---|---|
| Decides whether to run it | README.md |
Observable behaviour, configuration, capability limits |
| Installs it | after-install.md |
First steps, stated as they can be followed |
| Changes it | AGENTS.md |
The rules, written as rules |
| Changes it, and wants the reasoning | CONCEPTS.md |
Why a rule looks the way it does — derivation, costs, rejected approaches. Developers only |
The failure mode is not a missing paragraph, it is a README that reads like the design document: paragraphs nobody acts on, internals bleeding into user docs, and every mechanism needing an update in three places at once. When a README sentence starts explaining how rather than what a user sees, that is the signal to move it — and to keep the user-facing half, plus a pointer.
CONCEPTS.md holds developer information, and nothing else. Its reader is
whoever changes the plugin next and wants to know why a rule looks the way it
does — the derivation, the cost of each part, the approach that was tried and
rejected. Nobody reads it while deciding whether to install the plugin, so
nothing goes there that answers that question.
Two consequences, and the second is the one that gets forgotten:
README.md and link to CONCEPTS.md for the
reasoning — do not summarise the reasoning into the user document, not even a
sentence of it. A paraphrase is the same content with a worse update path: it
goes stale silently, because the summary is not what anyone goes looking for
when CONCEPTS.md changes.AGENTS.md. Three copies of one rule drift, and
AGENTS.md is the one that binds. Where the two overlap, AGENTS.md states
the rule and CONCEPTS.md explains why.Hermes Agent displays after-install.md to the user right after plugin
installation — that is the platform-plugin convention, not our timing choice.
By then the installer has already walked through plugin.yaml's
requires_env/optional_env prompts and written the answers into
~/.hermes/.env (hermes_setup_fn, save_env_value).
It is the first instruction anyone reads, ahead of README.md, config files and
source. Hold it to the same no-ambiguity bar and keep it in step with reality:
every env var, command and next step printed there must work exactly as
written, so it moves whenever setup, configuration or first-run behavior
changes. Because the values were just collected, after-install.md treats
.env/config.yaml as a review step — "check what the installer wrote",
not "edit these files to begin".
These behaviours are promises to users, documented in README.md — adapter changes must keep them, or consciously renegotiate the docs.
CHATTO_REQUIRE_MENTION_ROOMS it
answers only messages addressing the bot — by @-mention or by the
broadcast handles @all/@here (the only virtual handles Chatto defines,
FDR-006); in CHATTO_OPTIONAL_MENTION_ROOMS it answers every message,
and one aimed at someone else gets a 🫥 acknowledgement instead of an
answer. A room on neither list stays silent — read-only: still marked as
read, never seeded into context, never answered, no processing reactions;
the gate sits at the top of the dispatch paths, before any API call.
Unknown room kinds count as channels (a server that never sets kind
shows up as UNSPECIFIED), so they go through the same lists. Both gates
share _mentions_me, so "addressed" means the same thing everywhere:
case-insensitive handle matching outside code regions, per the Chatto web
frontend's extraction. DMs answer regardless so /join stays reachable;
hermes_validate_config rejects listing a room on both lists. The plugin
tools obey the same lists: chatto_send_room refuses a channel on neither
(a DM room is never gated), because a tool that posted there would break the
read-only promise the same paragraph makes.
CHATTO_HOME_CHANNEL is orthogonal: it is the default outbound target for
context-less cron/notification delivery, not an answer destination.auto_thread is disabled or the room is a DM. A thread's session starts
empty, so the first turn into one also seeds the messages that came before
it — gated on thread_root_event_id, not on the anchored thread_id (a
fresh room message anchors to itself, and that thread does not exist yet,
so it must cost no lookup). This is what makes a reply to a cron message
answerable: the bot's own echo is filtered as a self-event, so a cron
report enters no session and the thread it roots arrives blind. Own output
is labelled … (this bot) so the agent recognises it; authors off the
allowlist are labelled (unverified) — auth gates the trigger message
only, and the rest of a shared thread is reference text (fail closed for
authors never seen). Seeding delivers what the session has not seen yet, keyed on a
cursor rather than done once: the first turn seeds the whole thread, and a later
turn seeds only what landed after the cursor. That is what makes the agent's own
output reachable — a message it posts into a live thread with chatto_send_room
is filtered as a self-event and never dispatched, so without the cursor it would
be invisible to that thread for good, and the next human message would be
answered by an agent that does not know what it said. _seeded_threads maps a
thread root to the last message ID the session reached; the cap evicts the
least recently touched thread. The cursor passes the trigger as well as the
delivered block — the trigger is in the session, so a cursor left behind it
would hand the next turn the previous question a second time. The
fetch is anchored at the cursor (after=), so a cursor is a boundary the
server walks forward from rather than a position inside a window, and a thread
that outgrew THREAD_CONTEXT_MESSAGE_LIMIT keeps answering instead of going
quiet for good. The
cursor is stored, in memory and on the session
(chatto.thread_seed_cursor), because a session's frontier is not derivable
from the message list: a person who merely talked is in the session, the
bot's own output is in nobody's, and a past turn's trigger is
indistinguishable from the former. Rebuilding a partition from the fetch skips
the very output this exists to deliver — see CONCEPTS.md for how that goes
wrong and what each term of it costs. Writing the session copy costs a full
routing-index rewrite, so it is spent only on a turn that delivered the bot's
own output, and on the turn after a first turn whose cursor found no session to
land on (_unpersisted_threads). A thread can be blind exactly once — first
turn seeded nothing, no session yet, restart before the second turn — and that
pass re-delivers the bot's own messages, leaves the people's messages to their
own turns, and records the frontier; prefer that bounded duplicate to a
permanent loss. A backlog larger than the window drains over turns rather than
in one: each turn takes the next slice after the cursor and stops there, so
nothing is skipped and nothing repeats. A failed
lookup leaves the thread unseeded so its next turn retries. Not discarded on
reconnect, unlike the roster: thread history is immutable, so a refetch could
only duplicate what the session already has. Note there
is no session-reset path on this platform — /join and /leave are the
only commands and they ride DMs — so an emptied session cannot strand a
thread the way it would elsewhere; do not assume one exists.channel_context per dispatch, so each channel turn
carries the current member roster — repetition is the point, deduplicating
it strands later threads without an audience (that bug shipped once). The
cost stays bounded because the roster is a miniature projection of server
state, modelled on the Chatto web frontend's presence overlay: per room,
member IDs plus their users in _user_cache (one cache, one truth),
fetched from the directory once. presence_changed patches the cached user
in place; user_joined_room/user_left_room, /leave, stale-room pruning
and reconnects discard a room's projection so its next turn refetches once —
a discarded cache is what forces one honest refetch after downtime. The
realtime channel can deliver a server-side projection on subscribe, but not
a presence one: RealtimeSubscribe.initial_state = SNAPSHOT makes the server
send a single RealtimeSnapshot frame (rooms with their member_user_ids,
the user directory, room groups), and it carries no presence for anyone —
presence only ever arrives as PresenceChangedEvents. chattolib requests
LIVE_ONLY by default and the adapter neither requests the snapshot nor
consumes the frame (isinstance(event, RealtimeEvent) drops it), so the
discard-and-refetch behaviour stays the fail-fresh fallback; quiet rooms stay
free of lookups. The bot's own presence events are filtered out. A failed lookup
leaves the room unprojected and lets the next turn retry.message_edited
re-runs the admission gates against the new body: a message currently being
processed is cancelled (🚫) and redispatched with the edited text; a queued
follow-up is rewritten in place; a never-dispatched message (a forgotten
@mention, say) gets a fresh turn if the gates pass now; an already-answered
message stays answered — the _dispatched_ids ring exists so edits cannot
re-animate old conversations. Edits older than CHATTO_EDIT_WINDOW seconds
(default 300) are dropped, as are edited DM-command texts (/join, /leave
ran once and must not run again); CHATTO_EDIT_DISPATCH=false turns the
whole behavior off. Own edit echoes (the streaming path) are filtered by
actor before any of this.on_processing_start/on_processing_complete and the reactions
setting, never applied to the bot's own events./join and
/leave in a DM call RoomService/JoinRoom/LeaveRoom, so membership survives
restarts; the joined list mirrors it on every _refresh_rooms(). Silent
rooms are joined read-only — no history seeding (same rule as
_refresh_rooms). A channel-kind /join reply reports the room's
mention-list status; an unlisted one names both CHATTO_*_MENTION_ROOMS
lines verbatim, because that reply is where users copy the room ID from —
the bot never writes ~/.hermes/.env itself. DMs and the configured home
channel refuse /leave. Commands never reach the agent pipeline and are
not intercepted outside DMs.send() splits at 9900 chars against the 10000-char
server limit; edit_message() refuses over-long content so callers fall
back to send() rather than receiving silent truncation. The standalone
cron sender splits the same way, so out-of-process delivery cannot die on
the per-message limit either.Without CHATTO_ALLOWED_USERS and without CHATTO_ALLOW_ALL_USERS=true,
_check_auth denies everyone. Keep that fail-closed stance when touching the
auth path. hermes_validate_config rejects setting both options at once on
purpose, and allow_all_users hands every Chatto user full agent access
including tool use.
Work on this plugin happens against a real Hermes Agent checkout — set that up first, everything else builds on it:
git clone https://github.com/NousResearch/hermes-agent.git
hermes-agent/plugins/platforms/hermes-chatto-plugin.
Either clone this repo straight into that path, or link an existing checkout
so you can keep working in your own copy: ln -s /path/to/hermes-chatto-plugin \
hermes-agent/plugins/platforms/hermes-chatto-plugin
Hermes loads the plugin through this symlink, so a stale target silently runs a different checkout than the one you are editing. Every symptom is plausible as something else, which is what makes it expensive:
gateway.* from Hermes, and the failing assertion reads the
linked tree's plugin.yaml, not yoursvendor/ refresh appears to do nothing, because the tree that gets
exercised is the old oneThe directory name is what Hermes uses as the platform identity, and it is
not the same string as plugin.yaml's name: (chatto-platform). Hermes
resolves the manifest name to the directory name on purpose — see _missing_
in gateway/config.py — so Platform("chatto-platform").value is
"hermes-chatto-plugin". Tests must assert against
Platform(ChattoConstants.PLATFORM_NAME).value rather than a literal, so a
Hermes change to that mapping does not read as a plugin regression.
Check the link whenever tests fail in a way the source does not explain, and whenever you return to a checkout after working elsewhere:
readlink -f hermes-agent/plugins/platforms/hermes-chatto-plugin
grep -E '^Version' hermes-agent/plugins/platforms/hermes-chatto-plugin/vendor/common/chattolib-*.dist-info/METADATA
Both must name the tree you are editing and the chattolib version you just vendored. To repoint the link (the old target keeps its own working tree, so nothing is lost by moving it):
ln -sfn /absolute/path/to/this/checkout \
hermes-agent/plugins/platforms/hermes-chatto-plugin
Use an absolute path. A relative link resolves against
plugins/platforms/, so it survives being copied between checkouts only by
accident.
adapter.py imports from gateway.*, which lives in the Hermes Agent rather
than this repo. Without its source on the PYTHONPATH, collection dies with
ModuleNotFoundError: No module named 'gateway' before a single test runs.
With the layout above:
PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml --with "ruamel.yaml==0.18.17" pytest -q
Two Hermes dependencies are not among our own, and both are needed at import
time: pyyaml (for gateway.config) and ruamel.yaml (for
hermes_yaml, which utils.py imports on the way to platform_config). The
list grows whenever Hermes promotes one of its packages from an extra to a core
dependency — a missing one shows up as a collection error, not a test failure,
so re-check this command whenever the Hermes checkout moves.
Code is written for humans first and machines second — follow Clean Code principles, with PEP 8 as the baseline.
Everything in this repo gets read far more often than it gets written: by maintainers revisiting it months later, and by agents working from these guidelines. Optimising for that reader is not optional polish. Concretely:
#
commentary to explain its flow, split it until the code explains itself._seed_room beats do_init; a boolean
reads as a question (is_seen, retryable). No single-letter names outside
throwaway loop variables. Locals earn their line the same way: keep them
when they narrow, transform or get reused across branches; drop them when
they only re-alias a typed attribute (asset_id = asset.id reads inline
just as well).adapter.py for the house style).map/filter chains, dataclasses over dict-shaped blobs,
early returns over nested conditionals.if for states, try for faults — line count is not the metric.
A normal absence ("no client right now") reads as a guard clause;
try/except is reserved for operations that can genuinely fail, with each
except clause stating its own recovery. Folding a state check into an
operation's try saves lines and costs semantics: the shared handler cannot
tell "nothing there" from "server broke", so retry decisions and log blame
go wrong. (The client helpers in adapter.py document the split locally.)WIP/placeholder subject, wants to be split or finished first. The
history after August 2026 is the house style to imitate.Both gates run over the repo's whole first-party code — not just the lines you touched. Someone always forgets an older file otherwise.
ruff check . # lint
ruff format . # format
uv run basedpyright # types
Ruff excludes vendor/ and dist/ via pyproject.toml. basedpyright's scope
lives in pyrightconfig.json, which likewise skips vendor/, build output and
the mock-heavy test modules; its extraPaths resolve both Hermes layouts from
Development above.
Fix what the tools report instead of adding # noqa — if a rule genuinely
does not fit here, that is a change to make deliberately, not inline. The same
goes for silencing type findings with cast(Any, ...) or # type: ignore —
see Typed access below for why that trade hides real bugs.
Sporadic dead-field check. Neither gate catches instance state that is
written but never read: Ruff's F841 stops at locals, and pyright counts an
attribute assignment as a use. Leftovers from long-gone features therefore
survive indefinitely (_dedup, _resume_cursor, the _ws_* trio and the
_token/_user_* mirrors all lived here unnoticed). Run the sweep whenever
a feature is removed from or reworked in the adapter — that is when its
state gets orphaned:
git grep -hoE 'self\.[a-z_][a-z0-9_]*' -- '*.py' ':!vendor' \
| sort | uniq -c | sort -n
A low count is a suspect, not a verdict — a lone mention can be dead data, but just as well a one-shot helper method. Count assignments vs. other mentions per name, confirm each suspect against the test fixtures and the Hermes base class, and delete whatever nothing touches.
Two blind spots this sweep leaves, cover them by hand. First, the grep sees
only self.* state — module-level helpers go stale the same way, so check
their call sites whenever one smells redundant (a single-caller wrapper is
the same finding as an unread attribute). Second, duplication is found by
naming jobs, not by diffing text: before judging any function, ask who else
does its job (downloads? image materialisation?) — two implementations of
one job are one too many, because they drift silently until behaviour
differs (an uncapped downloader next to a capped one shipped here for
months).
The two send paths, one implementation. A user cannot tell whether the agent replied on its own or through a tool, so the two must not behave differently. Two rules already exist once each, and each answers for both callers:
_room_answers_in — whether the bot takes part in a room. It is both the
inbound gate (ChattoAdapter._answers_in_room) and the outbound one
(_room_is_writable on the send tools). Two copies of "a DM answers, a
channel needs a mention list" would drift until the send tool became a way
around the read-only promise, and the tool's copy is the security-relevant
one. _room_policy still exists because callers that must name the policy
(/join hint, the joined-rooms log) need the enum, not a bool._deliver_chunks — the split at SPLIT_THRESHOLD, the routing of a
split answer as one answer (chunk 1 opens or joins a thread, later chunks
carry that root) and the stop-at-first-failure. send() shapes the result
into a SendResult and _post_chunks into a refusal, because those result
contracts genuinely differ; the sending is the same job and must not be.
on_posted is how the adapter marks its own output seen — without it the
realtime echo of its own message would come back as input.The standalone cron sender keeps its own copy on purpose (out of process, no
adapter — see Why vendor/ exists), so agreement there is on the shared
constants, not on shared code.
State questions: check what exists before adding. The sweep above catches
dead state after the fact; the cheaper move is to not create parallel state
in the first place. Before introducing a new instance field, look through the
adapter's existing state for something that already carries the data —
_user_cache holds every known user (the roster projection stores only room
membership IDs and reads users from there), _room_names/_room_kinds carry
room metadata filled by _refresh_rooms. Two caches of one truth drift
silently until behaviour differs. And when the question is how to model
state or behaviour at all, read how the Chatto web frontend (apps/frontend
in the repo cloned under What this plugin is) solves it first — its
presence-overlay pattern (per-user cache patched by presence_changed, never
re-fetching member lists) is the reference implementation of exactly the
roster design in the behavioural contract below.
One field, three coordinated names — declared once on ChattoConfiguration.
A config value lives as a ConfigField class attribute in platform_config.py.
From the attribute name it derives the config.yaml key under
gateway.platforms.hermes-chatto-plugin.extra (the config_key, overridable
per field) and the CHATTO_* environment variable (upper-cased config_key).
That platform segment is the plugin's directory name, not
ChattoConstants.PLATFORM_NAME (chatto-platform): Hermes keys its platform
map by Platform.value, and _missing_ resolves the manifest name to the
directory name, so Platform("chatto-platform").value is
"hermes-chatto-plugin". Both spellings are accepted on read — Hermes rewrites
the alias to the directory name and writes only the latter back out, so quote
the directory name in docs. chatto is rejected outright: nothing registers
it. Resolution order
per instance: env var → extra → default. Declaring a field is enough for the
adapter, the env-enablement seeding and the setup wizard to pick it up;
plugin.yaml additionally repeats the env names as wizard prompts, so keep
those entries in step. Every optional_env entry states its default right in
its description ((default: …)): at install time the prompt is all the user
sees — "what happens if I leave this empty?" must be answerable without
opening README.md. Required entries have no default to state; they say what
happens when left blank instead (see CHATTO_BASE_URL).
When adding or renaming a field, also update the environment-variable table in
README.md — and document each option's default next to it, for env vars
(Default column) and config.yaml keys alike. A user reading only the
configuration docs must be able to predict what happens when they set nothing.
Secrets (CHATTO_TOKEN, CHATTO_PASSWORD) belong in ~/.hermes/.env, never
in config.yaml — docs and setup wizard rely on that split.
A raw value from anywhere is parsed by the field's kind before anything
reads it. .env lines are strings whatever the field means: a list field
holding "a,b" turns room_id in require_mention + optional_mention into a
substring test, and a bool field holding "false" is true. The parse lives once
per kind in the _parse_* helpers, and every path into a payload reaches
them: resolve() via the _get_env_or_extra_* wrappers, and
overlay_profile_secrets() via ConfigField.coerce. Both are required — the
overlay is the path a cron or CLI session takes, and it
is the one no env layer has already normalized.
Hermes multiplexes several profiles through one gateway process, so any process-wide config cache is a cross-tenant credential leak waiting to happen.
hermes_validate_config runs once per profile (run_adapters.py calls
_create_adapter for each), and each call resolves a different PlatformConfig.
adapter.py therefore keeps them in _resolved_chatto_configs, a dict keyed by
_profile_scope_key() — hermes_constants.hermes_home_key(), the same key the
platform registry scopes its own entries by, so both agree on what "the serving
profile" means. The fallback tier in _chatto_config_for_tools caches into the
same map under the same key.
For the secrets the fallback layers on top, prefer the scope Hermes already
installed (agent.secret_scope.current_secret_scope()): that mapping is the
served profile's authoritative secret set. Only when none is installed (CLI, the
standalone sender) does build_profile_secret_scope(get_hermes_home()) apply —
rebuilding one from the launch home while another profile is being served
answers with the wrong credentials.
The same reasoning rules out os.environ as a source of truth here: under
multiplexing it holds the launch profile's values, first-writer-wins
(gateway/platforms/_shared.get_scoped_secret is the upstream reader that
handles this). See Auth defaults to closed for what fail-closed means for
these values.
Work against the declared types. Do not reach for values dynamically when a typed attribute exists.
chattolib ships dataclasses with py.typed for everything it returns —
Message, Asset, AssetUpload, Room, User. Read their fields directly, so
a wrong name is a loud AttributeError at the first call and a type checker can
see it before that:
upload_id = upload.upload_id # yes
upload_id = str(getattr(cast(Any, upload), "id", "")) # no
The second form cost us every single file upload. AssetUpload has no id
field — it is called upload_id — but cast(Any, ...) disables the check and
the "" default turns the mismatch into an empty string. The result was an
error message blaming the server (CreateUpload returned no upload ID) for a
name we got wrong ourselves, and it survived in production because it never
raised.
So: no cast(Any, ...) on a value whose type is known, and no getattr with a
fallback default to paper over a field you are not sure about — look the field up
in vendor/common/chattolib/types.py instead. getattr is fine for genuinely
optional or duck-typed things (a handler that may not be set, a payload from an
untyped source), not for dodging a type.
Tests should exercise real library types too, not mocks shaped like them. The
upload bug was invisible because the tests replaced _upload_asset wholesale
with an AsyncMock, so no test ever touched a real AssetUpload.
Every method that overrides a base-class method must say so in its
docstring, closing with the marker line BasePlatformAdapter override — the
pattern already used throughout adapter.py.
This plugin lives by subclassing (ChattoAdapter overrides BasePlatformAdapter
methods throughout), so an override is not an implementation detail — it is the
contract between plugin and platform. Follow the existing form: first line says
what the method does, the body spells out what changes about the inherited
behaviour, and the last line names the parent class:
async def edit_message(self, chat_id, message_id, content, *, finalize=False):
"""Edit a message we previously sent, via MessageService/UpdateMessage.
Without this override the base class reports "Not supported" and every
incremental streaming update arrives as a *new* message. Content beyond
the per-message limit is refused so callers fall back to ``send()``.
BasePlatformAdapter override
"""
When you extend what the adapter can do, keep the startup banner current.
_capabilities() in adapter.py logs what the plugin supports at registration,
so starting it up tells you what is available instead of making you read the
source. Whether a capability counts is derived from real overrides: it only
counts when ChattoAdapter replaces the BasePlatformAdapter method. The label
for it, however, lives in _CAPABILITY_LABELS and is not discovered
automatically.
So when you add an overridden BasePlatformAdapter method:
_CAPABILITY_LABELS (method name → wording that means
something to someone who has not read the code). Skip this and the plugin can
do the thing but says so nowhere.platform_hint in register() needs to mention it. That
hint goes into the system prompt and is the only way the model learns what the
channel can do — without it the agent falls back to shelling out.TestRegistration if the capability is user-visible.The reverse holds too: drop a method and its line disappears from the log on its
own, but its _CAPABILITY_LABELS entry should go with it, along with its row in
the user docs.
A tool that exists in one session kind and not the other is a bug, and the usual cause is a second copy of the tool list.
Hermes defers platform plugins: the CLI and the TUI never materialize a
platform, so register() — where ctx.register_platform lives — does not run
there at all. plugin.yaml's provides_tools is what asks the loader for a
second door; it imports tools.py and calls register_tools(ctx)
(hermes_cli/plugins_loader.py, _register_deferred_platform_tools). Without
that module Hermes does not fail — it logs that the declared tools will be
missing, and the agent simply has no way to send.
Both doors call register_plugin_tools() in adapter.py, which reads the one
_PLUGIN_TOOLS table (name, schema, handler). So when you add a tool:
_PLUGIN_TOOLS and to provides_tools in plugin.yaml — the
manifest is what opts the plugin into the second door, and
TestRegistration pins the two against each other, so neither can drift
alone.register() instead re-creates the split
this structure exists to prevent. The same-toolset re-registration the
gateway then performs is allowed (tools/registry.py), so there is nothing
to work around.tools.py stays a thin shim: the handlers live in adapter.py, and a
second copy of them would be a second module-level copy of the adapter.Every shell script in this repo must pass shellcheck before its change
lands.
Right now that is exactly one script: vendor_chattolib.sh. After editing
it — or adding any other script — run:
shellcheck vendor_chattolib.sh
Fix what it reports instead of suppressing it, same stance as Linting &
Type Checking above: an exemption is a deliberate decision recorded here in
this document, never an inline disable. If a future script ever needs
project-specific options, put them in a .shellcheckrc, commit that file,
and name it here.
Why this earns a rule of its own: the script deletes things (rm -rf
"$VENDOR_DIR", the build-dir trap), so a quoting or word-splitting bug is
destructive rather than merely wrong output — and both are exactly the
mistakes review tends to skim past.