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.
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:
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.
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:
[Earlier in this thread] line beside a conversation that
already contains it.after=<cursor>) 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.
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.
Persisting costs a full routing-index rewrite, so the write is spent only where it buys something — which is not every turn:
_unpersisted_threads and retried, because a cursor that never
lands is exactly the cursor a restart needs.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.
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.
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.[Earlier in this thread] is reference text, not trusted input: only the
trigger passes the auth gate.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.
The fix has two halves, and the user-visible contract is "a session that starts here knows what it is about":
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.
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.
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).
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).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.
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:
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).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._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.
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 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:
all/here/role entry,
never one per recipient — so recognising them needs no room roster at all.@name as plain text" is
therefore not a rule we implement; it is what an empty list already means.@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.
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.
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.
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.
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.
chatto_search_messages is the first tool whose feature can be absent on the
server side while the API call itself still exists. Chatto returns
FAILED_PRECONDITION for a server whose operator left search.enabled off and
UNAVAILABLE for one whose provider is not reachable — both arrive at the tool
as a failed RPC, and neither means "no message contains these words".
Reporting them as an empty result list would be the one wrong answer: the agent
would conclude the conversation never happened and say so, and the user would go
looking for a missing message instead of a server setting. So the handler catches
those two codes and calls GetSearchStatus, which stays available while search
is disabled, and answers with the real state — disabled, starting, indexing
(with the server's own retry_after), degraded, or no provider. The distinction
that matters to the caller is whether it resolves by itself: indexing does,
disabled never does.
A hit is deliberately a pointer rather than a record. MessageSearchResult
carries a whole Message, so returning _message_out() would repeat the
reaction, attachment and link-preview detail for twenty hits — most of it
irrelevant to "which of these is the one I meant", and all of it out of the
agent's context budget. The leaner shape names where the hit is, who said it,
when and what it says; chatto_get_message is the call that reads one in full.
Thread grouping is the exception: there the row stands for a conversation, so
the root, reply count and latest reply are part of identifying it.
The tool also does not resolve author_login to a user ID. Chatto's own from:
filter in the query does that job with the login the user already knows; a
login→ID lookup on our side would have to scan member directories of rooms the
search has not named yet, and _find_user_by_login deliberately only looks at
DM rooms (a bot cannot read the server-wide directory). author_id is offered
for the case where an ID is already in hand.
CHATTO_ALLOWED_USERS matches user IDs only. That is not a taste decision —
it is what the gateway's own authorization makes survivable. Dispatch builds
every source with role_authorized=True ("the adapter vouched for this
user"), and the gateway treats that flag as a request to re-run the check
itself: reauthorize_roles (upstream gateway/session_ingress_context.py)
first re-tests the platform allowlist — the very same
CHATTO_ALLOWED_USERS list the plugin registered via
allowed_users_env — against source.user_id, and raises
RuntimeStoreError('permission_denied') when it does not match. Its matcher,
_principal_matches_allowlist, knows no login aliases for Chatto; only
WhatsApp, SimpleX and Nostr platforms carry alias rules there.
So while the plugin accepted logins too, the two doors disagreed: a
login-only entry passed the intake, got the message dispatched, and was then
thrown away at the recheck — no reply, no 🫥, nothing in the room. The
October 2026 incident (nickk's messages dropped all evening while paul's
went through, purely because paul's list entry happened to be his ID) was
diagnosed to exactly this fork. Implementing a reauthorize_native_roles
hook, as Discord does for its role model, would have re-decided the
allowlist question in a second place and recreated the drift the
one-implementation rules in AGENTS.md exist to prevent — teaching the
gateway matcher the alias instead would be an upstream change for one
platform. Accepting IDs only costs nothing (the ID is on every message, and
_check_auth's rejection warning prints it for the user to copy) and makes
both doors read one list with one meaning.