SKILL.md 16 KB


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 <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.

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) via client.batch_get_messages / get_message. 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, 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 (one RPC per hop) and returns the ancestors oldest-first in reply_chain.
  • event_ids[]: batch fetch; 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 (sorted by the timestamps each message carries, not the server's ordering). 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 (a reply inside a thread is refused as a root). 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:<room_id>" — 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.
  • 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: <name>, 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/<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.
  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.