--- name: chatto-platform-plugin description: "Use when working with the Hermes Chatto platform plugin: its tools, the Chatto API quirks, or extending the plugin." version: 1.1.0 author: Paul Klumpp license: MIT metadata: hermes: tags: [chatto, hermes, plugin, gateway, adapter, chattolib, tools] --- # Hermes Chatto Platform Plugin ## When to Use Using or developing/debugging the Chatto gateway plugin: its plugin tools (`chatto_send_dm`, `chatto_send_room`, `chatto_list_rooms`), the Chatto API, the vendored chattolib, or adding new tools to the plugin. ## The plugin tools and when to use them ### chatto_list_rooms(name?) — resolve room names to IDs Lists every room the bot is a member of: `room_id`, `name`, `kind` (channel/DM), `threading_mode`, `archived`. 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 (e.g. `chatto-platform:RbmwiHk4vs9a3Ql`) 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 case-insensitively by name OR verifies the ID, then posts. Works for any room the bot is a member of. - `create_thread=true` posts the message as the ROOT of a NEW thread (first chunk = root, further chunks inside), then best-effort `follow_thread`. - `thread_root_event_id` posts INSIDE an existing thread. Mutually exclusive with `create_thread`. - Response carries `thread_root_event_id` + `created_thread`. **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 DM limitation below). ### 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. On `permission_denied`: the recipient must message the bot first, then the existing-room path works. ## 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. - **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) module-level `_resolved_chato_config` set by `hermes_validate_config` in a running gateway; (2) empty `PlatformConfig(enabled=True, extra={})` wrapped in `ChattoConfiguration`, re-filled from `build_profile_secret_scope(get_hermes_home())` (`agent.secret_scope`) — re-reads the profile's own `.env`, works in gateway, cron and CLI alike; (3) clean error. Do NOT use `load_gateway_config().platforms[name]` as fallback (returns None in fresh processes) and do NOT leak a `token` parameter into the tool schema. - **Reuse `_chatto_tool_client(args, cfg)`** for client construction in new handlers instead of re-inlining token/base_url resolution. - **A field resolving to its declared default is a HOLE, not a value**: `base_url` defaults to `chat.chatto.run` — non-empty but wrong when the profile `.env` says otherwise. `ConfigValue.explicit` (commit 8b95ae0) distinguishes user-set values from default leakage; the `.env` override tier checks `explicit`. - **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 client → wrong stub/service name; `permission_denied` → server-side limit. ## Chatto API quirks - **protoc splits consecutive capitals in RPC names**: `StartDM` → stub method `start_d_m` (`_pb/chatto/api/v1/*_connect.py`). Always grep the generated stub for the exact method name — never guess snake_case from the RPC. - **Bot tokens have no admin rights**: `admin_get_member` etc. return `permission_denied`. Resolve users by login via `list_room_members(room_id, search=login)` + `batch_get_room_members(room_id, ids)` across the bot's rooms, matching `user.login` case-insensitively. - `post_message` lives on the messages service (`client.post_message`), not the rooms stub. - **There is no "create thread" RPC** — a thread is created by posting a root message, then posting a reply to the same room with `thread_root_event_id=`; verify the reply's own `thread_root_event_id` echoes the root. - **chattolib client methods are async** (`list_rooms()`, `post_message()` … return coroutines); `list_rooms()` yields `RoomWithViewerState` wrappers — `id`, `name`, `kind` sit on the nested `.room` attribute, NOT top-level. ## Development workflow 1. Edit `adapter.py` or vendored chattolib (`vendor/common/chattolib/`, platform-split via `vendor_path.py`). 2. Syntax gate: `python3 -m py_compile adapter.py` before every commit. 3. Commit + push on the feature branch — the plugin directory IS a git repo. 4. **Gateway restart is REQUIRED after plugin code changes** — the running session keeps the old module in memory. Ask the user to restart, then verify via `tool_call`. 5. **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. 6. **Importing vendored chattolib standalone: honor the protobuf split.** The vendored `_pb` gencode (7.35.x) needs a protobuf runtime at least that new; Hermes venvs can ship an older one (6.x) → `VersionError: gencode 7.x runtime 6.x`. Fix: fresh subprocess with `vendor/platform/` PREPENDED to sys.path and `vendor/common` APPENDED (same order as `vendor_path.setup_vendor_path()`). Platform tags: `linux-x86_64`, `linux-aarch64`, `macos-arm64`. Read `CHATTO_TOKEN`/`CHATTO_BASE_URL` from the profile `.env` for standalone tests. In a persistent kernel an already-imported old protobuf stays in `sys.modules` — always go through a fresh subprocess. 7. **Bundled skills:** register additional skills in `register(ctx)` via `ctx.register_skill(name, path_to_SKILL.md)` — see the `skills/` directory of this plugin.