AGENTS.md 43 KB

Agent Guidelines for hermes-chatto-plugin

This document stays contradiction-free

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.

What this plugin is

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.

skills/ holds two different things

_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.

Why vendor/ exists, and what goes in it

The 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.

README.md is the human README

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:

  • Say which Chatto you mean. Server, chattolib and plugin all answer to "Chatto"; write the one you mean. (A capability-matrix column labelled just "Chatto" recently had to be asked whether it meant the plugin.)
  • One sentence, one reading. If a claim can be parsed two ways, it is false in at least one of them — rewrite until only one parse remains.
  • No shorthand that needs the source. Config keys, env vars and RPC names appear spelled out, never as "see adapter.py".
  • Keep versions current. Compatibility claims must match reality — chattolib tracks Chatto upstream, so every 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.)

Only what interests the user goes in README.md

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 is for developers

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:

  • A README sentence must not carry content that belongs there. Write the user-visible behaviour in 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.
  • It must not restate 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.

after-install.md

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".

Behavioural contract

These behaviours are promises to users, documented in README.md — adapter changes must keep them, or consciously renegotiate the docs.

  • Rooms are opt-in via two mutually exclusive lists. Chatto has no group rooms like Signal — every non-DM surface is a channel, and a room participates only if it is listed: in 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 read one resolved _Mentions, so "addressed" is defined once, and the two paths that fill it differ only in what the server told us — never in what counts. A posted message carries the targets Chatto resolved when it was created (MessagePostedEvent.mentions, whose includes_viewer is the server's own answer to "did this reach me"), so the posted path never re-derives anything and role mentions work; an edit carries none, so it falls back to reading the body, case-insensitive outside code regions per the web frontend's extraction. Broadcast and role targets arrive unexpanded, so neither path needs a roster to recognise them. DMs answer regardless so /join stays reachable; hermes_validate_config rejects listing a room on both lists. The sending 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. The read tools are deliberately exempt — chatto_get_message and chatto_get_history post nothing and answer nothing, so reading a room grants no privilege the bot's token did not already have and the server's own visibility rules are the only gate. CHATTO_HOME_CHANNEL is orthogonal: it is the default outbound target for context-less cron/notification delivery, not an answer destination.
  • Threads stay threads. An inbound thread reply keeps its thread context; a fresh room reply opens a thread under the incoming message unless 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.
  • Room roster rides on every channel turn. Threads hold isolated sessions and the agent only sees 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.
  • Edits are corrections, not new traffic. An inbound 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. Re-gating here is a deliberate departure from Chatto, which treats mentions as post-time facts and never re-resolves them on edit (FDR-006 decision 4) — and from the web frontend, whose store reconciles an edited timeline without re-deciding who it addressed. We re-gate because the edit is meant to reach the agent, and Chatto has no turn for an edit to open. The price is that the body-derived fallback is a regex reading, not a resolution: a @bob inside a URL or blockquote reads as a mention on this path only. 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.
  • Processing reactions. 👀 while working, then ✅, ❌, or 🚫 (cancelled) — driven by on_processing_start/on_processing_complete and the reactions setting, never applied to the bot's own events.
  • Room membership is server-side, commands ride over DMs. /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.
  • Length handling. 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.

Auth defaults to closed

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.

Development

Work on this plugin happens against a real Hermes Agent checkout — set that up first, everything else builds on it:

  1. Fetch the Hermes Agent:
   git clone https://github.com/NousResearch/hermes-agent.git
  1. Put this plugin at 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

Verify the symlink actually points here — before trusting any result

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:

  • tests import gateway.* from Hermes, and the failing assertion reads the linked tree's plugin.yaml, not yours
  • a vendor/ refresh appears to do nothing, because the tree that gets exercised is the old one
  • a real gateway run serves an old chattolib while the source you just fixed looks correct

The 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.

Tests

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.

Readability

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:

  • PEP 8 for naming, layout and structure — Ruff enforces most of it mechanically, but the rules below are judgement calls no linter makes for you.
  • Small functions with one job. If a function needs paragraphs of # commentary to explain its flow, split it until the code explains itself.
  • Names that say what they mean. _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).
  • Comments explain why, not what. The code already says what happens; the comment earns its line by recording intent, constraints, or the bug a guard exists to prevent (see adapter.py for the house style).
  • Explicit over clever. Idiomatic Python beats micro-optimised trickery — comprehension over 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.)
  • Docstrings on every public method, following PEP 257 — plus the override marker required by Overriding below.
  • Commits explain why, not what — same rule as comments. The diff already shows what changes; the message earns its body by recording the bug that motivated it, the constraint it honours, or the alternative it rejected. Subject line in the imperative ("Fix auth bypass", not "Fixed" or "auth"), one logical change per commit — a commit that needs "and" in its subject, or a WIP/placeholder subject, wants to be split or finished first. The history after August 2026 is the house style to imitate.

Linting & Type Checking

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.

One rule, several tools — and the send path is not the only case. The same shape appears wherever two tools answer the same question about the same event: _resolve_thread_root decides "is this event ID a root of a thread in this room" for both tools taking a thread_root_event_id (_send_room posting into one, _get_history reading one). Only one of them sends, so this rule does not belong to the pair above — but the drift it prevents is identical: two copies would leave two tools disagreeing about the same event ID, and the caller has no way to tell which one to believe.

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.

Configuration surface

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.

plugin.yaml's version: names a branch, not a release

This plugin ships no releases, so the field says main and is not bumped per feature. The alternative — a 1.0.1 that quietly goes stale, as it did while two tools were added without a bump — promises a release cadence nobody runs.

Hermes does not validate the field (plugins_manifest.py reads it as a plain optional string) and, for a git-class install, never compares it: the update check resolves SHA against SHA via ls-remote (plugins_updates.py:222-234). The one reader that parses it is the release-feed branch, where a non-PEP-440 value yields update_available = None — "unknown", not a false update (_version_is_newer). So the cost lands only if a feed ever appears, and by then it is a decision to make, not an accident to inherit. A day that happens, the feed's version: and this paragraph are the two things that move together.

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.

One config per serving profile, never one per process

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.

Typed access

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.

Overriding

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
    """

Capabilities

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:

  1. Add an entry to _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.
  2. Check whether the 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.
  3. Add a test in TestRegistration if the capability is user-visible.
  4. Update the capability matrix and feature notes in README.md — the user-facing counterpart of the startup list. It may say more than the banner (setup-level features such as markdown or reconnect belong there), but nothing less.

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.

The plugin tools register through two doors

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:

  1. Add it to _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.
  2. Registering it directly inside 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.
  3. 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.

Shell Scripts

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.