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:

TermWhat it isWhere 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:

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:

Design patterns in play

PatternWhereWhy
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:

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)

┌─────────────────────────┐ │ User on Telegram/Slack/ │ 1. Platform delivers event │ Discord/Email/... │ └─────────┬───────────────┘ │ ▼ ┌─────────────────────────┐ │ Concrete Adapter │ 2. Adapter wraps it in a MessageEvent │ (TelegramAdapter, ...)│ └─────────┬───────────────┘ │ handle_message(event) ▼ ┌─────────────────────────┐ │ GatewayRunner │ 3. Auth check, session lookup, │ (gateway/run.py) │ route to the agent └─────────┬───────────────┘ │ ▼ ┌─────────────────────────┐ │ Hermes agent loop │ 4. LLM produces a reply or a tool call │ (tools, model, ...) │ └─────────┬───────────────┘ │ adapter.send(chat_id, text) ▼ ┌─────────────────────────┐ │ Concrete Adapter │ 5. Adapter chunks, sends typing, │ → platform API │ uploads media, writes back └─────────────────────────┘

Channel inventory

Built-in adapters live in gateway/platforms/; plugin adapters live in plugins/platforms/ or ~/.hermes/plugins/.

PlatformSourceFile
Telegrambuilt-ingateway/platforms/telegram.py
Discordbuilt-ingateway/platforms/discord.py
Slackbuilt-ingateway/platforms/slack.py
WhatsAppbuilt-ingateway/platforms/whatsapp.py
Signalbuilt-ingateway/platforms/signal.py
Matrixbuilt-ingateway/platforms/matrix.py
Mattermostbuilt-ingateway/platforms/mattermost.py
Email (IMAP/SMTP)built-ingateway/platforms/email.py
SMS (Twilio)built-ingateway/platforms/sms.py
DingTalkbuilt-ingateway/platforms/dingtalk.py
Feishu / Larkbuilt-ingateway/platforms/feishu.py
WeCom / Weixinbuilt-ingateway/platforms/wecom.py, weixin.py
QQ Botbuilt-ingateway/platforms/qqbot/
Yuanbaobuilt-ingateway/platforms/yuanbao.py
BlueBubbles (iMessage)built-ingateway/platforms/bluebubbles.py
Home Assistantbuilt-ingateway/platforms/homeassistant.py
API Server (REST ingress)built-ingateway/platforms/api_server.py
Webhook (generic)built-ingateway/platforms/webhook.py
MS Graph Webhookbuilt-ingateway/platforms/msgraph_webhook.py
IRCpluginplugins/platforms/irc/
Microsoft Teamspluginplugins/platforms/teams/
Google Chatpluginplugins/platforms/google_chat/
LINEpluginplugins/platforms/line/
tip The split isn't about quality — it's about coupling. Built-ins live in core because they pre-date the plugin system or are heavily tested in CI; new platforms should default to the plugin path. See gateway/platforms/ADDING_A_PLATFORM.md.

Are channels shared? (Telegram → WhatsApp?)

short answer No. By default, conversations are fully isolated per platform. A Telegram chat cannot be "continued" on WhatsApp. The agent has no concept of unified user identity across platforms.

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

ConversationSession 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:

  1. The agent's long-term memory. ~/.hermes/memories/MEMORY.md + USER.md are 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 same MEMORY.md / USER.md.
  2. The channel directory. ~/.hermes/channel_directory.json is shared — the agent in any session can route a send_message to any reachable channel on any platform.
  3. 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:

flow CLI session → mark 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?

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

┌──────────────┐ GatewayRunner._create_adapter() │ constructed │ PlatformConfig + Platform enum → adapter instance └──────┬───────┘ init: _active_sessions, _pending_messages, _session_tasks, ... │ │ GatewayRunner before connect(): │ set_message_handler(self._handle_message) │ set_fatal_error_handler(...) │ set_session_store(...) │ set_busy_session_handler(...) ▼ ┌──────────────┐ abstract; concrete adapter: │ wired │ acquires platform lock, builds platform client, └──────┬───────┘ registers native handlers, starts polling/webhook │ await adapter.connect() ▼ ┌──────────────┐ _mark_connected() │ connected │ ◄───────────────┐ └──────┬───────┘ │ │ platform delivers event│ ▼ │ ┌──────────────┐ │ many concurrent │ dispatching │─────────────────┘ events per adapter └──────┬───────┘ │ adapter.disconnect() / fatal error / gateway shutdown ▼ ┌──────────────┐ cancel background tasks, stop typing loops, │ disconnected │ release platform lock, _mark_disconnected() └──────────────┘

connect()

Abstract method (gateway/platforms/base.py:1532); every adapter implements it. The shape is consistent across platforms:

  1. Validate prerequisites. SDK installed? Token present? Bail with a clear log on missing deps.
  2. Acquire a platform lock. _acquire_platform_lock(scope, identity, desc) prevents two Hermes instances from racing on the same bot token / IMAP inbox.
  3. Build the platform client. e.g. Application.builder().token(...) for Telegram, discord.Client(...) for Discord, IMAP/SMTP sessions for Email.
  4. Register platform-native handlers that ultimately invoke await self.handle_message(event) with a normalized MessageEvent. For Telegram this is PTB MessageHandler registrations (gateway/platforms/telegram.py:1217); each handler builds an event via _build_message_event and either buffers (text bursts) or dispatches.
  5. 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).
  6. Return True on success. _mark_connected() updates runtime status; the gateway adds the adapter to self.adapters.
key insight The adapter's platform-native callbacks always end in 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.

handle_message(event) │ ├─ no _message_handler set? → drop │ ├─ coerce_plaintext_gateway_command(event) │ ("hey bot reset" → /reset, etc.) │ ├─ session_key = build_session_key(event.source, ...) │ ├─ _heal_stale_session_lock(session_key) │ (if owner task is done/cancelled, clear stale guard) │ ├─ session_key in _active_sessions ? ────────────────┐ │ │ │ YES (a task is in flight) │ │ ────────────────────────── │ │ command in {stop, new, reset} │ │ → _dispatch_active_session_command (atomic) │ │ │ │ command in {approve, deny, status, ...} │ │ → inline handler + send (no new task) │ │ │ │ _busy_session_handler returns True │ │ → swallowed (gateway showed a notice) │ │ │ │ event.message_type == PHOTO │ │ → merge into _pending_messages, no interrupt │ │ │ │ otherwise │ │ → _pending_messages[key] = event │ │ → _active_sessions[key].set() (INTERRUPT) │ │ │ │ NO (idle) │ │ ───────── │ │ _start_session_processing(event, session_key) │ │ → guard installed, owner task tracked, │ │ _process_message_background spawned │ │ │ └────────────────────────────────────────────────────┘
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:

_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.

_process_message_background(event, session_key) │ ├─ pick up interrupt_event from _active_sessions ├─ spawn _keep_typing() task (refresh every ~2s, gated by _typing_paused) ├─ on_processing_start hook │ ├─ response = await _message_handler(event) │ (this is GatewayRunner._handle_message → │ agent loop: LLM + tools + interrupts) │ ├─ unwrap EphemeralReply (TTL for system notices) │ ├─ if response is truthy AND interrupt_event.is_set() AND │ pending message exists → suppress stale response │ ├─ extract media (MEDIA: tags), image URLs, local files ├─ optional Auto-TTS for voice input (chat opt-in / global default) ├─ play_tts() before text (voice-first UX) ├─ send_multiple_images / send_document / send_voice / etc. ├─ _send_with_retry(text, chunked) for the text portion │ ├─ IN-BAND DRAIN: │ if _pending_messages[key] exists after the send: │ _active_sessions[key].clear() │ spawn fresh task for pending_event (NOT recursion — │ issue #17758: deep recursion would SIGSEGV at ~2000 frames) │ hand off _session_tasks[key] to drain task │ return │ ├─ except CancelledError: │ outcome = CANCELLED if expected else FAILURE │ on_processing_complete(outcome) ; re-raise │ ├─ except Exception: │ on_processing_complete(FAILURE) │ send a user-facing "Sorry, I encountered an error..." reply │ └─ finally: fire post-delivery callbacks (generation-aware, so old runs can't fire newer runs' callbacks) stop typing LATE-ARRIVAL DRAIN: if a message landed during cleanup awaits: if another task already owns _session_tasks[key]: re-queue the late event else: spawn a drain task for it else: clean up _session_tasks[key] (owner-task guarded) release the session guard

Concurrency state

Five fields on the adapter coordinate everything:

FieldTypeRole
_active_sessionsDict[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_tasksDict[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_messagesDict[str, MessageEvent]Single-slot queue per session of the latest follow-up arrival (photos merge; non-photos overwrite).
_background_tasksset[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_tasksset[Task]Tasks the gateway cancelled intentionally (e.g. via /stop). Used to distinguish CANCELLED outcome from FAILURE.

Edge cases worth knowing

mental model Adapters don't think about "messages" — they think about sessions. The session key drives every concurrency decision: who's already running, what queues, what cancels, what drains. A new platform inherits all of that simply by funneling its inbound events through handle_message(event).

Where to read first

  1. gateway/platforms/ADDING_A_PLATFORM.md — the canonical "what does a platform need to do?" checklist.
  2. gateway/config.py:82 — the Platform enum and how plugin platforms slip in via _missing_.
  3. gateway/platform_registry.py:38PlatformEntry dataclass: the contract between core and plugins.
  4. gateway/platforms/base.py:1259BasePlatformAdapter: the interface every adapter implements.
  5. 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: