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]
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) — 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.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.
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:
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.AGENTS.md first — it is 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.tool_call.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.