# 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: ```bash git clone https://github.com/chattocorp/chatto.git ``` Doc references, distinct jobs — know which one answers your question: - **https://docs.chatto.run/** — Chatto **self-hosting** documentation (deploying and operating a Chatto server). - **https://dev-docs.chatto.run/** — Chatto **developer** documentation (API, permissions, bot behaviour; e.g. the bots-cannot-start-DMs rule). 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.0b20 (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.20, 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//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` and `chatto_send_ephemeral` refuse 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, and `chatto_pin_message` refuses it too — a pin changes what the room shows everyone even though it posts nothing. The read tools are deliberately exempt from *that list* — `chatto_get_message`, `chatto_get_history` and `chatto_search_messages` post nothing and answer nothing, so the mention lists have nothing to say about them — but not from membership: `chatto_get_message` and `chatto_get_history` refuse an unjoined room through `_not_joined_refusal`, because every read RPC they use documents room membership as a precondition (`GetRoomEvents`, `GetMessage`, `BatchGetMessages`), and a bare PERMISSION_DENIED names neither the room nor the way in. `chatto_search_messages` needs no such check: it searches only the rooms the caller can read and the server re-checks every hit. `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. - **A fresh room turn opens with the room behind it.** Every fresh channel message anchors a session of its own (`thread_id = message.id` when no inbound thread root — unconditional, not gated on `auto_thread`, which only rules whether the *outgoing* reply opens a server-side thread), so a non-thread turn would otherwise start empty even when the room talked for hours. The turn therefore carries an `[Earlier in this room]` block built from `get_room_events_around`, filtered to messages older than the trigger (`around` serves both directions; a session start is the past, not the future), rendered by the same shared renderer as the thread block (`_earlier_context_block`, same caps and labelling — two renderers would drift until thread and room quoted differently). No cursor and no `_seeded` bookkeeping: every fresh message roots a *new* session, so a cursor written for one session's frontier would describe another session's past; the one re-dispatch path that hits an existing session (an edit restarting a turn) is covered by the `_session_exists` guard, which fails open so a first turn always seeds. DMs never seed (one session per DM). A reply also names what it answers: `reply_to_text`/`reply_to_is_own_message` are resolved via `get_message` on the target — the gateway renders `[Replying to …]` from them — best-effort, an unreadable target costs the pointer, never the turn. - **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 `PresenceChangedEvent`s. 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. - **Interactive prompt cards thread into their conversation, and their emoji match by normalized key.** `send_clarify` and `_send_exec_approval_prompt` resolve the thread through the same `_resolve_outbound_thread` call as `send()` — an answer to a card is routed by session key, and that key includes the thread ID, so a card at the room root is answered into a session nobody is waiting in (the "Question-Loop" bug: the card worked, the answer never arrived). Emoji matching compares `_normalize_reaction_emoji` of the incoming reaction against keys the card builders also store normalized: the incoming shortcode name is decoded to its display emoji (`_SHORTCODE_TO_PROMPT_EMOJI`) first — Chatto's reaction API and realtime events speak gemoji names, not Unicode (see *Research notes*) — then the normalizer strips variation selectors (U+FE0E/U+FE0F) and folds check-mark variants (✔, ✔️, ☑️, ✓, plus the shortcodes `heavy_check_mark`/`ballot_box_with_check`) onto ✅, because the card seeds ✅ (U+2705) while clients send whatever the emoji picker offers and may add or strip a selector in transit. Keycap digits (1️⃣…9️⃣) keep their combining keycap (U+20E3) — only the VS16 goes. Display forms (card text, seeded reactions, the reply emoji in `_forward_reaction`) stay raw; only *comparison* keys are normalized, on both sides at once. Outgoing reactions are converted to shortcodes by `_shortcode_for_reaction_emoji` — one converter for every `add_reaction` path. The prompt emoji sets live once as module constants (`_PROMPT_NUMBER_EMOJIS`, `_PROMPT_OTHER_EMOJI`, `_PROMPT_APPROVAL_EMOJIS` and their derived normalized key lists) — a fourth hand-copied list is the bug waiting to happen, since a list that forgets normalization rejects valid reactions again. - **Reconnects resume, restarts resume inside a window.** The realtime loop hands `stream_events` the cursor of the last event it finished handling, and advances it only after the handler returns: a raising handler leaves the old cursor so the event is redelivered, and an unreplayable event (`cursor is None`) leaves the previous one intact. `_seen` still suppresses duplicates. A clean `disconnect()` stores the cursor under the profile's home (`state/`), keyed by `_profile_scope_key()`, and `connect()` restores it — but only while it is younger than `CHATTO_RESUME_WINDOW` (default 900 s) and only for the profile that wrote it. A crash stores nothing, and an expired or foreign cursor is refused rather than trusted, so the stream falls back to the live boundary. Do not write on every event: the file is written at shutdown on purpose, and making it per-event needs a throttling decision recorded here first. - **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. - **Per-room toolsets replace, never subtract.** `toolsets_for_source` answers with exactly the toolsets `CHATTO_ROOM_TOOLSETS` names for the turn's room (`room_id:toolset` entries, accumulated per room; a thread turn carries its room's ID), because Hermes' contract for that hook is a *replacement* of `platform_toolsets`, not a subtraction from it. A room without an entry returns None so the platform list stays in charge. A malformed entry is rejected at startup: ignoring it would leave a room that the user meant to restrict on the default toolsets, which fails open. Toolset *names* are not validated here — Hermes drops unknown ones, so a typo yields fewer tools, which is the safe direction. ### 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. **Allowlist entries are user IDs, never logins.** Authorization runs through two doors: the plugin intake (`_is_authorized`) and the gateway's fresh-role recheck, which fires because dispatch builds every source with `role_authorized=True` and re-tests the same env list — matching raw IDs only (`_principal_matches_allowlist` has no login aliases for Chatto; only WhatsApp/SimpleX/Nostr carry alias rules). A login entry therefore passes the intake and then throws `RuntimeStoreError('permission_denied')` in `reauthorize_roles`: no answer, no reaction, a silent drop. That is the bug that cost nickk an evening in October 2026. Logins stay welcome where they are addressing — @-mentions, the roster, `chatto_send_dm` — never where they would be authorization. ## 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: ```bash git clone https://github.com/NousResearch/hermes-agent.git ``` 2. 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: ```bash 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: ```bash 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): ```bash 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: ```bash 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.** ```bash 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: ```bash 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. `chatto_send_ephemeral` is a third caller and posts through `_post_chunks`, so it inherits the split and the partial-failure refusal; a posting tool that called `post_message` directly would be the copy this section exists to prevent. 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), and **`_not_joined_refusal`** answers "may the bot act here at all" for all three (`_send_room` posting, `_get_message` and `_get_history` reading — every read RPC documents room membership as a precondition, so reading is not a way around the send gate). `chatto_search_messages` sits outside that rule on purpose: it never reads a room, it asks for hits and the server returns only those from rooms the caller may read, so a membership gate here would refuse searches the server would answer. Only one of the first pair sends, so neither rule belongs to the pair above — but the drift they prevent is identical: two copies would leave two tools disagreeing about the same room or 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: ```python 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`. The presence crash repeated the pattern one level down: the tests mocked a payload, and every mock they chose agreed on a contract the wire does not have — first a `StrEnum` with `.name`, then an `int` sitting on a `MagicMock`. A real `PresenceChangedEvent` with `status = 1` inside a real `RealtimeEvent` is what pins the wire shape, because the mock can no longer be the thing under test. ### Wire enums arrive as ints **A realtime payload enum field is a plain `int` on the wire** — the protobuf runtime here has no native enum wrappers, so `payload.status` is `1`, not a `PresenceStatus`. Read it through `presence_pb2.PresenceStatus.Name(int(...))` and map that name onto chattolib's enum *by value*, never by reading `.name` directly: `'int' object has no attribute 'name'` is what a `StrEnum` assumption costs. `chattolib.bot` applies the same coercion to the same field, so the shape is the library's own, not ours. Two payload fields have bitten this way (`PresenceChangedEvent.status` and the removed `PresenceChangedEvent.user_id`), which is why the coercion is a rule and not an incident. ### A handler bug is not a broken stream **An exception raised inside an event handler must not be answered with a reconnect backoff.** The event loop cannot tell a handler bug from wire damage, and both look identical to it — but the costs are not equal. A handler crash that the loop treats as a stream break costs a 1s→30s backoff, and every `message_posted` that arrives inside such a gap is silently dropped. A presence label is cosmetic; an inbound message addressed to the bot is the whole product. So a handler that fails on one event fails *that event*: skip it, log the kind and the event ID loudly, leave the cursor where it was, and keep streaming. Reserve the reconnect for exceptions from `stream_events` itself. ## 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: ```python 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: ```bash 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. ## Research notes **A bug whose diagnosis took real digging gets its findings written down here, in its own kind of note — not just the fix in code.** Most bugs in this repo are legible from the diff: the test that fails names the behavior, the fix names the cause. The ones worth recording are the other kind — where the expensive part was *finding out how the system actually works*, and the fix alone does not carry that knowledge: - **The knowledge lives across repo boundaries.** The emoji bug needed three sources read together to diagnose: the adapter's own matching code, the Hermes gateway code that keys answers to sessions (`build_session_key` includes the thread ID — a fact visible only in Hermes, not here), and the Unicode facts themselves (✔️ U+2714 vs ✅ U+2705 are different codepoints; variation selectors U+FE0E/U+FE0F are added or stripped by clients in transit; keycap sequences keep their combining U+20E3). None of these is discoverable from this repo alone. - **The wrong fix looks right.** Normalizing only the incoming emoji, or normalizing on only one of the two comparison sides, fixes the reported ✔️ case and silently breaks others (1️⃣ losing its keycap, a stored key still carrying its selector). What made the fix correct was deriving both sides from one function — a decision a diff does not explain. - **The diagnosis inverted a plausible reading of the report.** The "reactions to questions are broken" report read as a clarify-prompt bug, but ✅ is not a clarify option at all — the failing case was the exec-approval card. Writing that down stops the next reader from re-testing the wrong prompt. ### Reactions travel as gemoji shortcode names, not Unicode The report after the first fix ("still invalid") read as an emoji-variant problem — it was a wire-format problem. Chatto's AddReaction API accepts **only gemoji shortcode names** (`"one"`, `"white_check_mark"`); the server's `resolveEmojiInput` rejects everything else with `invalid_argument: unsupported emoji`, and the realtime `ReactionAddedEvent` echoes the name back. Consequences the adapter code must honour end to end: - **Outgoing seeds and lifecycle reactions are sent as shortcodes** — every reaction goes through `_shortcode_for_reaction_emoji`. Raw Unicode is rejected server-side; before the fix, the two card paths passed display emojis straight to `client.add_reaction`, so **no card ever had visible reaction anchors** and no lifecycle marker ever rendered. - **Incoming events carry the name.** Matching decodes prompt-relevant names back to display emojis (`_SHORTCODE_TO_PROMPT_EMOJI`) before `_normalize_reaction_emoji`, so the comparison domain stays the seeded display form. The prompt-relevant subset lives in `_PROMPT_SHORTCODES`; a client-side check variant arrives as `"heavy_check_mark"` (not ✔️ — the alias fold runs on the decoded glyph). - **The half-map that hid this.** `EMOJI_TO_SHORTCODE` already existed and covered the lifecycle emojis (👀/✅/❌/🚫/🫥), which is why processing markers worked while every seeded card anchor silently failed — a working neighbour made the broken path look sound. The keycap digits, ✏️, 🌀 and ♾️ were missing from that map; the tests that passed did so because they fed Unicode into the matcher, i.e. the mocks agreed on a contract the wire does not have (the *Typed access* failure mode again, one level up). - **Diagnosis source of truth:** the Chatto server repo (`cli/internal/core/emoji.go` for `resolveEmojiInput`, `cli/internal/core/emoji_gen.go` for the accepted names, `reactions.go` for the event carrying `emojiName`). Anything sending a bare emoji to this API is wrong by construction — verify against the server, not against what our callsites happen to pass. So when a diagnosis required that kind of work, add a note here (or extend the relevant *Behavioural contract* entry) recording: what the wrong plausible reading was, which cross-repo sources had to be read together, and what makes the naive fix incorrect. The measure is not thoroughness — it is whether the next agent facing a similar report can skip the digging. The first reader of such a note is the agent that meets the sibling bug in another code path.