--- 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] --- # Hermes Chatto Platform Plugin ## Where knowledge lives — what goes where Three homes, one rule each. Pick the destination by asking "does this travel with the plugin?": - **This skill** — plugin behavior that holds on ANY installation: what the tools do, debugging procedures tied to plugin code (gates, realtime stream, tool contract), lessons a fresh checkout would rediscover the hard way. If it references `~/.hermes` paths of a particular machine, a particular server, or a local Hermes checkout's quirks, it does NOT belong here. - **The repo root's `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. - **The operator's local skill (`chatto-local-preferences`)** — 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. 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. ## When to Use 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`) or adding new tools to the plugin. Repo-internal rules (editing, linting, vendoring) live in the plugin root's `AGENTS.md` — see Development workflow. ## The plugin tools and when to use them ### chatto_list_rooms(name?) — resolve room names to IDs 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 )`). **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. ### chatto_send_room(room_id|room_name, message, create_thread?, thread_root_event_id?) — post into a channel 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. - Response carries `thread_root_event_id` + `created_thread`. - **Only rooms the bot answers in**: a channel has to be on `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). ### chatto_get_message(room_id|room_name, event_id|event_ids, follow_reply_chain?) — read messages by ID 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. - **Not gated on the mention lists**: reading posts nothing and answers nothing, so any room the server lets the bot see is readable — the server's visibility rules are the only gate. ### chatto_get_history(room_id|room_name, thread_root_event_id?, limit?) — read recent context 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), `has_older` in the result says whether more history exists. 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 either — the server's own visibility rules decide what the bot may read. ### chatto_send_dm(user_login|user_id, message) — DM a user 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. ## Cron delivery patterns - **Split content destination from report destination.** Content goes out via `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:"` — resolve the ID via `chatto_list_rooms`). A `deliver` target without a `:thread_id` segment lands in the channel's main chat. - Pattern: prompt says "post X to room Y via chatto_send_room, mention @user, then answer with a short confirmation" — `deliver` stays `origin` or points at a separate admin channel. - Test any redesigned flow with `cronjob_manage action='run'` before the next scheduled fire. ## Plugin tool contract (ctx.register_tool) — for extending the plugin - **Async handlers need `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()`). - **Handlers must return JSON strings** (`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. - **Early exits raise `_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. - **Numeric arguments go through `_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. - **Config access: never read `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. - **Never put a credential or a server URL in a tool schema.** The configured bot token must not be reachable from an argument: a `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. - **A half-delivered send says so.** The split, the thread routing and the stop-at-first-failure live in `_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. - **Verify in BOTH contexts:** a gateway-session `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. ## Inbound gating — debugging "the bot answered a message it should have ignored" 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: 1. Read `~/.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. 2. Establish the room's policy before blaming the gates: `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]`. 3. In an `[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: - **Fail-open gates degrade silently.** An unresolved handle reads as "not a mention", so the bot barges in on every @colleague message with no log line louder than DEBUG. The fix is a fuller local cache, never a per-handle lookup: an admin-directory call (`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. - **Mentions are extracted from body text** (`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. ## Realtime stream health — a message the bot never saw 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: , reconnecting in Ns` and exponential backoff (up to 30s); inbound messages inside the gap are lost silently. 1. `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. 2. Read the wire shape before reading a payload field: the envelope's `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. 3. Fix the handler, never the loop — the loop cannot tell handler bugs from wire damage, so both cost the same reconnect outage; no handler path may be able to raise. ## Development workflow 1. **Read the repo's `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. 2. **Gateway restart is REQUIRED after plugin code changes** — the running Hermes session keeps the old module in memory. The user's `/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. 3. **Before requesting a restart, verify config-dependent fixes in an isolated subprocess** simulating the cron scope: pop the relevant env vars (`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. 4. **Bundled skills:** add a `skills//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. 5. **Run the suite against a Hermes checkout:** the Hermes kernel is not a plugin dependency and `yaml` is not in the dev group, so a plain run dies at import — the kernel path and the exact invocation are installation-specific (see the operator's local skill). 6. **Mock event payloads to the wire shape, not to the attributes the code happens to read:** a bare `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`.