SKILL.md 8.3 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. Read the repo's AGENTS.md first — it is the rulebook for developing this plugin (contradiction-free rule, vendored-chattolib-only policy, architecture references, agent conduct). It ships in the plugin root next to this skill and is binding for all work on the repo.
  2. Edit adapter.py or vendored chattolib (vendor/common/chattolib/, platform-split via vendor_path.py).
  3. Syntax gate: python3 -m py_compile adapter.py before every commit.
  4. Commit + push on the feature branch — the plugin directory IS a git repo.
  5. 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.
  6. 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.
  7. 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.
  8. Bundled skills: register additional skills in register(ctx) via ctx.register_skill(name, path_to_SKILL.md) — see the skills/ directory of this plugin.