Channel Subsystem — Requirements and Invariants#
This document states what the channel layer must hold to: the interface each platform adapter presents, how the two send paths relate, how sessions are keyed, and where the design boundaries sit. The comparison sections record what OpenClaw and hermes do at each of these points and why our answer differs.
For the layer's structure and message flow, see
design.md.
1. The abstraction#
1.1 Channel ABC#
openprogram/channels/base.py defines the contract every adapter satisfies:
@dataclass(frozen=True)
class MessageHandle:
platform: str
account_id: str
target: str
message_id: str
@property
def editable(self) -> bool: ...
class Channel(abc.ABC):
platform_id: str = ""
@abc.abstractmethod
def run(self, stop: threading.Event) -> None: ...
def send_text(self, target, text) -> Optional[MessageHandle]: ...
def send_text_full(self, target, text) -> SendResult: ...
def edit_text(self, handle, new_text) -> bool: ...
def edit_text_full(self, handle, new_text) -> SendResult: ...
run(stop) is the sole abstract method because inbound event loops differ too
much to unify: discord.py's Gateway, Slack Socket Mode, and the Telegram and
WeChat long polls have no common shape. Everything outbound does have a common
shape, so send_text / edit_text ship as concrete defaults on the base class,
implemented over _transport. An adapter overrides them only when a
platform-native SDK buys something raw HTTP cannot — mention parsing, attachment
upload.
MessageHandle is the unit of "a message I sent and may later edit". All four
fields are strings so a handle survives serialization: one process can send and
another can edit, which is what lets a cron-driven caller and the dispatcher
address the same message.
1.2 Two entry points, one implementation#
Outbound traffic has two entry points by design, serving two different callers:
Entry A (stateless, cron-friendly) outbound.send(channel, account, user, text)
Entry B (stateful, keeps message_id) adapter.send_text(target, text) -> handle
adapter.edit_text(handle, text)
↓ both call
implementation layer _transport.post_message / patch_message
HTTP call + chunking + credential loading
Keeping both entry points is a requirement, not an accident. Entry B needs adapter state because progress streaming has to remember which message to edit. Entry A must work with no adapter instance in the process at all — the reason is in §5.F.
The invariant is that they share one implementation. _transport owns the HTTP
call, credential loading, and chunking; neither entry point reimplements them.
1.3 Structured send results#
_transport.post_message and patch_message return a SendResult carrying
ok / message_id / error_kind / error_detail / retryable. error_kind
is one of auth / rate_limit / bad_target / network / not_supported /
unknown, inferred from HTTP status via _classify_http_status and from
platform-specific error descriptions via _telegram_kind_from_description and
_slack_kind_from_error.
A bool return cannot distinguish a transient network failure from a permanent
auth failure, so it cannot support intelligent retry or an accurate UI message.
The bool-returning forms (outbound.send, Channel.send_text,
Channel.edit_text) remain for callers that only need success or failure; the
_full variants expose the structured result.
1.4 Neutral inbound message#
_message.py:ChannelMessage is the platform-neutral inbound structure. It is a
frozen dataclass with text / chat_id / user_id / user_display /
chat_type / ts / reply_to_id / quoted_text / thread_id /
attachments (a tuple of Attachment download descriptors). Each of the
four adapters parses its platform-native object into a ChannelMessage at the
entry point.
The base pipeline consumes quoted_text (prepended as a > quoted block) and
attachments (downloaded via _attachments, small images forwarded as vision
input). thread_id is parsed but not yet folded into the session key.
1.5 Inbound dispatch#
dispatch_inbound(channel, account_id, peer_kind, peer_id, user_text, user_display, progress_stream=False) -> Optional[str] handles a message
end to end:
- Look up
session_aliases/bindings→ decide agent_id - Compute
session_keyperagent.session_scope - Apply the
daily_reset/idle_minutesreset policy _load_or_init_sessionwrites SessionDB- Build a
TurnRequestand callprocess_user_turn - Append the reply to SessionDB
- Broadcast a
channel_turnenvelope to webui
With progress_stream=False it returns the complete reply string and the
adapter sends it. With progress_stream=True the dispatcher drives the channel
directly through send_text / edit_text, so tool events reach the user while
the turn is still running.
1.6 Platform registration#
channels/__init__.py splits registration into _BUILTIN_CHANNEL_CLASSES (the
four built-ins, always present) and _PLUGIN_CHANNEL_CLASSES (externally
registered). A plugin registers either by declaring
[project.entry-points."openprogram.channels"] in pyproject.toml, scanned at
startup through importlib.metadata.entry_points, or by calling
register_channel(name, cls) from a plugin hook.
Built-ins take priority: a plugin claiming a built-in name is ignored rather
than allowed to override it. CHANNEL_CLASSES remains as a dict-like proxy over
both, so existing callers are unaffected.
2. How other projects solve this#
There are two comparables: OpenClaw (the source we forked from, TypeScript) and hermes (a dedicated chat-bot project, Python). Neither opencode nor claude-code has a channel subsystem — their surfaces are CLI/TUI/Web/IDE, addressing a human sitting at the front end rather than plugging into a Discord or Slack group.
2.1 OpenClaw#
Source: references/openclaw/src/channels/ plus
references/openclaw/extensions/{discord,slack,telegram}/.
Layout: the core src/channels/ holds many fine-grained files (routing /
account / approval / typing / draft-stream / health-check /
thread-bindings-policy), and each platform gets its own directory under
extensions/{name}/ — discord alone has 70+ files, slack 40+, telegram 35+.
Plugin SDK (src/plugin-sdk/channel-*.ts, 50+ contract files) isolates the
core from platform implementations completely. The core sees only abstract
interfaces:
ChannelMessageSendAdapter // send capability
ChannelMessageLiveAdapterShape // live message editing (draft → live-preview → final)
ChannelApprovalAdapter // reaction ✓/✗ confirmation + timeout/retry
ChannelMessageActionAdapter // button/menu action handler
ChannelOutboundAdapter // cross-process send also goes through the adapter
Streaming edit (src/plugin-sdk/channel-streaming.ts +
extensions/discord/src/draft-stream.*): a message has three lifecycle states.
draft → live-preview (throttled edit) → final
A draft goes out, the message is edited continuously while the tool runs, and the pipeline finalizes at the end. Throttling is built into the pipeline.
Reaction approval (src/channels/ack-reactions.ts +
extensions/discord/src/approval-native.ts):
type ChannelApprovalAdapter {
onApprove, onDecline, onTimeout
}
When a dangerous tool fires the bot adds a ✓/✗ emoji reaction, the user clicks it, and the adapter notifies the dispatcher. The full lifecycle covers timeout, retry, and cancel.
DurableMessageSendResult: the send return value carries message_id, edited_ids, and a retry policy, supporting receipt tracking and delivery confirmation.
Health check (health-check-adapter.ts): probes each adapter's availability
at startup and degrades gracefully on failure, so one dead platform does not
take down the whole worker.
Registration: a plugin manifest — each extension's openclaw.plugin.json
declares its channels capabilities — with the core loader scanning
extensions/*/ or npm packages, dynamically loading and lazily instantiating.
2.2 Hermes#
Python, interfacing with 14+ platforms. Its design philosophy is simpler than OpenClaw's: no Plugin SDK layer, and a single file holds a complete adapter (base is 1500 lines).
BasePlatformAdapter ABC (gateway/platforms/base.py):
class BasePlatformAdapter(ABC):
async def send(self, chat_id: str, content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None) -> SendResult
async def edit_message(self, chat_id: str, message_id: str,
content: str, finalize: bool = False) -> SendResult
async def send_draft(self, chat_id: str, draft_id: int,
content: str, metadata=None) -> SendResult
async def send_typing(self, chat_id: str,
metadata=None) -> None
async def create_handoff_thread(self, parent_chat_id: str,
name: str) -> Optional[str]
Five-plus async abstract methods, all returning a SendResult dataclass with
message_id / retryable.
Neutral message structure
@dataclass
class MessageEvent:
text: str
message_type: MessageType = MessageType.TEXT
source: SessionSource # platform, chat ID, user ID, thread_id
media_urls: List[str] = [] # cache paths downloaded to local
reply_to_message_id: Optional[str] = None
auto_skill: Optional[str | list[str]] = None
channel_prompt: Optional[str] = None
@dataclass
class SessionSource:
platform: Platform
chat_id: str
chat_type: str = "dm" | "group" | "channel" | "thread"
user_id: Optional[str] = None
thread_id: Optional[str] = None
guild_id: Optional[str] = None
parent_chat_id: Optional[str] = None
The adapter translates platform-native → MessageEvent; the dispatcher sees
only MessageEvent.
Two-dimensional session key isolation
(build_session_key(source, group_sessions_per_user, thread_sessions_per_user)):
DM: agent:main:{platform}:dm:{chat_id}[:{thread_id}]
Group: agent:main:{platform}:group:{chat_id}[:{thread_id}][:{user_id}]
Threads share across users by default, groups isolate per user by default, and per-channel configuration overrides both.
Progress streaming (gateway/run.py:_edit_progress_message()):
async def _edit_progress_message(message_id: str, content: str):
result = await adapter.edit_message(
chat_id=source.chat_id,
message_id=message_id,
content=content,
)
A tool starts, adapter.send posts a placeholder, the message_id comes back,
tool stream events trigger _edit_progress_message(message_id, latest_text),
and finalize=True closes it out. _roll_progress_overflow_if_needed() handles
the case where progress lines exceed the platform character limit: the first
group edits the current bubble, later groups become new bubbles.
Debounce merging (base.py:2812-2876):
class TextDebounceState:
event: MessageEvent
task: asyncio.Task | None
first_ts, last_ts: float
async def _queue_text_debounce(session_key, event):
"""merge consecutively arriving texts of the same session into one, delay 0.35s, hard cap 1.0s"""
Three messages sent in a row ("hi", "you there", "got a question") reach the agent as one merged turn rather than three agent runs.
Quick-command bypass (base.py:3205-3219):
if should_bypass_active_session(cmd): # /stop, /new, /reset, /approve
await self._dispatch_active_session_command(...)
/stop and /approve take a fast path, skipping the session queue and not
waiting for the agent's current task.
Attachment local caching:
def cache_document_from_bytes(data: bytes, filename: str) -> str:
"""synchronously write to cache_dir, filename doc_{uuid12}_{original name}"""
def cleanup_document_cache(max_age_hours: int = 24) -> int:
"""delete caches older than 24h"""
Telegram URLs are downloaded locally before their one-hour expiry so the agent can read them repeatedly, with cleanup after 24h.
DeliveryRouter (gateway/delivery.py):
class DeliveryTarget:
"""origin | local | telegram:123 | slack:..."""
platform: Platform
chat_id: Optional[str] = None
class DeliveryRouter:
async def deliver(content, targets, ...) -> Dict:
"""Route to all targets via adapter instances."""
The outbound.send equivalent also goes through adapter instances rather than a
separate raw HTTP path.
Approval flow: text commands rather than reactions.
async def _handle_slash_approve(self, event):
"""Handle /approve — unblock waiting agent thread(s)."""
_pending_approvals: Dict[str, Dict[str, Any]] # session → pending
# tool thread: Event.wait() blocks
# /approve command: Event.set() wakes it up
Simple and stable. The adapter layer does implement send_reaction, but
reactions are not on the approval critical path.
Platform registration (gateway/platform_registry.py):
@dataclass
class PlatformEntry:
name, label, adapter_factory, check_fn,
validate_config, install_hint
platform_registry.register(PlatformEntry(...))
adapter = platform_registry.create_adapter("slack", config)
Built-ins take a hardcoded fast path; plugin platforms self-register through the registry.
3. Three-way comparison#
| Aspect | OpenProgram | OpenClaw (fork source) | Hermes |
|---|---|---|---|
| Base abstract methods | 1 (run) + concrete send/edit defaults |
5+ (SendAdapter / LiveAdapter / ApprovalAdapter etc.) | 5+ (send/edit/draft/typing/handoff) |
| Neutral message structure | ChannelMessage dataclass |
ChannelMeta with media/richtext/components |
MessageEvent + SessionSource dataclass |
| Send return value | SendResult (ok/message_id/error_kind/retryable) |
DurableMessageSendResult (message_id/edited_ids/retry policy) |
SendResult (message_id + retryable) |
| Dispatch signature | sync → str, or progress_stream=True |
async streaming pipeline (draft → live → final) | async → streaming events |
| Session isolation | session_scope 4 enums |
dmScope hardcoded + thread-bindings-policy |
two-dimensional (chat × user × thread) |
| Edit interface | edit_text on the base class |
complete (ChannelMessageLiveAdapterShape) | built-in |
| Progress stream | dispatcher drives send_text/edit_text | three stages, throttling built in | edit_message + automatic overflow splitting |
| Approval mechanism | text-command bridge (_question_commands.py) |
reaction ✓/✗ + onApprove/onDecline/onTimeout | /approve text command |
| Debounce merging | none | unknown | 0.35s delay + 1s hard cap |
| Retryable signal | SendResult.retryable |
DurableMessageSendResult with backoff | SendResult.retryable |
| Health check | none | health-check-adapter.ts startup probe |
unknown |
| Receipt tracking | none | yes (delivery confirmation) | unknown |
| Structured replies | text only | embed/button/menu | partial |
| Attachment handling | download to state dir + vision input (_attachments) |
cached | UUID-prefix + 24h cleanup |
| Outbound API | outbound.send shares _transport |
goes through adapter instances | DeliveryRouter(adapters: dict) |
| Process model assumption | multiple deployment forms (lib + worker + script) | single daemon process | single gateway process |
| Chunking implementation | _transport._chunk, plus adapter-local copies |
unified within the platform plugin | unified (truncate_message) |
| Platform registration | built-in dict + entry-point plugins | Plugin SDK (manifest + dynamic loader) | hybrid (built-in + registry) |
| Language | Python | TypeScript | Python |
4. Design boundaries#
4.1 Why run stays the only abstract method#
Mandating send / edit / react as abstract would force every adapter to
implement capabilities its platform may not have. Instead the base class
provides working implementations over _transport and lets an adapter override
what it can improve. An adapter that implements nothing beyond run is still
fully functional for send and edit.
4.2 Why WeChat is the hard case#
The iLink API does not support editing a sent message. MessageHandle.editable
encodes this: WeChat handles carry an empty message_id and report
editable == False, and edit_text_full returns
SendResult.fail("not_supported", ...) rather than raising or faking the edit
by deleting and reposting.
This is why edit capability is expressed as a property of the handle rather than as an abstract method a platform must implement. A platform that cannot edit reports so through the same return type every caller already handles, and no caller has to special-case a platform name.
4.3 Cross-platform edits are refused#
edit_text_full checks handle.platform != self.platform_id and returns
SendResult.fail("bad_target", ...). Coordinating across adapters is the
caller's concern; the base class holds the line that one adapter edits only its
own platform's messages.
4.4 _conversation.py responsibilities are split#
Routing, session-key computation, session persistence, dispatcher invocation,
and webui broadcast live in separate modules: _session_routing.py,
_session_store.py, _broadcast.py, with _conversation.py retaining the
end-to-end dispatch_inbound flow. This follows the repository's preference for
hierarchical code structure.
5. Rationale#
A. Progress streaming is wiring, not a new feature
The dispatcher already emits tool_use / stream_event / tool_result
envelopes (see agent/_event_parsing.py), and dispatch_inbound._on_event
already subscribes. What made streaming possible was the abstraction: a send
that returns a message_id and an edit_text that can act on it. With
MessageHandle and _transport.patch_message in place,
progress_stream=True is the dispatcher consuming an event stream that was
already there.
B. Adding per-adapter methods without a shared implementation multiplies cost
Adding edit directly to each of the four adapters without a shared transport
would mean four send implementations plus four edit implementations plus four
react implementations, doubled again by the outbound path. Routing both entry
points through _transport is what keeps a new operation to one implementation
rather than eight.
C. Hermes's advanced mechanisms are deferred deliberately
Debounce merging, quick-command bypass, and attachment caching are optimizations hermes arrived at after running production traffic volumes. They are not required at OpenProgram's current request rate. The order is to get the abstraction right first and add these when the problems appear.
D. Why OpenClaw is not copied wholesale
Three reasons, from shallowest to deepest.
There is no Python implementation to copy. OpenClaw is entirely
TypeScript/Node.js (pnpm-workspaces + tsdown build); src/bindings/ holds
one TS file, and packages/sdk/ and packages/plugin-sdk/ are all TS. The only
five .py files are CI scripts and skill tooling, unrelated to channels.
OpenClaw provides neither a Python binding nor a Python SDK, so reuse means
re-implementing the design rather than importing it.
Language alone is no barrier to borrowing, though: a TS interface maps to a
Python Protocol or abc.ABC, a TS dataclass to @dataclass, TS async to
asyncio, and a TS plugin manifest to plugin.json (already done in
openprogram/plugins/). Design patterns carry across languages.
Static versus dynamic typing changes what 50+ contract files are worth. In TS
the compiler enforces that a plugin implements every interface, and IDE hints
are accurate. The same split written as Python Protocols is not enforced at
runtime and gives weaker hints, since mypy is not on by default. So a split at
that granularity returns less in Python. This affects whether each interface
deserves its own file (it does not), not whether the interface shapes are worth
learning (they are).
Async-first versus sync-with-threading. OpenClaw is async throughout
(send/edit/typing/handoff) with dispatch as a streaming pipeline; hermes is
async-first as well. Our channel layer is synchronous plus threading — one
thread per adapter, dispatch_inbound returning blockingly. Adopting the async
design wholesale would mean rewriting the dispatch flow: turning
dispatch_inbound into an async generator and re-wiring all four adapters'
event loops into asyncio. That is a real migration cost, not a rename at the
abstraction layer.
E. What to learn from each project
from OpenClaw from hermes
─────────────────────────────────────────────────────────
Interface design (what)
send/edit/typing/approve ✓ (more complete) ✓
SendResult with retry ✓ ✓
Streaming lifecycle ✓ (three-state) ✓ (single edit)
Approval lifecycle ✓ (complete) ✓ (/approve command)
Health check / probe ✓ —
Code organization (how)
Plugin SDK 50+ contracts ✗ overkill —
70+ files per platform ✗ overkill —
single-file base + adapter — ✓ matches
async-first dispatch ✓ ✓
The two are learned from at different levels, and the levels do not conflict. OpenClaw's interface shapes are more complete and more systematic, so its method signatures, lifecycles, and return-value structures are worth following. Hermes's code-organization scale matches ours — one file for the base ABC, one per platform, no plugin manifest. Taking OpenClaw's method signatures and landing them in a hermes-scale file organization is the combination this design uses.
F. Why the stateless outbound entry point exists
OpenProgram runs two paradigms:
Paradigm A: agentic programming
Python drives → if/else/for/while control flow
@agentic_function creates a Context node
Runtime.exec requests the LLM only when explicitly called
entry point: Python code written by the programmer
Paradigm B: agent loop (the path channel/webui chat takes)
the LLM decides what tools to call and when
process_user_turn → agent_loop → tool streaming
entry point: an external message
Channels attach to Paradigm B. Paradigm A still needs to send: a cron-driven
@agentic_function that greets the user needs no adapter instance, no stream
subscription, and no session lifecycle binding.
OpenClaw's "everything goes through the adapter" and hermes's
DeliveryRouter(adapters: dict) are both sound designs under a single-daemon
process model, where the cron scheduler, platform adapter, and agent runtime
share a process and a cron job can obtain the adapter dict by dependency
injection. OpenProgram's deployment forms break that assumption:
Deployment scenario where is the adapter instance
────────────────────────────────────────────────────────────────────────────────
openprogram worker running in the worker process
user script importing @agentic_function nowhere
cron in a separate process outside the worker nowhere
Jupyter notebook experiment nowhere
pytest test nowhere
Paradigm A is library mode by design: the user imports it into their own script
and no worker process is assumed. So outbound.send is a requirement of the
paradigm split, not a duplicate of the adapter path. What must not be duplicated
is the implementation beneath them, which is why both route through
_transport.
Two consequences follow. Any future move to an async-first base must keep a
synchronous wrapper at module top level so an @agentic_function can send
without dealing with asyncio. And streaming edit should stay reachable from
Paradigm A: an @agentic_function reporting intermediate progress should be
able to hold a MessageHandle and edit it, rather than the capability being
locked to the dispatcher's pipeline.
6. Appendix — implementation status#
In place: the Channel ABC with MessageHandle and concrete
send_text / edit_text; _transport as the shared implementation layer for
both entry points; SendResult error classification; the ChannelMessage
neutral inbound structure; entry-point-based platform registration;
progress_stream=True inbound dispatch; and the _conversation.py split into
routing, session-store, and broadcast modules.
Not yet done:
- Chunking is still duplicated.
_transporthas_chunk, butdiscord.py,slack.py, andwechat.pykeep local_chunkcopies and each of the four adapters defines its ownMAX_MSG_CHARS. Adapter reply paths that still use the platform SDK directly have not moved tosend_text. - Session isolation stays one-dimensional.
peer_idjoins chat and user into a single string, andsession_scopehas four enum values (main / per-peer / per-channel-peer / per-account-channel-peer). The thread-shared mode hermes enables by default is not expressible.ChannelMessage.thread_idis parsed in anticipation of this. account_idis passed twice, once to the adapter constructor and once todispatch_inbound. This blocks one adapter serving multiple accounts in a process.thread_idis parsed but not consumed. Quoted text and attachments flow into the turn; thread-scoped session isolation is still open.- No health check or receipt tracking. Adapter availability is not probed at startup, and delivery is not confirmed after send.