# Hermes Chatto Plugin A [Chatto](https://github.com/chattocorp/chatto) platform adapter plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Connects your self-hosted Chatto server to Hermes so you can interact with the agent through chat rooms and DMs. All API interaction goes through [chattolib](https://github.com/TheCodemancerLtd/chattolib): a persistent WebSocket realtime connection delivers inbound events, ConnectRPC over HTTP handles outbound messages, reactions, uploads and more. Messages are processed through the full Hermes Agent pipeline (tool use, memory, reasoning) and answered in real time. Tested against Chatto v0.5.0-beta.20 with the vendored `chattolib[realtime]` 0.5.0b20 (realtime protocol 4). chattolib versions track the Chatto server release they target, so this build pairs with Chatto v0.5.0-beta.20; Chatto servers older than v0.5.0-beta.20 are not supported by this build. Before setup, here's the part most people want to know: how Hermes behaves once it's in your Chatto instance. ## Hermes Agent behavior in Chatto | Context | Behavior | |---------|----------| | **Who** | Who may talk to Hermes at all is decided first, before any of the rules below: a Chatto user listed in `CHATTO_ALLOWED_USERS` may, and with `CHATTO_ALLOW_ALL_USERS=true` every Chatto user may. With **neither set, nobody may** — a fresh installation answers no one until you add yourself. See [Allowed Users](#allowed-users). | | **DMs** | For such a user, Hermes answers every DM without an `@mention`. Each DM has its own session. | | **Rooms** | For such a user, opt-in per room: a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` gets answers only when Hermes is addressed (`@mention`, `@all`, `@here`); one listed in `CHATTO_OPTIONAL_MENTION_ROOMS` gets an answer for every message. A room on neither list stays silent — read-only. | | **Threads** | If you reply in a thread, Hermes keeps the thread context isolated from the parent room. The first turn into a thread also carries the messages that came before it — including ones the bot itself posted, such as a cron report nobody replied to — so the agent never answers a reply whose thread it cannot read. The bot auto-follows threads it participates in. | | **Fresh room messages** | A message posted in a room without a thread opens its own session, so it carries what the room said before it (an `[Earlier in this room]` block, same budget as the thread block), and a reply names what it answers (`[Replying to "…"]`). The agent never answers a room message as if the room had been silent. | | **Room awareness** | Every channel turn comes with the member roster attached (who is in the room, with live presence), so Hermes always knows his audience — in every thread, every turn. The roster is fetched once per room and kept fresh from presence events without extra directory lookups; the first 20 members are listed, larger rooms get an "and N more" note. DMs never carry one. | | **Processing indicators** | Hermes adds a 👀 reaction when it starts processing a message, and replaces it with ✅ on success, ❌ on failure, or 🚫 when processing was cancelled. | | **Editing your messages** | An edit within 5 minutes of posting counts as a correction: while Hermes is still working on the original, he restarts with the edited text (🚫 → 👀); if he had ignored or not yet answered the message, the gates are re-checked against the new text — adding a forgotten `@mention` this way works. Edits to messages Hermes has already answered change nothing. Disable with `CHATTO_EDIT_DISPATCH=false`. | | **Typing indicators** | Hermes broadcasts persistent typing indicators while it's working, so users know the bot is active. | | **Message splitting** | Long responses (>10000 chars) are automatically split into multiple messages. | > **Tip:** Nothing is answered until you name the users allowed to talk to Hermes (`CHATTO_ALLOWED_USERS`, or `CHATTO_ALLOW_ALL_USERS=true`) — and rooms stay silent until you list them on top of that. Put a room in `CHATTO_REQUIRE_MENTION_ROOMS` to have Hermes answer only `@mentions` there, or in `CHATTO_OPTIONAL_MENTION_ROOMS` to have him answer every message. ## Capability Matrix Capabilities natively implemented by the Chatto plugin adapter: | Capability | Supported | |------------|--------| | text | yes | | markdown | yes | | threads | yes | | reactions | yes (lifecycle 👀/✅/❌/🚫 outbound; inbound reactions forwarded to Hermes user hooks as `reaction:added` / `reaction:removed`) | | message editing | yes | | inbound edit processing | yes (correction / late-mention / ignored, see behavior table) | | inbound media | yes (attachments downloaded into the media cache, delivered as image/document/etc.) | | message deletion | yes | | typing indicators | yes | | processing notifications | yes (👀/✅/❌/🚫) | | file attachments | yes (chunked upload) | | image / video / audio / documents | yes (native attachments) | | DM participation | only once the person has DMed the bot — a bot cannot start a DM on Chatto, see [DM Initiation](#dm-initiation) | | plugin tools | yes (fifteen tools covering sending, self-deleting notices, reading, searching, pinning, attachments, custom status, link previews and room groups — see [Plugin tools](#plugin-tools)) | | room creation | yes | | room join/leave over DM | yes (`/join`, `/leave`) | | member directory | yes (cached) | | room member context | yes (roster on every channel turn, presence kept live from a per-room cache) | | per-room toolsets | yes (`CHATTO_ROOM_TOOLSETS` replaces the platform toolsets for the rooms it lists) | | presence broadcasting | yes (online, refreshed) | | interactive prompts | yes (reaction-driven clarify + exec approval, see [Interactive Prompts](#interactive-prompts)) | | custom status | yes | | read state management | yes | | auto-reconnect | yes (resumes from the last applied event, duplicates suppressed) | ## Prerequisites 1. **A running Chatto server** — self-hosted and accessible from the Hermes host. See the [Chatto repository](https://github.com/chattocorp/chatto) for installation instructions. 2. **A Chatto bot account** — the adapter authenticates with a bot API key. Create one in Chatto under **Settings → Bot Accounts**. 3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter picks up every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join ` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot. 4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`. 5. **Message search (optional)** — `chatto_search_messages` needs the Chatto server's search feature, which is off by default. Server operators enable it with `[search] enabled = true` and `[search_provider] enabled = true` and restart Chatto; the index is then built from the existing message history, and the server reports **Indexing** until that finishes. Without search, the tool says which state the server is in — off, no provider reachable, or still indexing, with a retry hint — instead of answering "no matches". Every other tool works without it. > **Info:** The adapter speaks realtime protocol 4 (the revision chattolib sends in the subscribe handshake) and needs a Chatto server that accepts it. Ensure your Chatto server is up to date. ## Install ### Option A: Plugin Install (Recommended) ```bash hermes plugins install https://gogs.netdome.biz/paul/hermes-chatto-plugin.git ``` Hermes clones the plugin from that Git URL into its platform-plugin directory and auto-discovers it on gateway startup — no manual registration needed. > **Note:** During install Hermes reads the env vars declared in the plugin's `plugin.yaml` (`requires_env` / `optional_env`) and prompts you for each — connection details plus a few behaviour options. Your answers are written to `~/.hermes/.env`; secrets should still live there only (see [Configuration](#configuration)). ### Option B: Manual Installation Copy the plugin files into the Hermes Agent plugin directory so they sit at `~/.hermes/plugins/platforms/hermes-chatto-plugin/`. Hermes auto-discovers platform plugins on gateway startup — no manual registration needed. Keep that directory name: it is what `config.yaml` keys the platform under (see [Home Channel](#home-channel)), so a different folder name also changes the config path. ## Configuration ### Option A: Interactive Setup (Recommended) Run the guided setup command: ```bash hermes gateway setup ``` Select **Chatto** when prompted, then provide your server URL and bot token when asked. ### Option B: Environment Variables Add the following to your `~/.hermes/.env` file: ```bash # Required CHATTO_BASE_URL=https://chat.example.com CHATTO_TOKEN=cht_BK_your-bot-key-here # Optional: home channel for cron/notification delivery when there is no # conversation context to reply into — list it in one of the mention room # lists below too if the bot should also converse there # CHATTO_HOME_CHANNEL=REljMv5Pgolo6Y9 # Optional: restrict who can talk to the bot (comma-separated user IDs — # see "How to find a user ID" below) # CHATTO_ALLOWED_USERS=Ua1B2c3D4e5F6g7,Ux9Y8w7V6u5T4s3 # Optional: allow any user to talk to the bot (default: false) # CHATTO_ALLOW_ALL_USERS=true # Rooms the bot participates in — without at least one entry below, # every room except DMs stays silent: # CHATTO_REQUIRE_MENTION_ROOMS=REljMv5Pgolo6Y9 ← answer only @mentions # CHATTO_OPTIONAL_MENTION_ROOMS=REljMv5Pgolo6Y9 ← answer every message # Optional: auto-create threads for replies in rooms (default: true) # CHATTO_AUTO_THREAD=true # Optional: 👀/✅/❌/🚫 processing reactions (default: true) # CHATTO_REACTIONS=true # Optional: treat edits of chat messages as corrections (default: true). # A message being processed restarts with the edited text; a message the bot # ignored (e.g. missing @mention) is re-checked against its new text; # already-answered messages stay answered. # CHATTO_EDIT_DISPATCH=true # Optional: how long after posting an edit may still land, in seconds # (default: 300). Older edits are ignored. # CHATTO_EDIT_WINDOW=300 ``` Secrets (`CHATTO_TOKEN`) belong in `~/.hermes/.env`, never in `config.yaml`. ### Option C: config.yaml Or configure via `~/.hermes/config.yaml`: ```yaml platforms: hermes-chatto-plugin: enabled: true extra: base_url: https://chat.example.com home_channel: REljMv5Pgolo6Y9 require_mention_rooms: [] # rooms that answer only @mentions optional_mention_rooms: [] # rooms that answer everything allowed_users: [] # empty = deny all (or set allow_all_users) allow_all_users: true edit_dispatch: true # process message edits as corrections edit_window: 300 # seconds after posting an edit may land ``` > **Note:** `platforms:` is a **top-level** key — it does not nest under `gateway:`. > **Note:** The platform key is the plugin's directory name, `hermes-chatto-plugin`. > The manifest name `chatto-platform` is accepted as an alias, but Hermes rewrites > it to the directory name — write the directory name so the file matches what > Hermes itself saves. > **Note:** Environment variables override `config.yaml` values. ### Environment Variables | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `CHATTO_BASE_URL` | Yes* | ChattoHQ (`https://chat.chatto.run`) | Base URL of the Chatto server (e.g., `https://chat.example.com`) | | `CHATTO_TOKEN` | Yes | — | Bot API key (e.g., `cht_BK_...`) — generate one in Chatto under **Settings → Bot Accounts** | | `CHATTO_HOME_CHANNEL` | No | _(none)_ | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Without it, context-less deliveries have no Chatto target and are not sent. Not exempt from the mention lists. | | `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto **user IDs** allowed to talk to the agent (e.g. `Ua1B2c3D4e5F6g7`) — not logins. Exact match, case-sensitive. See [How to find a user ID](#how-to-find-a-user-id). | | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) | | `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. | | `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). | | `CHATTO_ROOM_TOOLSETS` | No | _(no room restricted)_ | Comma-separated `room_id:toolset` entries, e.g. `RbmwiHk4vs9a3Ql:web,RbmwiHk4vs9a3Ql:file`. Several entries for one room accumulate. A listed room **replaces** the platform's toolsets for its turns, so list every toolset it should keep; unlisted rooms keep the normal platform toolsets. A malformed entry is rejected at startup. | | `CHATTO_AUTO_THREAD` | No | `true` | Auto-create threads for replies in rooms (`true`/`false`) | | `CHATTO_REACTIONS` | No | `true` | 👀/✅/❌/🚫 processing reactions (`true`/`false`) | | `CHATTO_EDIT_DISPATCH` | No | `true` | Treat edits of chat messages as corrections: a message being processed restarts with the edited text, an ignored message is re-checked against its new text, already-answered messages stay answered (`true`/`false`) | | `CHATTO_EDIT_WINDOW` | No | `300` | How long after posting an edit may still land, in seconds; older edits are ignored | | `CHATTO_RESUME_WINDOW` | No | `900` | How long a stream position saved by a clean shutdown may still be used after a gateway restart, in seconds. Inside the window the bot receives what arrived while it was down; an older position is discarded. `0` disables it. A crash leaves no position to resume from. | ## Start the Gateway Once configured, start the gateway: ```bash hermes gateway ``` The bot should connect to your Chatto server within a few seconds. Send it a message — a DM or any message in a room — to test. If nothing happens at startup — plugin failed to load, credentials rejected, server unreachable — run `hermes logs` to see what the Chatto adapter reported. You can run `hermes gateway` in the background or as a systemd service for persistent operation. ## Features ### Realtime Connection The adapter maintains a persistent WebSocket connection to the Chatto server through chattolib's event streaming API. All realtime events — room messages, reactions, DM notifications — arrive pre-decoded and are dispatched to the Hermes Agent pipeline. ### ConnectRPC API (Outbound) All outbound actions — sending messages, reactions, typing indicators, file uploads, etc. — use chattolib's high-level API methods, which handle ConnectRPC calls over HTTP with authentication and error recovery automatically. ### Markdown Support Hermes sends messages with full Markdown formatting. Chatto renders Markdown natively, so code blocks, lists, bold/italic, links, and inline code all display correctly. ### Thread / Reply Support When you reply to a message in Chatto (creating a thread), Hermes responds within that thread. Thread context stays isolated from the parent room — each thread has its own session namespace. With auto-threading enabled (default), fresh room replies open a thread under the incoming message, keeping busy-room timelines clean. The bot auto-follows threads it participates in. #### Thread history in context A thread's session starts empty, so the turn that opens a thread would otherwise be answered with nothing but the reply itself. Turns inside that thread therefore also carry what the thread said, as `[Earlier in this thread]` above the message text — one line per message, oldest first, at most 20 messages and 2000 characters. Over that budget the oldest messages are dropped and counted in an `… N earlier message(s) omitted` line, and the rest arrives with the following turns rather than all at once. For anything further back, the agent can read it on request with `chatto_get_history`. Messages the bot posted are labelled `hermes_bot (this bot):` so it recognises them as its own output. Two situations turn on that. A reply to something the bot posted itself — a cron report, say, which enters no session because the bot's own realtime echo is filtered as a self-event — roots a thread whose first turn would otherwise be blind: without the block the agent answers a message it cannot read. And a message the bot posts into a live thread through a tool never becomes input either, which is what later turns keep carrying it for. Messages in the thread from people outside `CHATTO_ALLOWED_USERS` are prefixed `(unverified)`. Only the message that triggers a turn passes the auth gate, so the rest of a shared thread is reference text, not trusted input; an author the adapter has never seen is marked unverified too. With `CHATTO_ALLOW_ALL_USERS=true` nothing is marked. Nothing is delivered twice: each turn gets what the thread gained since the last one, and a Hermes gateway restart does not lose that position. If the lookup fails, the turn proceeds without the block and the next turn in that thread retries. Direct messages never thread and never seed. How the plugin keeps track of that is a developer question: [CONCEPTS.md](CONCEPTS.md#thread-sessions-and-the-frontier). #### Room history on the first turn Every fresh channel message anchors a session of its own (the same anchoring that keeps thread sessions isolated), so a turn that is not inside a thread would otherwise start from nothing — even when the room has been talking for hours and even when the message replies to something the bot itself posted. Every such turn therefore carries what the room said before it, as an `[Earlier in this room]` block with the same shape and the same 20-message / 2000-character budget as the thread block, including messages the bot itself posted, which never enter a session any other way. Direct messages share one session per DM and never seed; messages inside a thread use the thread block instead. A message that is a reply to another one also names what it answers: the reply pointer `[Replying to "…"]` — `[Replying to your previous message: "…"]` when the answered message is the bot's own — is rendered ahead of the message text. An unreadable or deleted target costs the pointer, never the reply itself. ### Interactive Prompts The plugin replaces the default text-based clarify and exec-approval prompts with reaction-driven ones. A clarify question is posted as a numbered list with one emoji reaction per option (1️⃣ … 9️⃣) plus ✏️ for free-text input; the exec-approval prompt offers ✅ once, 🌀 session, ♾️ always, ❌ deny. The prompt message is edited to a terminal state (e.g. "✅ Alice chose: …") after a reaction resolves it, and timed out prompts are retired with a notice. When more than 9 choices are offered the adapter falls back to the default text-based prompt. Prompt cards are posted into the conversation they belong to (the same thread as the turn that asked), so a text answer typed under the card reaches the session that is waiting for it. Near-identical check-mark reactions (✔, ✔️, ☑️, ✓) are accepted where the card seeds ✅, and a variation selector appended or removed by a client in transit does not change what a reaction means. The option emojis are seeded onto the card as Chatto reaction anchors — reacting to the card picks the option. (Chatto's reaction API accepts only shortcode names server-side; the adapter sends and matches accordingly, so this works regardless of which emoji form your client sends.) ### Room Member Roster Hermes cannot see who else is in a room: unaddressed channel messages are dropped by the mention gates long before they could teach him a name. So every turn in a channel carries the member roster alongside the message text — one entry per member as `@login (Display Name, presence)`, e.g. `@alice (Alice, online), @bob (Bob, offline)`. The bot's own account and deleted users are left out; rooms larger than 20 members end with `… and N more`. Presence is the Chatto status (`online`, `away`, `do not disturb`, `offline`). The roster rides on every channel turn — threads hold isolated sessions, so each turn needs its own copy. That repetition is free of API calls: the adapter keeps a small projection per room (member IDs plus their cached users), fetched once from the directory, and `presence_changed` events patch those users in place, so someone going `away` or `offline` shows up on the next turn without any extra lookup. The bot's own presence refresh never enters the roster. Membership changes (`user_joined_room`/`user_left_room`), leaving a room and reconnects discard the projection, and the room's next turn refetches once. The realtime channel can deliver a server-side projection on subscribe, but not a presence one: it carries the rooms and the user directory, while presence only ever arrives as its own events. The adapter neither requests that snapshot nor consumes it, so the discard-and-refetch behaviour stays as the fail-fresh fallback until it does. If the initial lookup fails, the turn proceeds without the roster and the next turn tries again. ### Reactions Hermes uses emoji reactions for processing lifecycle notifications: - 👀 — added when the agent starts processing your message - ✅ — replaces 👀 when processing completes successfully - ❌ — replaces 👀 when processing fails - 🚫 — replaces 👀 when processing was cancelled Disable the lifecycle reactions with `CHATTO_REACTIONS=false`. Inbound reactions from other users are forwarded to user hooks as `reaction:added` / `reaction:removed` events, carrying the reactor's user ID, the room ID and the message ID. The bot's own lifecycle reactions (👀/✅/❌/🚫) are not forwarded — they would feed the agent its own markers. `CHATTO_REACTIONS=false` disables only the outbound lifecycle reactions; inbound forwarding is unaffected. ### Message Editing and Deletion The adapter supports editing existing messages (via chattolib's `update_message`) and deleting them (via `delete_message`). Streaming replies are delivered as incremental edits to a single message. Inbound edits are processed too — see [Editing Your Messages](#editing-your-messages) under Usage Notes. ### Typing Indicators While the agent works, the adapter broadcasts a persistent typing indicator per room, refreshing it until the response is sent. ### Message Splitting Messages longer than 10000 characters are automatically split into multiple sequential messages (split threshold: 9900 chars to leave room for separators). ### Chunked File / Attachment Upload File attachments are uploaded via chattolib's chunked upload API: 1. `create_upload` — initiates an upload session 2. `upload_chunk` — uploads the file in 256 KB chunks (SHA-256 verified per chunk) 3. `complete_upload` — finalizes the upload and attaches it to the message Images, videos, audio and documents are posted as native Chatto attachments; several images can be bundled into a single message. ### Inbound Media Attachments sent *to* the bot are downloaded into the gateway's media cache (bounded against the download cap) and handed to the agent as image, document, video or audio — an image arrives as a viewable media URL and media type, not as a bare link. A message that is only an attachment (an image or PDF with an empty body) is still delivered rather than dropped. A download that fails is logged and skipped, so the surrounding text — and the turn — still reaches the agent. ### Auto-Reconnect If the realtime stream drops, the adapter reconnects automatically with exponential backoff (1 s → 30 s max, jittered) and honours server-provided retry hints. It hands the server the cursor of the last event it applied, so delivery picks up where it stopped instead of at the live boundary — messages posted during a short outage still arrive. A redelivered event is recognised by an in-memory event-ID list and processed only once. A clean shutdown stores that position, so a restart within `CHATTO_RESUME_WINDOW` (default 900 seconds) also receives what arrived while the gateway was down; a crash stores nothing, and a position older than the window is discarded instead of replaying hours of history. ### Read State Management After discovering rooms, the adapter marks joined rooms as read and dismisses pending notifications (`MarkRoomAsRead` / `DismissAllNotifications`), keeping the bot's unread indicators clean. ### Per-Room Toolsets `CHATTO_ROOM_TOOLSETS` narrows what the agent may do in a named room — useful for a public or untrusted channel where the bot should answer but not reach for the shell. Each entry is `room_id:toolset`, several entries for one room accumulate, and a turn in a listed room gets exactly the toolsets its entries name. This is a **replacement**, not a subtraction: a room listed as `RbmwiHk4vs9a3Ql:web` loses every platform toolset that is normally enabled, including the `chatto-platform` tools, so list everything the room should keep. Rooms without an entry keep the normal platform toolsets. A thread turn belongs to its room and is restricted the same way. A malformed entry stops startup rather than being guessed at — silently leaving a restricted room unrestricted would fail open. ### DM Initiation On Chatto only a person can start a direct message — a bot cannot, not even with `message.post`. So a DM room exists only once the other user has written to the bot at least once; until then the bot has no room to post into, and no setting changes that. The adapter therefore has no way to open a DM at all: it joins *rooms* on request (`/join ` in a DM, see [Managing Rooms over DM](#managing-rooms-over-dm)), and the `chatto_send_dm` plugin tool reports the precondition rather than pretending to deliver. ### Room Creation The adapter can create new rooms via `CreateRoom`. This is available to the agent for organizing conversations or creating dedicated channels for tasks. ### Member Directory User lookups (for mention resolution etc.) go through `GetUser` and are cached per user ID and per handle to reduce API calls. ### Presence Broadcasting Presence is a TTL the server lets lapse, not a flag that stays set: `UpdatePresence` rejects `OFFLINE` outright ("stop refreshing to go offline"). The bot therefore announces itself online on startup and a background task re-announces it every 60 seconds until disconnect cancels that task. Going offline is exactly that cancellation — the adapter never sends an offline status. | Presence Status | API Value | |-----------------|-----------| | Online | 1 | | Away | 2 | | Do Not Disturb | 3 | ## Usage Notes ### Room Participation Chatto distinguishes only direct messages and rooms. Every multi-person room is opt-in, configured with two mutually exclusive lists: - `CHATTO_REQUIRE_MENTION_ROOMS` — Hermes answers a message in these rooms only when it addresses him: by `@login`, by `@display_name`, or with the room-wide handles `@all`/`@here`. Anything else is dropped. - `CHATTO_OPTIONAL_MENTION_ROOMS` — every message gets an answer, addressed or not. A message aimed at someone *else* is acknowledged with a 🫥 reaction instead of an answer. A room on neither list stays silent: it remains joined and is marked as read, but its history is never seeded into the agent's context and its messages never reach the agent pipeline — no answers, no processing reactions. This is also how to quiet server-forced rooms that every account is joined to automatically. Listing a room on both lists is rejected as conflicting configuration at startup. The lists gate inbound replies only. DMs always get a response so `/join` stays reachable, and context-less cron/notification delivery via `CHATTO_HOME_CHANNEL` is unaffected. ### Mention Recognition For a newly posted message, Chatto itself decides who is mentioned and Hermes reads that answer. So the usual cases work without any guessing on our side: - A `@name` that is not a room member does not count — Hermes answers rather than staying silent. - A `@name` inside a link, a URL, a blockquote or a code block does not count. - A `@role` reaches Hermes when the bot holds that role. - `@all` and `@here` reach every member of the room, the bot included. An `@name` nobody holds is treated as plain text. One exception: **edited** messages are read from their text rather than from Chatto's decision, because an edit carries no mention information. There, a `@name` inside a URL, a blockquote or a code block can be mistaken for a real mention. If you edit a message to add a mention, the mention itself is found reliably; the imperfect cases are the ones where a mention is only quoted. ### Editing Your Messages Editing a message you sent is treated as a correction, not as new traffic. What an edit does depends on where the original message stands: | Original message | Effect of the edit | |------------------|--------------------| | Being processed right now (👀) | The running turn is cancelled (🚫) and restarts immediately with the edited text (👀 again). | | Queued behind another turn in the same conversation | The queued message is updated in place to the edited wording — no stale answer later. | | Ignored so far (e.g. missing `@mention` in a channel) | The admission gates are re-checked against the new text; if it passes now — mention added, for example — the message is answered for real. | | Already answered (✅/❌) | Nothing. Answered messages stay answered, so old conversations cannot be re-animated by editing them. | Two limits apply: only edits within `CHATTO_EDIT_WINDOW` seconds of posting are processed (default 300), and edits whose text is a membership command (`/join`, `/leave`) never run those commands again. The whole behavior can be turned off with `CHATTO_EDIT_DISPATCH=false`. ### Allowed Users By default, if neither `CHATTO_ALLOWED_USERS` nor `CHATTO_ALLOW_ALL_USERS` is set, the bot denies all users as a safety measure. Configure one of: - `CHATTO_ALLOWED_USERS=Ua1B2c3D4e5F6g7,Ux9Y8w7V6u5T4s3` — only these Chatto user IDs can interact with the bot - `CHATTO_ALLOW_ALL_USERS=true` — any Chatto user can interact Setting both at once is rejected as conflicting configuration. List **user IDs**, not login names: a login in the list does not authorize anyone (see [How to find a user ID](#how-to-find-a-user-id)). #### How to find a user ID A Chatto user ID is the `U…` string that identifies an account; it never changes, while a login can be renamed. Two ways to get one: 1. **Have the person write to the bot** (a DM, or a message in any joined room) while their ID is not yet listed. The gateway log then names both — login and ID — in the line `Chatto: rejecting message from unauthorized user 'nickk' (Uxxxxxxxxxxxxxxx)`. Copy the ID from there, add it to `CHATTO_ALLOWED_USERS`, and restart the gateway. On a fresh install without any allowlist, set `CHATTO_ALLOW_ALL_USERS=true` temporarily so this first message reaches the log. 2. **Admin Dashboard → Members** shows every account with its ID. > **Warning:** Setting `CHATTO_ALLOW_ALL_USERS=true` means any user on your Chatto server has full access to the agent's capabilities, including tool use and system access. Use this only on trusted, private Chatto instances. For deployment-level hardening beyond the bot's own gates, see the security documentation of your Hermes Agent distribution. ### Home Channel The home channel is the default outbound target for proactive messages — cron job output, reminders, and notifications when there is no inbound conversation context to reply into. Set it via: - `CHATTO_HOME_CHANNEL` env var (room ID) - `config.yaml` → `gateway.platforms.hermes-chatto-plugin.extra.home_channel` (the platform's key is its plugin *directory* name; `chatto-platform` from `plugin.yaml` is accepted as an alias) Without a set home channel, context-less cron/notification deliveries have no Chatto target and are not sent — set one if you plan to use Hermes' proactive output. The home channel follows the same participation rules as any room: a silent home channel still receives cron/notification posts, but the bot converses there only if it is listed in one of the mention lists too. ### Managing Rooms over DM The bot can manage its own room memberships when you DM it: - `/join ` — join a room by ID - `/join #name` — join a room by name (case-insensitive; ambiguous names return the candidate IDs) - `/leave ` — leave a room These commands work only in direct messages with the bot and require you to pass the same user check as normal messages (`CHATTO_ALLOWED_USERS` / `CHATTO_ALLOW_ALL_USERS`). They change membership on the Chatto server itself (RoomService/JoinRoom, RoomService/LeaveRoom), so joined rooms survive gateway restarts and `/leave`d rooms stay left. A channel `/join` reply reports the room's mention-list status: an unlisted room answers with ready-to-paste `CHATTO_REQUIRE_MENTION_ROOMS` / `CHATTO_OPTIONAL_MENTION_ROOMS` lines for `~/.hermes/.env` — that reply is the place to copy the room ID from, since room IDs are hard to find elsewhere. The bot never edits `.env` itself; a gateway restart applies the new entry. Silent rooms are also joined read-only: their history is not seeded into context. Two refusals by design: direct messages cannot be left, and leaving the configured home channel is rejected because cron/notification delivery posts there. If `CHATTO_HOME_CHANNEL` names a room the bot has not joined, the adapter logs a warning at each reconnect — invite the account natively in Chatto or DM it `/join`. ## Plugin tools Beyond answering inbound messages, the agent can act on Chatto on its own initiative. The plugin registers fifteen tools, available to the agent in the gateway and, with `hermes tools`, in the CLI: | Tool | What it does | |------|--------------| | `chatto_send_ephemeral` | Posts a notice into a channel that deletes itself after `ttl_seconds` (default 60, range 5-3600), for progress or status updates that should not stay in the timeline. Same room rules as `chatto_send_room`. Deletion is best-effort: a gateway restart before the TTL expires leaves the notice behind. | | `chatto_list_rooms` | Lists the rooms visible to the bot with their IDs, kinds and membership. Optional `name` filters case-insensitively by substring. Useful for turning a channel name into the `platform:chat_id` address a cron `deliver` target needs. | | `chatto_send_room` | Posts a message into a channel or DM room, by `room_id` or `room_name`. Can post inside an existing thread (`thread_root_event_id`) or open a new one (`create_thread`). Either way the bot follows the thread afterwards, so later replies in it reach the agent. | | `chatto_send_dm` | Posts a message into the DM room you already have with a person, addressed by `user_id` or `user_login`. | | `chatto_get_message` | Reads one message (or several, via `event_ids`, answered in the order you list them) with all its data: body, author, timestamps, reply and thread references, attachments, reactions, pinned and deleted status. `follow_reply_chain` (0-10) additionally resolves the messages that message replies to, oldest-first — use it to resolve a `reply_to=` reference. It works with a single `event_id` only, because a chain belongs to one message. | | `chatto_get_history` | Reads the most recent messages of a room (or of one thread, via `thread_root_event_id`), oldest-first, in a page of up to `limit` messages (1-100, default 20). To read further back, pass the previous answer's `start_cursor` as `before` (or `end_cursor` as `after`); `has_older` tells you whether there is anything left to page to, `has_newer` whether anything landed after the page. One call reads one page; `before` and `after` cannot be combined. | | `chatto_search_messages` | Full-text search over the messages the bot can read. Use it to find something said earlier when you know the words but not the room, thread or day. The query supports required words, `"exact phrases"`, `AND`, and the filters `in:`, `from:`, `before:`, `after:` and `has:attachment`; it can additionally be scoped by `room_id`/`room_name`, `author_id` and `created_after`/`created_before`. `group_by: thread` returns one row per matching thread with its root, reply count and latest reply; `scope: followed_threads` restricts the search to threads the bot follows. Pages of up to `limit` (1-100, default 20) with `next_cursor` for the next one. Requires the Chatto server operators to have enabled search — see [Prerequisites](#prerequisites). | | `chatto_list_room_attachments` | Lists the file attachments of a room with their ID, filename, MIME type and size, in pages of up to `limit` (default 40, offset-paged). `has_more` and `total_count` describe the rest of the list. | | `chatto_list_pinned_messages` | Lists a room's pinned messages with their event ID, body, author and posting time, in pages of up to `limit` (default 20, offset-paged). | | `chatto_pin_message` | Pins or unpins one message by event ID (`pin: true` or `false`). | | `chatto_delete_message` | Deletes one message by event ID. Intended for the bot's own messages; the server decides who may delete what and its refusal is reported as an error. There is no un-delete. | | `chatto_set_custom_status` | Sets the status shown next to the bot's name in Chatto clients: an `emoji` and `text`, optionally clearing itself after `expires_in_seconds`. | | `chatto_delete_custom_status` | Clears the bot's custom status. | | `chatto_fetch_link_preview` | Fetches the link preview Chatto's server would show for a URL: title, description, image URL, site name. Fields are empty when the site provides no metadata — that is not an error. | | `chatto_mark_thread_as_read` | Marks a thread as read for the bot, by `thread_root_event_id`, and reports the previous read marker. | | `chatto_list_room_groups` | Lists the server's room groups with their ID, name, room IDs and sidebar links. | Five boundaries are deliberate, and each tool reports them as an error instead of half-doing the job: - **A channel must be opted in.** `chatto_send_room` and `chatto_send_ephemeral` only post into a room listed in `CHATTO_REQUIRE_MENTION_ROOMS` or `CHATTO_OPTIONAL_MENTION_ROOMS` (DM rooms are never gated). A room on neither list stays read-only — a human message there would never have produced output either. The refusal names both settings so you can paste the room ID into `~/.hermes/.env`. The read tools are exempt from that rule — `chatto_get_message`, `chatto_get_history`, `chatto_search_messages`, `chatto_list_room_attachments`, `chatto_list_pinned_messages` and `chatto_list_room_groups` post nothing and answer nothing, so the mention lists have nothing to say about them. Membership is a separate rule, covered by the last bullet. - **Pinning and deleting are writes.** `chatto_pin_message` and `chatto_delete_message` change what the room shows everyone, so they obey the same opt-in rule as `chatto_send_room`: an unlisted channel refuses the action and names both settings. A DM is never gated. - **The bot cannot open a DM.** Only a person can start a direct message on Chatto, so `chatto_send_dm` needs a recipient who has written to the bot at least once. To reach someone else, mention them in a channel message. - **A name is not an identity.** If several rooms share the name you passed, `chatto_send_room` returns their IDs and asks for `room_id` instead of picking one. Same rule as `/join #name`. The read tools resolve names by the same rule. - **Visible is not joined.** `chatto_list_rooms` lists rooms the bot can see, which includes channels it has not joined; check `is_member` before posting into one. `chatto_send_room`, `chatto_send_ephemeral`, `chatto_pin_message` and `chatto_delete_message` refuse an unjoined room and say how to get in, because the server would reject the write with an error naming neither the room nor the fix. Reading is no free pass: Chatto requires membership for timeline and message reads, so `chatto_get_message` and `chatto_get_history` refuse an unjoined room the same way and name the same remedy. `chatto_search_messages` needs no check of its own — it searches only the rooms the caller can read, and the server re-checks every hit it returns. Credentials never come from the model: no tool takes a server URL or a token as an argument, so the configured bot token cannot be pointed at another host. Inside a channel, mention people in the message text (`@login, …`) — there is no separate mention parameter. Long messages are split at the same limit as every other send (see [Message Splitting](#message-splitting)). If a chunk fails after earlier ones were delivered, the tool reports what it already sent plus a `partial: true` marker, so a retry can repeat only the missing part. A message the tool posts is the bot's own, so Chatto echoes it back and the adapter drops it as a self-event — it never becomes a turn of its own. In a thread, the next human message still reaches the agent with what it said: each turn seeds the thread with whatever landed since the last turn, so a `chatto_send_room` reply is in the session the next time someone speaks there, and a gateway restart in between does not change that. In a room without a thread, the same holds through the room block: the next message's `[Earlier in this room]` fetch reaches back over the tool's messages too, because they are part of what the room said before that message. ## Troubleshooting **First step for every issue below:** look at what the gateway logged around the time things went wrong: ```bash hermes logs ``` The Chatto adapter logs each connection attempt, room discovery (`_refresh_rooms`), rejected users, failed sends and reconnects there — most problems name their cause directly. ### Bot is not responding to messages **Cause**: The bot account is not a member of the room, or the user is not in `CHATTO_ALLOWED_USERS`. **Fix**: Verify the bot is a member of the room (the adapter picks up all joined rooms by default; DM the bot `/join ` to add it). Check that your Chatto user ID is in `CHATTO_ALLOWED_USERS` (see [How to find a user ID](#how-to-find-a-user-id)), or set `CHATTO_ALLOW_ALL_USERS=true`. Restart the gateway. ### Connection refused / WebSocket fails to connect **Cause**: The Chatto server is unreachable, or the URL is incorrect. **Fix**: Verify `CHATTO_BASE_URL` points to your Chatto server (include `https://`, no trailing slash). Test connectivity: ```bash curl -s -o /dev/null -w "%{http_code}" https://chat.example.com/api/connect/chatto.api.v1.ViewerService/GetViewer ``` If you use a reverse proxy (nginx, Apache), ensure WebSocket upgrade headers are configured for `/api/realtime`: ```nginx location /api/realtime { proxy_pass http://chatto-backend; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 600s; } ``` ### Authentication failed **Cause**: The `CHATTO_TOKEN` is incorrect or the bot account has been deactivated. **Fix**: Verify your bot key is valid and the account is active in Chatto (Settings → Bot Accounts). Update `CHATTO_TOKEN` in `~/.hermes/.env` and restart the gateway. ### Protocol version mismatch **Cause**: The Chatto server is running an older version that rejects the realtime protocol revision the adapter's client sends (4, set in the subscribe handshake). **Fix**: Upgrade your Chatto server to a release that accepts realtime protocol 4: ```bash curl -s https://chat.example.com/api/version | jq . ``` If you see a `RealtimeError` with a protocol-related message in the logs, upgrading the server is the fix. ### WebSocket disconnects / reconnection loops **Cause**: Network instability, Chatto server restarts, or firewall/proxy issues with WebSocket connections. **Fix**: The adapter automatically reconnects with exponential backoff. Check: 1. Your server's WebSocket configuration — reverse proxies need upgrade headers (see above). 2. No firewall is blocking WebSocket connections on your Chatto server. 3. The Chatto server is running and healthy. Run `hermes logs` for recent adapter output, or dig through the gateway log directly: ```bash grep -i "chatto\|websocket\|realtime" ~/.hermes/logs/gateway.log | tail -30 ``` ### 401 Unauthorized during operation **Cause**: The bot token was revoked or the account was deactivated while the session was live. **Fix**: Check that the bot account is active in Chatto and regenerate the key if needed. Update `CHATTO_TOKEN` in `~/.hermes/.env` and restart the gateway. ### Bot is offline **Cause**: The Hermes gateway isn't running, or it failed to connect. **Fix**: Check that `hermes gateway` is running. Look at the terminal output or gateway logs for error messages. Common issues: wrong URL, expired credentials, Chatto server unreachable. ## Notes - **Self-hosted friendly**: Works with any self-hosted Chatto instance. No cloud account or subscription required. - **Minimal dependencies**: Only requires `chattolib[realtime]==0.5.0b20`, which is vendored — nothing to install. - **Bounded backfill**: while the gateway process runs, a dropped realtime connection resumes from the last event it applied, so messages posted during the gap still arrive. A clean shutdown stores that position under the profile's home, so a restart within `CHATTO_RESUME_WINDOW` (default 900 seconds) also delivers what arrived while the gateway was down. A crash stores nothing, and a stored position older than the window is discarded rather than replaying hours of history. Nothing is handled twice: redelivered events are recognised and dropped. ## Acknowledgements - This project began as a fork of [jrimmer/hermes-chatto-plugin](https://github.com/jrimmer/hermes-chatto-plugin/), whose groundwork this plugin still builds on — a regular stop when looking for features worth adopting. - All Chatto API interaction rides on [chattolib](https://github.com/TheCodemancerLtd/chattolib) by Felix — vendored under `vendor/`. - Our adapter implementation also took inspiration from [TheCodemancerLtd's Chatto platform adapter](https://github.com/TheCodemancerLtd/hermes-agent/blob/feat/chatto-integration/plugins/platforms/chatto/adapter.py) — the same Felix behind chattolib. ## Contact For questions, bug reports, or help integrating this plugin, contact Paul Klumpp (handle `PaulK`) on ChattoHQ ([https://chat.chatto.run](https://chat.chatto.run)). The `#chatto-hackers` community room on ChattoHQ ([https://chat.chatto.run](https://chat.chatto.run)) is where Chatto plugin developers and users hang out: [#chatto-hackers](https://chat.chatto.run/chat/-/RTKVVgllAweTw3h) ## License MIT — see [LICENSE](LICENSE).