How Channels Are Handled
A guided tour of the platform/adapter system in hermes-agent. Start here; we'll dive into specific layers in follow-up notes.
TL;DR
Each external messaging service (Telegram, Discord, Slack, WhatsApp, Email, SMS, …) is a platform. Each reachable destination inside a platform (a Discord text channel, a Slack room, a Telegram chat) is a channel. Hermes routes messages between an LLM agent and any number of these channels through a pluggable adapter layer.
Mental model
Three words you'll see everywhere:
| Term | What it is | Where it lives |
|---|---|---|
Platform |
Identifier for a messaging service. Enum + string name. | gateway/config.py:82 |
Channel |
A specific destination on a platform (chat, room, contact, inbox). | ~/.hermes/channel_directory.json |
Adapter |
The code that talks to one platform's API. Subclass of BasePlatformAdapter. |
gateway/platforms/<name>.py |
The three layers
1. Channel (data)
Channels are not classes — they're just rows in a cached JSON directory at
~/.hermes/channel_directory.json. The gateway rebuilds this map every 5 minutes from two sources:
- Live API enumeration — for platforms that list channels (Discord, Slack).
- Session history — for everyone else: scan past sessions and harvest
(chat_id, chat_name).
The agent's send_message tool and the MCP bridge read this file to resolve human-friendly names ("#general") into platform IDs.
Build/refresh: gateway/channel_directory.py:60
2. Adapter (behavior)
Each platform has a concrete adapter — a subclass of the abstract BasePlatformAdapter
(gateway/platforms/base.py:1259). Every adapter implements the same async interface:
class BasePlatformAdapter(ABC):
async def connect(self) -> bool: ...
async def disconnect(self) -> None: ...
async def send(self, chat_id, text, ...) -> SendResult: ...
async def send_typing(self, chat_id) -> None: ...
async def send_image(self, chat_id, image_url, caption) -> SendResult: ...
async def get_chat_info(self, chat_id) -> dict: ...
async def handle_message(self, event: MessageEvent) -> None: ...
The base class is ~3600 lines because it also handles cross-cutting concerns: typing heartbeats, chunking long messages, per-session interrupt events, draft streaming, auto-TTS, approval flows, media uploads, post-delivery callbacks. Subclasses override only what's platform-specific.
3. Gateway runner (control plane)
GatewayRunner in gateway/run.py is the orchestrator. It:
- Reads
config.yaml, decides which platforms to enable. - Calls
_create_adapter(platform, config)for each — checks the plugin registry first, falls back to a built-in if/elif chain. - Starts every adapter's
connect()in parallel. - Routes incoming
MessageEvents to the agent and outgoing replies toadapter.send(). - Refreshes the channel directory every 5 min.
Design patterns in play
| Pattern | Where | Why |
|---|---|---|
| Adapter | gateway/platforms/base.py:1259 | One stable interface for the agent; platform APIs differ wildly underneath. |
| Plugin Registry | gateway/platform_registry.py:146 | Third-party platforms self-register a PlatformEntry dataclass — zero changes to core. |
| Factory | PlatformEntry.adapter_factory |
A callable, not a bare class, so plugins can wrap construction (kwargs, try/except). |
| Template Method | BasePlatformAdapter abstract methods + concrete helpers |
Shared lifecycle (connect/typing/chunking/interrupt) with platform-specific hooks. |
| Extensible Enum | gateway/config.py:82 (_missing_) |
Platform("irc") works for plugin platforms without editing the enum. |
| Cache & Index | gateway/channel_directory.py | Decouples message routing from live adapter availability (cron, MCP, restarts). |
What's in a PlatformEntry?
The dataclass at gateway/platform_registry.py:38 bundles everything the gateway needs to know about a platform without importing its adapter:
name,label,emoji— identity and display.adapter_factory—(cfg) -> adapter instance.check_fn— are the platform's deps installed?validate_config— is the user'sconfig.yamlentry usable?required_env,install_hint— forhermes setupwizard.allowed_users_env,allow_all_env— auth gating.max_message_length,pii_safe— message handling rules.platform_hint— string injected into the system prompt ("You are on IRC; no markdown.").env_enablement_fn,cron_deliver_env_var,standalone_sender_fn— cron / out-of-process delivery hooks.
So a plugin isn't just "an adapter" — it's an adapter plus all the metadata the gateway, the setup wizard, the cron runner, and the LLM prompt builder need to integrate it without special-casing.
End-to-end flow (incoming message)
Channel inventory
Built-in adapters live in gateway/platforms/; plugin adapters live in plugins/platforms/ or ~/.hermes/plugins/.
| Platform | Source | File |
|---|---|---|
| Telegram | built-in | gateway/platforms/telegram.py |
| Discord | built-in | gateway/platforms/discord.py |
| Slack | built-in | gateway/platforms/slack.py |
| built-in | gateway/platforms/whatsapp.py | |
| Signal | built-in | gateway/platforms/signal.py |
| Matrix | built-in | gateway/platforms/matrix.py |
| Mattermost | built-in | gateway/platforms/mattermost.py |
| Email (IMAP/SMTP) | built-in | gateway/platforms/email.py |
| SMS (Twilio) | built-in | gateway/platforms/sms.py |
| DingTalk | built-in | gateway/platforms/dingtalk.py |
| Feishu / Lark | built-in | gateway/platforms/feishu.py |
| WeCom / Weixin | built-in | gateway/platforms/wecom.py, weixin.py |
| QQ Bot | built-in | gateway/platforms/qqbot/ |
| Yuanbao | built-in | gateway/platforms/yuanbao.py |
| BlueBubbles (iMessage) | built-in | gateway/platforms/bluebubbles.py |
| Home Assistant | built-in | gateway/platforms/homeassistant.py |
| API Server (REST ingress) | built-in | gateway/platforms/api_server.py |
| Webhook (generic) | built-in | gateway/platforms/webhook.py |
| MS Graph Webhook | built-in | gateway/platforms/msgraph_webhook.py |
| IRC | plugin | plugins/platforms/irc/ |
| Microsoft Teams | plugin | plugins/platforms/teams/ |
| Google Chat | plugin | plugins/platforms/google_chat/ |
| LINE | plugin | plugins/platforms/line/ |
gateway/platforms/ADDING_A_PLATFORM.md.
Are channels shared? (Telegram → WhatsApp?)
Session isolation is structural
Session keys structurally embed the platform name:
agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}][:{user_id}]
Built by build_session_key() — gateway/session.py:594
| Conversation | Session key |
|---|---|
| You DM the bot on Telegram (chat 12345) | agent:main:telegram:dm:12345 |
| You DM the bot on WhatsApp (chat 12345) | agent:main:whatsapp:dm:12345 |
Different keys → different session rows in sessions.json → different SQLite session_ids → different transcripts. There is no user field that spans platforms; Telegram-user-X and WhatsApp-user-Y are simply unrelated identifiers as far as the gateway is concerned.
What IS shared across platforms
Three globals, but only one of them carries conversational continuity:
-
The agent's long-term memory.
~/.hermes/memories/MEMORY.md+USER.mdare profile-global — loaded once per agent start, injected into the system prompt. So if the agent learned "the user prefers concise replies" on Telegram, that fact also applies on WhatsApp. But the conversation history doesn't.tools/memory_tool.py:55-57, tools/memory_tool.py:127-142
caveat Memory is profile-global, not user-global. Two different humans using the same Hermes instance share the sameMEMORY.md/USER.md. -
The channel directory.
~/.hermes/channel_directory.jsonis shared — the agent in any session can route asend_messageto any reachable channel on any platform. - The agent persona & tools. Same system prompt, same toolset, same models everywhere.
The one explicit cross-platform feature: /handoff
There is a real handoff mechanism, but it's narrower than it first looks:
- It's a CLI → platform transfer. Not platform → platform.
- In the Hermes CLI you run
/handoff telegram(orwhatsapp, etc.). The CLI writeshandoff_state='pending'on the session row. cli.py:5689 - A watcher in the gateway picks it up, atomically claims it, and re-binds the destination platform's
session_keyto the CLI'ssession_idviasession_store.switch_session. The full transcript replays on the destination on the next agent turn. gateway/run.py:3721, gateway/session.py:1176 - Optionally, the adapter creates a fresh thread (Telegram topic, Discord thread, Slack thread) on the destination's home channel so the handoff has its own scrollback. gateway/run.py:3784
- The CLI process polls the row until
completedorfailed, then prints the result and exits.
pending in SQLite → gateway watcher claims → switch destination key to CLI's session_id → synthetic MessageEvent dispatched on destination → mark completed.
There is no user-driven Telegram → WhatsApp handoff. The CLI is the only legal source.
Cross-platform send_message ≠ continuity
From any session, the agent can call send_message(target="whatsapp:+...", message="...") to reach any channel on any other platform.
But this starts a new isolated conversation on the destination — the recipient sees a single message, not the source-platform backstory.
tools/send_message_tool.py
If I want cross-platform continuity, what are my options?
- Use memory. Have the agent explicitly write key facts to
MEMORY.md. They'll be available in any session, on any platform. - Use
/handofffrom the CLI. Start the conversation in the CLI, then hand off to the platform you want. The transcript follows. - Build it. The session store has
switch_session()as a primitive (gateway/session.py:1176) — a platform-to-platform handoff would mostly need a trigger (a slash command on the source platform) and a destination-resolution policy.
Adapter lifecycle
Every concrete adapter is a state machine governed by BasePlatformAdapter.
The lifecycle has five stages — construct, wire, connect, dispatch loop, disconnect — but the interesting
behavior is concentrated in the dispatch loop because it has to coordinate concurrent in-flight agent runs,
interrupts, command bypasses, late-arriving messages, and stale guards.
Lifecycle states
connect()
Abstract method (gateway/platforms/base.py:1532); every adapter implements it. The shape is consistent across platforms:
- Validate prerequisites. SDK installed? Token present? Bail with a clear log on missing deps.
- Acquire a platform lock.
_acquire_platform_lock(scope, identity, desc)prevents two Hermes instances from racing on the same bot token / IMAP inbox. - Build the platform client. e.g.
Application.builder().token(...)for Telegram,discord.Client(...)for Discord, IMAP/SMTP sessions for Email. - Register platform-native handlers that ultimately invoke
await self.handle_message(event)with a normalizedMessageEvent. For Telegram this is PTBMessageHandlerregistrations (gateway/platforms/telegram.py:1217); each handler builds an event via_build_message_eventand either buffers (text bursts) or dispatches. - Start the listener. Long polling, websocket, webhook server, IMAP IDLE, whatever the platform supports. Many adapters support multiple modes via env vars (e.g.
TELEGRAM_WEBHOOK_URL). - Return
Trueon success._mark_connected()updates runtime status; the gateway adds the adapter toself.adapters.
await self.handle_message(event). That single funnel is where all the cross-cutting behavior (session keying, interrupts, command bypass, typing) lives.
Dispatch — handle_message(event)
Defined at gateway/platforms/base.py:2749.
This method returns quickly. It never runs the agent inline; it just classifies the incoming event and either spawns a background task, queues it, or routes it through a dedicated bypass path.
Why bypass commands need a special path
If /stop, /new, or /reset arrived during an active run and were merely queued like normal messages, they would leak into the conversation as user text and never cancel the running agent. If /approve or /deny were queued, the agent (which is blocked on an Event.wait for approval) would deadlock.
So handle_message consults should_bypass_active_session(cmd) and either:
- routes through
_dispatch_active_session_commandfor/stop|/new|/reset— installs a command_guard, runs the handler, sends the response, then cancels the old task (so the reply doesn't lose to a cancellation race), then drains the latest pending follow-up; gateway/platforms/base.py:2672 - or dispatches the handler inline for
/approve|/deny|/status|/background|/restart— no new task, no guard swap, just call the handler and send the reply.
_process_message_background
Defined at gateway/platforms/base.py:2892. This is the worker task that actually runs the agent loop and sends the reply.
Concurrency state
Five fields on the adapter coordinate everything:
| Field | Type | Role |
|---|---|---|
_active_sessions | Dict[str, asyncio.Event] | Level-1 "is a task in flight?" guard. The Event is also the interrupt signal: set it to ask the running agent to bail. |
_session_tasks | Dict[str, asyncio.Task] | Owner-task map. Lets _heal_stale_session_lock clean up dead guards and finally blocks check "do I still own this session?" before deleting. |
_pending_messages | Dict[str, MessageEvent] | Single-slot queue per session of the latest follow-up arrival (photos merge; non-photos overwrite). |
_background_tasks | set[Task] | All in-flight processing tasks. cancel_background_tasks() kills them on adapter shutdown so the next gateway instance doesn't see ghosts. |
_expected_cancelled_tasks | set[Task] | Tasks the gateway cancelled intentionally (e.g. via /stop). Used to distinguish CANCELLED outcome from FAILURE. |
Edge cases worth knowing
- Photo bursts. Telegram clients split albums into multiple updates. Photos arriving during an active run are merged into
_pending_messagesviamerge_pending_message_eventinstead of interrupting — otherwise the agent would restart mid-album. - Stale guards. If a task crashed without unwinding, the guard would be permanent ("I'm always busy"). On every
handle_message,_heal_stale_session_lockchecks whether the owner task is actually done; if so, the lock is cleared and dispatch falls through normally. - Two distinct drain paths. "In-band drain" runs while the current task is still inside its try block; "late-arrival drain" runs in the
finallyfor messages that snuck in during the cleanup awaits. Both spawn a fresh task — recursion was banned after issue #17758 (sustained follow-ups blew the C stack at ~2000 frames). - Generation-aware post-delivery callbacks.
GatewayRunnertags each agent run with a generation number on the interrupt event.pop_post_delivery_callback(key, generation=...)refuses to fire if a fresher run has already registered new callbacks — prevents a stale run from clearing the newer run's deferred work. - Pre-send order in command bypass.
_dispatch_active_session_commandsends the response before cancelling the old task (issue #18912). Reversed order risked the "/new" confirmation losing to a cancellation race. - Suppressed stale response. If an interrupt fired during a long agent run and a pending message is queued, the (now-stale) response from the original run is dropped silently — the pending message gets a fresh run instead. Otherwise the user would see the answer to the question they already abandoned.
handle_message(event).
Where to read first
- gateway/platforms/ADDING_A_PLATFORM.md — the canonical "what does a platform need to do?" checklist.
- gateway/config.py:82 — the
Platformenum and how plugin platforms slip in via_missing_. - gateway/platform_registry.py:38 —
PlatformEntrydataclass: the contract between core and plugins. - gateway/platforms/base.py:1259 —
BasePlatformAdapter: the interface every adapter implements. - gateway/channel_directory.py:60 — how channels are discovered and cached.
What we'll dive into next
Open questions for follow-up notes — pick any to go deeper:
- How does a single adapter actually connect and dispatch incoming messages? (lifecycle in
BasePlatformAdapter) - How are sessions identified across platforms? (
session_key,chat_id,thread_id) - How does the agent's
send_messagetool decide which channel to write to, and what happens when the adapter is offline (cron, MCP)? - What does message chunking / typing heartbeat / interrupt look like, end to end?
- How do
plugin.yaml,register(ctx), and bundled plugin discovery work together?