name: chatto-platform-plugin description: "Use when working with the Hermes Chatto platform plugin: its tools, cron delivery patterns, or extending the plugin." version: 1.2.0 author: Paul Klumpp license: MIT metadata: hermes:
tags: [chatto, hermes, plugin, gateway, adapter, chattolib, tools]
Three homes, one rule each. Pick the destination by asking "does this travel with the plugin?" — and note that only the first two ship with it:
~/.hermes paths of a particular machine, a particular server, or a local Hermes checkout's quirks, it does NOT belong here.AGENTS.md — the binding rulebook for WORK ON THE REPO: editing, gates, vendoring, structure rules (e.g. how tools register through two doors). This skill does not repeat those rules; when a lesson belongs there, fix AGENTS.md instead.chatto-local-preferences on this installation) — installation facts and personal workflow: server URL, room IDs, cron jobs, how to run the suite against THIS machine's Hermes checkout, baseline quirks of THIS dev environment (pre-existing failures, checker noise), the user's git/merge preferences. Nothing here depends on it existing.When in doubt: a lesson written because of one bug is upstream-worthy only if another installation could hit the same bug from the plugin's own code — not from a stale local checkout.
Using or developing/debugging the Chatto gateway plugin: its plugin tools (chatto_send_dm, chatto_send_room, chatto_list_rooms, chatto_get_message, chatto_get_history, chatto_search_messages) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's AGENTS.md — see Development workflow.
Lists every room visible to the bot: room_id, name, kind (channel/DM), threading_mode, archived, is_member, label. Optional name filters case-insensitively by substring (unnamed DM rooms are skipped when a filter is set; without a filter they appear with an empty name and label (DM room <id>)).
Primary use case: cron deliver targets. The Hermes cron deliver field takes only platform:chat_id strings and does NO name resolution. When a scheduled job's report should go to a channel, call this tool first, take the room_id, and use it as the chat_id part. Never trust chat names in session/origin metadata as room IDs — resolve live.
Resolves the room by ID or case-insensitively by name, then posts. A name shared by several rooms is refused with the candidate IDs rather than guessed (_resolve_room).
create_thread=true posts the message as the ROOT of a NEW thread (first chunk = root, further chunks inside), then best-effort follow_thread. Channel rooms only — a DM has no threads.thread_root_event_id posts INSIDE an existing thread; the root must be a message of that same room (client.get_message), otherwise the call refuses.thread_root_event_id + created_thread.CHATTO_REQUIRE_MENTION_ROOMS or CHATTO_OPTIONAL_MENTION_ROOMS, same as inbound messages (_room_is_writable); DM rooms are never gated. The refusal names both env vars because that reply is where an operator copies the room ID from.Mentions instead of DMs: to notify a user inside a channel, mention them in the message TEXT (@login, hier ist …) — Chatto renders room-text mentions. A bot cannot deliver a personal notification any other way (see the DM limitation below).
Resolves the room (same _resolve_room as the send tools) and fetches the message(s) — client.get_message for a single event_id, client.batch_get_messages for event_ids. Returns every field: body, author_user_id + author_login (hydrated via batch_get_room_members, best-effort — an unreadable directory costs the names, not the messages), created/updated/deleted timestamps, in_reply_to, thread_root_event_id, attachments (with a video block carrying duration, thumbnail and transcoded variants when the server sent one), reactions, pinned, link preview, thread summary. Use it whenever a reply_to= / in_reply_to reference or any event ID appears in a message or session metadata.
follow_reply_chain (0-10): walks the in_reply_to chain backwards (client.get_message per hop) and returns the ancestors oldest-first in reply_chain. A single event_id only — a chain belongs to one message, so the tool refuses the combination rather than picking one. A hop the server cannot resolve ends the chain.event_ids[]: batch fetch, answered in the order asked for; unknown IDs come back in missing_event_ids instead of failing the whole call. A single unknown event_id refuses cleanly./join remedy rather than a raw PERMISSION_DENIED.Reads the newest messages of a room (client.get_room_events) or of one thread (client.get_thread_events), returned oldest-first (_oldest_first, the same ordering the thread seeding uses — the server's own ordering is not a contract). limit 1-100 (default 20). Paging: before (a previous result's start_cursor) walks towards older events, after (an end_cursor) towards newer ones; the cursors are opaque positions, NOT event IDs, and the two are mutually exclusive because chattolib sends before OR after and would drop the second silently — one call reads one page, and the answer carries start_cursor/end_cursor/has_older/has_newer so the next call needs nothing else. Author logins come from the timeline page's own users_by_id directory first; actors missing there are hydrated via batch_get_room_members. Same room resolution and the same thread-root rule as chatto_send_room — both go through _resolve_thread_root, so the two tools cannot disagree about what a root is; a thread page that omits its root gets it prepended (the root is already in hand from that check).
Not gated on the mention lists — but room membership is still required (GetRoomEvents says so, and so do GetMessage/BatchGetMessages), so both of these tools refuse an unjoined room via _not_joined_refusal rather than handing the model a bare PERMISSION_DENIED.
Full-text search (client.search_messages). Use it when you remember the words but not the room, thread or day — chatto_get_history pages a window you already know, this finds a needle. Same room resolution as the other tools, and likewise not gated on the mention lists. Unlike the message-read tools it needs no membership check of its own: the search covers only rooms the caller can read, and the server re-checks every hit before returning it.
"quoted text" is an exact phrase, AND joins terms, and the filters in:room-name, from:user-login, before:DATE, after:DATE, has:attachment may be combined with them — including alone.room_id/room_name (mutually exclusive, resolved through _resolve_room), author_id, created_after/created_before (ISO-8601, exclusive bounds) and has_attachments. A malformed date is refused rather than dropped: silently ignoring a bound answers a wider question than the one asked.order: relevance (default), newest, thread_activity, thread_created. thread_activity requires group_by: thread and is refused without it, because that is the server's rule and naming both arguments beats relaying INVALID_ARGUMENT.group_by: thread returns one row per matching thread with a thread block (root event/body, room name, reply count, participant count, last reply, latest reply event/body) instead of one row per message. scope: followed_threads narrows to threads the bot follows.limit 1-100 (default 20), next_cursor in the result feeds the next call's cursor. The cursor is bound to the caller and to every other query field, and it walks a live index — a page may repeat or skip a hit that moved while paging. Do not reuse a cursor across different queries.FAILED_PRECONDITION and UNAVAILABLE are translated into the real state via get_search_status(): disabled (operator has to enable search.enabled), starting, indexing (retry — the server gives retry_after_seconds), degraded, or no provider reachable. Never report "nothing found" in those cases; the answer says which of them it is. A hit is a pointer, not a record: fetch it with chatto_get_message for reactions, attachments and link preview.Looks up the user by login or ID and posts into the DM room.
Precondition: the recipient must have DMed the bot once. Bots can NEVER start a DM — by design (Chatto dev-docs, guides/operations/permissions: "Only people can start a DM... A bot cannot start a DM, even with message.post."). No permission or role change lifts this. The handler therefore never calls start_dm: with no existing DM room it refuses and says so (_find_dm_room). A login is resolved among the members of rooms the bot shares with that person (_find_user_by_login), because reading the server-wide user directory needs admin rights.
chatto_send_room/chatto_send_dm called BY the job prompt; the cron deliver field routes only the final confirmation/report (e.g. deliver: "chatto-platform:<room_id>" — resolve the ID via chatto_list_rooms). A deliver target without a :thread_id segment lands in the channel's main chat.deliver stays origin or points at a separate admin channel.cronjob_manage action='run' before the next scheduled fire.is_async=True — the default is sync; an async def handler registered without it returns an unawaited coroutine and the dispatcher fails with tool_result_contract: unsupported result type: coroutine (tools/registry.py: dispatch()).json.dumps({...})), never plain dicts — _normalize_handler_result accepts only str or the multimodal envelope. Annotate them -> str too: the annotation is what makes a regression to dict a type error instead of a runtime break._ToolRefusal, they do not return. The async with _tool_client(cfg) context manager and the _refuse(...) helper keep one result shape per failure; the _handle_* wrapper turns a refusal back into its JSON and catches ChattoError/ChattoAuthError around it. Never except Exception: pass — the AGENTS.md rule about naming each recovery applies here too._int_arg. Every tool argument comes from a model, so a numeric one arrives as "20", "twenty", 0 or 3.5. int() raises on the word, and 0 or default silently widens the answer past what was asked. _int_arg(args, key, default, low=…, high=…) refuses anything it cannot read instead.os.environ in handlers. .env credentials like CHATTO_TOKEN are profile-scoped and NOT visible in cron fires' os.environ, and hermes_validate_config never runs in a fresh cron session. Resolve config through _chatto_config_for_tools() in adapter.py: (1) _resolved_chatto_configs[_profile_scope_key()] — the snapshot hermes_validate_config installed for this profile, only after it accepted the config; the map is keyed by hermes_home_key() because Hermes multiplexes profiles in one process, so a single module-level slot would hand the tools whichever profile validated last; (2) a fresh load_gateway_config() read for the chatto-platform platform entry, so config.yaml's own extra block is honoured, re-filled from the installed secret scope (agent.secret_scope.current_secret_scope()) or, when none is installed, from build_profile_secret_scope(get_hermes_home()), which re-reads the profile's own .env and works in gateway, cron and CLI alike; (3) clean error.base_url parameter would send it as Authorization: Bearer … to whatever host the caller names, and any message the bot reads is something the model could be talked into repeating. Take the client from async with _tool_client(cfg) as client: — it is the only sanctioned way to build one._deliver_chunks; send() turns its result into a SendResult and _post_chunks into a refusal carrying partial: true plus the message_ids that went out, so a retry repeats only what is missing. Add a new send path by calling _deliver_chunks, not by writing a new loop — two copies of the routing rules drift, and send() has to keep marking its own output seen (on_posted) so the realtime echo never comes back as input.tool_call is not enough — also fire a cronjob_manage action='run' test when the tool will run from cron, because cron sessions exercise the config-fallback tier gateway sessions never hit. Debug order: tool_result_contract error → contract bug (async/return type); AttributeError on the client → wrong stub/service name; permission_denied → server-side limit.All admission gates live in _admit_and_build (adapter.py), shared by the posted and the edited path, and run in order: read-only-room drop, user auth, REQUIRE_MENTION drop of unaddressed messages, then the OPEN-room "someone else" check (_mentions_someone_else → 🫥 reaction, no dispatch). DMs skip every mention gate.
Procedure when the bot answered a channel message it should not have:
~/.hermes/logs/agent.log (DEBUG) around the message timestamp — the adapter logs the decision: message_body: … room_kind:, then either message addresses someone else, acknowledging only or dispatching message to Hermes. ~/.hermes/logs/gateway.log (INFO) carries the summarized inbound line with room and session key. Handle resolution logs nothing: it is a pure cache lookup, so its silence is not a signal either way.gateway.platforms.chatto-platform.extra.require_mention_rooms / optional_mention_rooms in ~/.hermes/config.yaml, and the connect-time log line currently joined in N room(s), which tags each room [dm] / [on-mention] / [every-message] / [read-only].[every-message] (OPEN) room the only silencing gate is _mentions_someone_else, which confirms each @handle through _handle_belongs_to_a_user. That check reads local knowledge only — _user_cache, filled by the room roster fetched for the very same dispatch — and makes no API call, so it cannot fail per-request. Its failure mode is a cold or thin cache, not a permission error: an unresolved handle reads as "not a mention", so a handle nobody has ever seen passes. If foreign mentions are getting through, ask what the roster projection knows about that room — not whether a call was rejected.Pitfalls:
PresenceChangedEvent.status is an int on the wire (protobuf runtime without native enums), so payload.status.name raised AttributeError: 'int' object has no attribute 'name' on EVERY presence change — and each one cost a 30s reconnect gap that silently swallowed inbound messages. Coerce through presence_pb2.PresenceStatus.Name(int(...)) and map to chattolib's PresenceStatus by value (the coercion chattolib's own bot.py applies to the same field). Tests must mock the WIRE shape — the existing tests mocked status as a StrEnum (which has .name) and so never caught it; a regression test with payload.status = 1 pins the real contract.admin_get_member) needs permissions a bot account must not hold, and its permission_denied failed every lookup in the field, which fail-open turned into "answer every @colleague message". Prefer what the roster projection already holds, and keep it fed — the OPEN admission path fetches it precisely so this check has something to read.ChattoConstants.MENTION_RE) outside code fences and inline code spans, matching the upstream web frontend — a handle quoted in code does not gate, and case-insensitivity applies throughout.A message that produced no dispatch, no reaction and no log line usually was not swallowed by the gates — it arrived while the realtime stream was in a reconnect gap. _chattolib_event_loop answers ANY exception escaping _handle_realtime_event with unexpected realtime error: <name>, reconnecting in Ns and exponential backoff (up to 30s); inbound messages inside the gap are lost silently.
grep -n "unexpected realtime error" ~/.hermes/logs/agent.log — the exception's name names a handler bug, not a transport failure. An AttributeError/KeyError on an event payload means the handler reads a field that event kind does not carry.actor_id is the identified user of an event; payload shapes live in the DESCRIPTOR blob of vendor/common/chattolib/_pb/chatto/realtime/v1/events_pb2.py, and chattolib's own bot.py dispatch shows the sanctioned field per event kind. Payload shapes drift with chattolib versions.AGENTS.md first — the binding rulebook for all work on this repo: editing, linting/type-checking gates, shell-script checks, typed access, overriding, capabilities, README discipline. This skill does not repeat those rules./restart chat command restarts in-band (gateway exits with code 75, the service manager relaunches); a restart issued from a tool terminal inside the gateway is blocked, because the gateway kills its own child processes on shutdown. Verify via tool_call afterwards.CHATTO_TOKEN, CHATTO_BASE_URL), sys.path.insert the Hermes kernel and plugin dir, set HERMES_HOME, call the handler directly. A green isolated test proves the cron path before burning a restart cycle.skills/<name>/SKILL.md and register it via _register_bundled_skills(ctx), which passes the SKILL.md frontmatter description through — register_skill stores it verbatim and does not read the frontmatter itself, so an empty description leaves the skill listed with nothing explaining when to load it.yaml is not in the dev group, so a plain run dies at import. The invocation — kernel on PYTHONPATH, the two --with packages, and the reason to re-check them when the checkout moves — is AGENTS.md → Development; which checkout sits where is machine-local, and belongs in the operator's local skill where there is one.MagicMock() payload answers any attribute, so tests pass on fields the real event never carries — use MagicMock(spec=[...]) with exactly the proto's fields. Same family: a MagicMock stub client auto-creates plain (non-async) Mocks for unassigned methods, so assign AsyncMock explicitly before any assert_not_awaited.