# Concepts How the plugin works where the reasoning is not obvious from the code. The README describes what the plugin does; `AGENTS.md` states the rules a change has to honour. This file explains *why* a few of those rules look the way they do, for a reader who wants to understand the design before changing it. - [Thread sessions and the frontier](#thread-sessions-and-the-frontier) - [Realtime resume](#realtime-resume) - [Configuration resolution](#configuration-resolution) - [Per-room toolsets](#per-room-toolsets) - [One send path, two callers](#one-send-path-two-callers) ## Thread sessions and the frontier ### The problem A Chatto thread gets its own Hermes session, and **that session starts empty**. Meanwhile two kinds of message never become a turn of their own: - **The bot's own messages.** Chatto echoes a posted message back over the realtime connection, and the adapter drops it as a self-event — correctly, since the gateway is already running the turn that produced it. - **Anything the gateway did not dispatch**, chiefly a message that arrived while the gateway was down. A reconnect inside a running process resumes from the last applied event cursor, but a restart forgets it and leaves a gap (README documents the boundary). So a thread can contain messages that no session holds. The reported case: a cron job posts a build report into a channel, its echo is filtered, and an hour later someone replies to it. The thread it roots is new, its session is empty, and the agent would answer a message it cannot read. The fix is to hand the thread's earlier messages to the session — but only the ones it has not seen. Re-delivering the whole thread on every turn would fill the context with a conversation the session already holds. ### The frontier That "only what it has not seen" boundary is a **cursor**: one opaque message ID per thread, meaning *the session holds this thread's history up to here*. Two facts about it drive everything else: 1. **It has to advance past the turn's own trigger**, not just past the block that was delivered. The trigger is the message text of the turn, so the session holds it; a cursor left behind it hands the next turn the previous question a second time, as an `[Earlier in this thread]` line beside a conversation that already contains it. 2. **The fetch is anchored at the cursor**, which is what makes a cursor usable rather than merely informative. An unanchored page returns the *newest* messages in the thread; an anchored one (`after=`) returns the *oldest* messages past the cursor, walking forward. So the window and the thing being delivered are the same window, and a thread that outgrew `THREAD_CONTEXT_MESSAGE_LIMIT` keeps answering — where an unanchored page put the cursor outside the window, left no tail to find inside it, and muted the thread for good. Two consequences ride along. The root is not part of an anchored page (the server sets `IncludeRoot` false whenever `after` is set), and it is not needed either — everything up to the cursor is already in the session — so the extra root fetch is skipped: one RPC fewer per turn. And a backlog bigger than the window drains over turns instead of arriving at once: each turn takes the next slice and stops there, so nothing is skipped and nothing repeats. ### Why the frontier is stored, not derived The cursor lives twice: in `_seeded_threads` for the running process, and on the session (`chatto.thread_seed_cursor`) so it survives a restart. An earlier version of this code did not persist it, on the grounds that `set_session_metadata` rewrites the whole routing index per call. Instead it rebuilt the partition from the fetch the turn makes anyway: > the newest message that is not ours is the last one the session certainly > holds, because every other person's message arrives as its own turn That premise is false, and the fetch it reasons over is the shape a thread reply *always* produces: the window ends with the turn's own trigger — a person's message that became this turn seconds ago. So the rebuilt partition lands on the trigger, the tail comes out empty, and every bot message before it is skipped. After a restart the agent stops seeing its own output in a thread, which is the one thing the whole feature exists to prevent. Excluding the trigger does not rescue it. The *previous* turn's trigger is just as much a person's message that became a turn, so the next restart skips again. A session's frontier is not a function of the message list at all: every candidate message is either a turn the session holds (person) or one it does not (bot), and the list alone cannot tell a past trigger from a bot message — only the stored frontier can. ### When the write happens Persisting costs a full routing-index rewrite, so the write is spent only where it buys something — which is not every turn: - **A turn that delivered the bot's own output.** That is the one thing no later turn can recover from the message list, so it is the one cursor that has to outlive the process. A turn that carried only the trigger writes nothing: its frontier *is* "the newest message that is not ours", which a restart can work out for itself. - **The turn after a first turn whose cursor found no session to land on.** A thread's first turn has no session yet, so the write is refused; that refusal is remembered in `_unpersisted_threads` and retried, because a cursor that never lands is exactly the cursor a restart needs. ### The blind pass One case has no cursor anywhere. A thread's first turn seeded *nothing* — a reply that opens a fresh thread has no earlier messages — so there was nothing to write; there was no session to write it to either; and the process restarted before the second turn. The thread is blind exactly once. The blind pass re-delivers the bot's own messages, leaves people's messages to their own turns, and records the frontier so no later process is guessing. It can duplicate one block, in the rarer variant where that first seed *did* carry own output. Losing the output instead is the common case and permanent, so the bounded duplicate is the cheaper error. ### What the cursor is looked up with The adapter derives session keys of its own — to find the cursor, to find the turn an edit should cancel — and a derived key is worthless unless it is byte-identical to the one the gateway built. So it delegates to `_source_session_key` instead of calling `build_session_key` itself. That matters more than it looks: ingress runs *before* the runner stamps the profile onto the event, so a key built without the profile namespace puts every bot of a multiplexed gateway into `agent:main:` and names a session nobody is running. ### What this costs - One extra RPC per turn that delivers something: `get_thread_events`, capped at 20 messages. Quiet threads skip it — the cursor means there is nothing to fetch. It used to be two: the unanchored page omits the thread root, so the root was fetched separately on top. - One session-metadata write per turn that delivered own output, plus one retry after a thread's first turn. Not per turn. - `[Earlier in this thread]` is reference text, not trusted input: only the trigger passes the auth gate. ## Room sessions open blind, and why they do not carry a cursor ### The second reported case The thread seed solved "reply to a cron message opens a thread the agent has never seen". The room case is the same failure one level up: a human replies in a channel *without* opening a thread, pointing at a message the bot posted. The reply roots a session of its own — the anchoring gives every fresh channel message `thread_id = message.id`, unconditionally — so the turn is answered by an agent that has never seen the room's conversation, nor the bot's own earlier answer, nor what the reply points at. ### Two half-fixes, one contract The fix has two halves, and the user-visible contract is "a session that starts here knows what it is about": 1. **The reply pointer.** `MessageEvent.reply_to_text` and `reply_to_is_own_message` are resolved by fetching the target with `get_message`. The gateway renders `[Replying to …]` from them — Signal does the same for quotes — and without them the turn reads as a comment from nowhere even when everything else is in place. One RPC per reply, skipped for non-replies; a deleted or unreadable target costs the pointer, never the turn. 2. **The room block.** `[Earlier in this room]`, built from `get_room_events_around`, filtered to messages older than the trigger. `around` serves both directions of the anchor, and a session start is answered with the past, not the future — a message someone typed while the agent was thinking is not "earlier", and handing it over would answer a question the room may not have asked yet. ### Why no cursor, when the thread seed needs one The thread seed's whole machinery — `_seeded_threads`, the session copy under `chatto.thread_seed_cursor`, the blind pass — exists because a thread session *survives across turns*: turn two must know what turn one already delivered, or it repeats and skips. A room session does not survive across turns in the same sense. Every fresh channel message roots a *new* session, so there is no "session frontier" to track: the question each turn asks is "what did the room say before this message", and the trigger answers it on its own. A per-room cursor would be actively wrong here. It would record one session's frontier and hand the *next* message's session a block written for the first one's — delivering messages the second conversation never saw and skipping others. The one dispatch path where the same message *does* hit a session that already exists is an edit restarting a turn, and that is covered by the `_session_exists` guard (fail-open when no store is wired, so a first turn always seeds). ### What this costs - One extra RPC per first turn into a room: `get_room_events_around`, capped at 20 events. The roster fetch already dominates the per-turn budget for a channel turn, and the lookup is skipped entirely for DMs. - `[Earlier in this room]` is reference text, not trusted input: only the trigger passes the auth gate, authors off the allowlist are labelled `(unverified)` by the shared renderer, own output `(this bot)`. ## Realtime resume ### The gap A realtime connection can drop while the gateway keeps running. The server offers two ways to continue: resume after a cursor, or start at the live boundary. Starting at the boundary is silent data loss — events posted during the drop are never delivered, and nothing in the log says so. `chattolib` exposes the cursor as `RealtimeEvent.cursor`, and its docstring defines when a value is safe to retain: after that event has been fully applied, and never when the field is `None` (the event cannot be replayed). The adapter therefore advances `_realtime_cursor` only after the handler returns, and only when the event carried one. Two behaviours follow from that placement rather than from extra code: a handler that raises leaves the old cursor in place, so the event is redelivered, and an unreplayable event cannot wipe a good position. ### What a restart keeps Persisting the cursor extends the resume across process restarts, and the piece is cheap — one value. The cost is in the *bounds*, which is why three of them ship with it: - **Only a clean stop writes.** `disconnect()` stores the last applied cursor; a crash stores nothing, so the gap it leaves is the same gap that existed before. Writing per event instead would buy crash coverage at the price of a file write on every presence change — a trade this plugin does not make silently (AGENTS.md says so, and the file is deliberately written in one place). - **Only a fresh position is used.** `CHATTO_RESUME_WINDOW` (default 900 s) decides how much of an outage may be replayed. Without it, a gateway that was down overnight would answer yesterday's messages on startup; with it, an old position is discarded and the stream starts at the live boundary. That is also what the server would do anyway — chattolib requests `INITIAL_STATE_LIVE_ONLY`, so a cursor it cannot resume degrades to the live boundary rather than failing. - **Only the profile that wrote it reads it.** The file lives under the profile's own home and carries `_profile_scope_key()`, the same key the configuration cache and the platform registry use. Under multiplexing, a position written by one profile must never be spent on another profile's stream — the events differ even when the server is the same. A stored value that fails any of these checks is refused, not repaired: the adapter then behaves exactly as it did before the file existed. ## Configuration resolution AGENTS.md owns the rules here — one field, three coordinated names, every path parses by kind. What is not obvious from them is *which configuration a tool call sees*. The gateway multiplexes several serving profiles through one process, so a process-wide cache would hand one profile another profile's credentials. Cached configurations are keyed by the profile scope Hermes itself uses, and secrets come from the scope the gateway installed rather than from `os.environ` — under multiplexing the environment holds the *launch* profile's values, not the serving one. A tool call that resolved its config from the environment would quietly answer with the wrong bot's token, which reads as a permissions error on the server. That is also why a configuration that resolved *without* a token is not cached at all. Caching it would pin "token not configured" for the life of the process, so a later call in a properly scoped context would keep being served the degraded configuration long after its scope came back — a failure that persists exactly as long as nobody restarts the gateway. ## Mentions: one decision, two sources `_Mentions` answers one question — did this address us, or somebody else — and both admission gates read only that. The interesting part is where the answer comes from, because the two inbound paths are not equally informed. **The posted path asks the server.** Chatto resolves mentions when a message is created and reports the targets on the event (`MessagePostedEvent.mentions`). `includes_viewer` is the server's own answer to "did this target include me", fixed at post time and unaffected by membership since. Three properties make this better than anything we could compute: - Broadcast and role targets arrive **unexpanded** — one `all`/`here`/`role` entry, never one per recipient — so recognising them needs no room roster at all. - Targets that resolved to nobody are **omitted** rather than reported as failures. "Mentioning a user who isn't a room member leaves the `@name` as plain text" is therefore not a rule we implement; it is what an empty list already means. - Roles work, which the previous regex could not express: `@moderator` reaches a bot that holds the `moderator` role, and there is no handle to match it against. The web frontend does the same thing in `packages/chatto-client` (`addressedMessage`: `message.mentions.some(m => m.includesViewer)`), so this is the platform's own answer rather than a second opinion. **The edited path reads the text.** `MessageEditedEvent` carries only `room_id` and `message_event_id`, and the hydrated `Message` has no mention field — the mention resolution was deliberately kept out of the message resource. There is no payload to read, so the fallback re-derives candidates from the body with the same regex as before. **Why re-gate at all, when Chatto does not?** FDR-006 decision 4 states that mentions are post-time facts and that editing never re-resolves them. The frontend honours that by treating an edit as a timeline reconciliation — `store.ts` refreshes the window and returns, and only `messagePosted` feeds `addressedMessage`. We deliberately do not: a user who types a message, forgets the `@mention`, and adds it by editing expects an answer, and that is a promise this plugin makes. Chatto's rule is uncontroversial in a client that has no turn for an edit to open. **What that costs.** The fallback is a text reading, not a resolution, so it inherits the regex's blind spots: a `@bob` inside a URL, a blockquote, a link label or a link destination reads as a mention. The same text posted rather than edited reaches the server path and is classified correctly. Two paths that answer the same question differently is exactly the drift risk this design usually avoids; the containment is that they converge on one type, and the only place that fills it differently is the one place where no better information exists. ## Per-room toolsets ### Why the hook exists at all Hermes has no per-chat tool policy of its own. The adapter hook `toolsets_for_source(source)` is the only door: the gateway asks the adapter, per turn, which toolsets to enable. The webhook adapter already uses it for per-route tool lists, which is the precedent this follows. ### Replacement, not subtraction The hook's contract is that a returned list *replaces* `platform_toolsets` for that turn — the gateway copies the override into the platform's entry and resolves it through the same `_get_platform_tools` path. Subtraction would need a second decision ("minus these, from what?"), and a room entry that removed `terminal` would otherwise silently keep everything else. Replacement makes the entry self-contained: what is written is what the room gets. The cost is that a user must list every toolset the room keeps, including `chatto-platform` if the agent should keep its Chatto tools — which is why README says so in the tool documentation and in the variable table rather than leaving it to be discovered. An unlisted room returns `None`, the hook's "no opinion" answer, so a config with no entries changes nothing. ### Keyed on the room, validated at startup The entry key is `chat_id`, which for this adapter is the room ID the realtime event carried — not model input, so it cannot be talked around. A thread turn keeps the room as `chat_id` and carries the thread separately, so one entry covers a room's threads without a second lookup. The entry *syntax* is validated at startup because a malformed entry would otherwise be skipped, leaving a room the operator meant to restrict on the default toolsets — the one failure direction that is invisible in logs and dangerous. The toolset *names* are deliberately not validated here: Hermes drops unknown ones when it resolves the list, so a typo yields fewer tools, which is the failure direction that hides nothing. ## One send path, two callers A user cannot tell whether the agent replied on its own or through a tool, so the two paths must not behave differently. Both go through `_deliver_chunks`, which owns the split at the 9900-character limit, the routing of a split answer as one answer (the first chunk opens or joins a thread, later chunks carry that root), and the stop-at-first-failure. The callers differ only in what they do with the result: `send()` shapes it into a `SendResult`, `_post_chunks` into a refusal that names which chunk failed and lists the IDs that went out — so a retry repeats only what is missing instead of duplicating a message the room already has.