Research: 009 — Channels
中文版: research.zh.md
Background gathered before design: how mature open-source agents integrate messaging channels, and what the Telegram and SeaTalk platforms actually require. Sources: OpenClaw docs and channel-plugin SDK, NousResearch hermes-agent docs and source, SeaTalk official cs-bot repository and open-platform documentation mirrors.
Prior art — OpenClaw and Hermes
Both products converge on the same architecture, which this spec adopts:
- Thin adapters, shared core. An adapter implements lifecycle (connect/disconnect), outbound send, and inbound normalization into a standard envelope. Session routing, command parsing, pairing/security, and rendering policy live in a shared core. Hermes'
BaseAdapteris exactly three methods; OpenClaw'sChannelPluginstarts fromid+setupand adds optional capability surfaces. - Capability declaration over special-casing. OpenClaw adapters declare what the transport supports (editing, native streaming, media); the core degrades automatically. This is what lets Telegram stream by editing one message while SeaTalk falls back to ack-then-final without
if telegrambranches in the core. - Pairing as the default DM policy. Both default to deny. Hermes and OpenClaw both use 8-character codes from an unambiguous alphabet with a 1-hour TTL; Hermes adds per-user rate limiting and failure lockout, and has a recorded security incident from a fail-open setup path — channels must fail closed.
- Session mapping. Per-peer long-lived sessions keyed by
(channel, account, chat)with/new-style reset; OpenClaw warns that anything coarser shares context across users. - Long-turn UX in three layers. Immediate ack (typing/reaction), one reused editable progress message (cached
(chat_id, status_key) → message_id, throttled edits), final reply as its own message with notifications only on the final message. - Rendering. Don't emit Telegram MarkdownV2 (escaping minefield). OpenClaw renders markdown → Telegram-safe HTML and retries as plain text when the platform rejects it. Tables are normalized (bullets or code blocks).
- Busy-turn input. Queue messages arriving mid-turn; control commands (
/stop,/new) bypass the queue. - Polling vs webhook. Both default Telegram to long polling for local-first deployments; webhooks are an opt-in for cloud hosting.
Telegram Bot API facts
getUpdateslong polling needs no public ingress; the offset acknowledges processed updates, so committing it only after dispatch gives at-least-once handling across reconnects.sendMessagewithparse_mode: "HTML"; 4096-character hard limit per message (we chunk at 4000 on paragraph boundaries).editMessageTextenables the progress-message pattern; edits are rate-limited, so throttle to ≥ 1.5 s between edits.- Inline keyboards (
InlineKeyboardMarkup) delivercallback_queryupdates with the button'scallback_data;answerCallbackQueryacknowledges the tap. setMyCommandsregisters the native command menu;sendChatActionshows the typing indicator.
SeaTalk Open Platform facts
Verified against the official seatalk-io/cs-bot repository and mirrored official docs (the doc site requires a developer login).
- Inbound is webhook-only. No polling or websocket. Events arrive as
POSTJSON:{event_id, event_type, timestamp, app_id, event}. Single chat messages areevent_type: "message_from_bot_subscriber"; the sender is identified byemployee_code. - Callback URL: http or https, must be publicly reachable (intranet IPs fail validation). Tunnels work. On save, SeaTalk posts
event_verificationcontainingevent.seatalk_challenge; the server must echo{"seatalk_challenge": ...}within 5 seconds. Events are retried up to 3 times on non-200. - Signature: every callback carries a
Signatureheader equal tosha256(raw_body + signing_secret)hex digest. The signing secret is per-app, visible and resettable in the developer portal. - Auth for sending:
POST /auth/app_access_tokenwith app id + secret → token valid 7200 s (endpoint limited to 600 calls/hour). API calls useAuthorization: Bearer. Error code 100 = expired token (refresh and retry), 101 = rate limited. - Send single chat:
POST /messaging/v2/single_chataddressed byemployee_code;tag: "text"withformat: 1is Markdown; ~300 messages/minute rate limit; 4096-byte content limit. - Interactive cards:
tag: "interactive_message"withbutton_type: "callback"buttons carrying a customvalue; taps come back asinteractive_message_clickevents with thevalue,message_id, andemployee_code. - Typing indicator:
single_chat_typingendpoint exists. - Org approval: a self-built app's scopes (Send Message to Bot User, etc.) require organization admin approval; outbound IP allowlist is optional and should stay empty for machines with dynamic IPs.
Group chat, threads & rich content (2026-07-08)
Verified live against a real SeaTalk app and a real Telegram bot while building group/@mention/thread/forward support (feature/channel-group-mention-rich).
SeaTalk inbound tags/events (verified live):
- DM
combined_forwarded_chat_history={tag, combined_forwarded_chat_history:{content:[{tag, sender:{email}, message_sent_time, text:{content}|image:{content:url}|file:{filename}}]}}. - DM quoted =
tag:"text"+quoted_message_id. - DM thread =
tag:"text"+thread_id. - Group events:
bot_added_to_group_chat(event.group.group_id+ inviter);new_mentioned_message_received_from_group_chat(fires only when the bot is @mentioned;event.group_id+message.{thread_id, sender, text:{plain_text, mentioned_list:[{username, seatalk_id}]}});new_message_received_from_thread(non-@ thread chatter — ignored, since the bot never acts without an @mention). - Group text lives at
text.plain_text, nottext.content— the DM and group event shapes diverge here and it is easy to read the wrong field. - An @mention of the bot inside a thread still arrives as
new_mentioned_message_received_from_group_chat, withthread_idset — there is no separate "mentioned in thread" event type.
- DM
SeaTalk Open API endpoints used:
- Group send:
POST /messaging/v2/group_chat {group_id, message}. To reply into a thread, putthread_idinsidemessage(message.thread_id), NOT as a top-level sibling — verified live that a top-levelthread_idis silently ignored and the reply lands in the group main chat, whereasmessage.thread_idthreads it and roots a new thread at that id when none exists yet (so a group-main @mention reply threads under the @mention). - Thread read:
GET /messaging/v2/group_chat/get_thread_by_thread_id {group_id, thread_id, page_size}→ response{code, next_cursor, thread_messages:[…]}— the list key isthread_messages, notmessagesorcontent. GET /messaging/v2/get_message_by_message_idfor resolving a single referenced message (quotes).- The group-chat history endpoint (fetching recent group-main messages, as opposed to one thread) is deliberately unused — the corresponding SeaTalk permission is not granted to Coffer's app, so recent-group-main context is never read; the @mention message plus its own thread (if any) is the whole context window.
- Group send:
Two platform limits worth recording:
- SeaTalk does not deliver emoji reactions or non-@ group-main messages to a bot at all — there is no event for either, so "read recent group-main history" is not just unimplemented, it is unbuildable without a permission SeaTalk does not grant self-built apps at our scope.
- Telegram's Bot API cannot fetch chat history at all (no equivalent of
get_thread_by_thread_id), so Telegram group/thread context is never read; the adapter still parses @mentions, replies, and forwards from the inbound update itself and replies into the correct forum topic. Two disclosed Telegram parsing caveats, both documented at the call site intelegram_parse.py: mention entity offsets are matched against plain Python code-point indices even though Telegram's own offsets are UTF-16 code units — an accepted simplification that only drifts when a surrogate-pair character (e.g. an emoji outside the BMP) precedes the mention in the same message; and onlyentitieson a plain text message is parsed for a mention —caption_entitieson a captioned photo/file is not yet parsed, so an @mention inside a media caption is not recognized.
Decisions taken from research
| Decision | Choice | Rationale |
|---|---|---|
| Telegram transport | long polling via raw httpx | local-first, no ingress; the API surface used is 7 small methods — an SDK dependency buys nothing and adds an import-confinement contract |
| SeaTalk transport | webhook → separate listener process + user-run tunnel | webhook is the only option; the constitution requires public-reachable surfaces to be a separate process limited to signed callback paths |
| SeaTalk SDK | none (raw httpx) | the official repo itself is a thin httpx-equivalent; token caching is ~20 lines |
| Pairing parameters | 8 chars, no 0O1I, 1 h TTL, bounded guesses, fail closed | matches both prior arts and Hermes' post-incident hardening |
| Telegram rendering | markdown → HTML, plain-text retry on rejection | OpenClaw-proven; MarkdownV2 escaping is a known bug farm |
| Progress UX | one editable status message, throttled; ack first; final reply separate | both prior arts; degrades naturally on SeaTalk via capability flags |
| Mid-turn input | bounded FIFO queue, control commands bypass | predictable; avoids Hermes' interrupt-by-default surprise |
| Session scope | one long-lived conversation per (channel, chat), /new resets | matches the 1:1 product decision; group chats become new rows later |
Channels as a management plane — build-vs-adopt & landscape (2026-07-08)
Gathered while deciding whether to extend Coffer's own SeaTalk/Telegram adapters (FR-028…FR-041) or adopt an agent-native gateway wholesale.
Build-vs-adopt decision. Do NOT fork OpenClaw or Hermes channel code. Both are TypeScript/Node monorepos (MIT) whose channel layers are coupled to their own agent/session/MCP/memory runtimes; extracting the transport and re-wiring it to Coffer's Python agents — plus running a Node process and carrying fork maintenance — costs more than incrementally improving Coffer's own SeaTalk / Telegram adapters, especially since neither supports SeaTalk (Coffer's primary channel) and Coffer already has Telegram. Decision: reference-and-port the good patterns (album debouncing, edit-to-stream, ack reactions, event dedup) into Coffer's own clean adapters, not the code.
Official-channel landscape (2026-07). Claude Code "Channels" are official local plugins for Telegram/Discord/iMessage but are a research preview, personal-only (no groups), require the session to stay open, and cannot transcribe voice (code.claude.com/docs/en/channels). Claude-in-Slack, Codex-in-Slack, and Cursor-in-Slack are official but Slack-only, cloud-hosted, and single-agent (code.claude.com/docs/en/slack, developers.openai.com/codex/integrations/slack, cursor.com/docs/integrations/slack). Codex/Gemini/OpenCode have no official Telegram; Gemini CLI has no official IM channel at all. SeaTalk is supported by none of them (no official competition for any agent).
Single-track channel-management-plane model. Coffer's channel plane manages only what Coffer hosts — the Coffer-hosted channel (SeaTalk always, plus Telegram for the agents/uses the official bridges don't cover), the one-bot-controls-all-agents moat, managed the same way Coffer manages MCP servers, memory, and skills. Externally-hosted channels — agent-native gateways (OpenClaw/Hermes standalone) and official integrations (Claude/Codex/Cursor-in-Slack, Claude Code's official plugins) — are a non-goal: Coffer neither proxies nor manages them (stacking gateways conflicts with their runtime; a token handed to an external process's config defeats the vault; official cloud integrations have no local credential to hold). Users set those up through the tool's own flow; Coffer's docs point the way. (Earlier framing considered a second "manage external channels" track — dropped as over-engineering / YAGNI.)
North star. One paired bot drives any managed agent, switchable per conversation and per thread (each thread is its own conversation, FR-032).