SKILL.md 8.0 KB


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

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:<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.
  • 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=<root 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/<tag> 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.