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?":
~/.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) — 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.
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.
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) 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.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.
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.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:
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 kernel path and the exact invocation are installation-specific (see the operator's local skill).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.