website/docs/developer-guide/gateway-internals.md
The messaging gateway is the long-running process that connects Hermes to 20+ external messaging platforms through a unified architecture.
| File | Purpose |
|---|---|
gateway/run.py | GatewayRunner facade — composes the gateway/run_*.py sibling mixins (startup, adapters, inbound, turn, busy, goals, notifications, shutdown, …) and gateway/slash_commands_*.py handlers |
gateway/session.py | SessionStore — conversation persistence and session key construction |
gateway/delivery.py | Outbound message delivery to target platforms/channels |
gateway/pairing.py | DM pairing flow for user authorization |
gateway/channel_directory.py | Maps chat IDs to human-readable names for cron delivery |
gateway/hooks.py | Hook discovery, loading, and lifecycle event dispatch |
gateway/mirror.py | Cross-session message mirroring for send_message |
gateway/status.py | Token lock management for profile-scoped gateway instances |
gateway/builtin_hooks/ | Extension point for always-registered hooks (none shipped) |
gateway/platform_registry.py | Adapter registry, factories, and deferred (lazy) loaders for bundled platform plugins |
plugins/platforms/<name>/ | Bundled messaging adapters (most platforms: adapter.py + plugin.yaml) |
gateway/platforms/ | Shared base.py plus legacy/direct adapters (Signal, API server, webhooks, …) |
┌─────────────────────────────────────────────────┐
│ GatewayRunner │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Telegram │ │ Discord │ │ Slack │ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ ▼ │
│ _handle_message() │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ Slash command AIAgent Queue/BG │
│ dispatch creation sessions │
│ │ │
│ ▼ │
│ SessionStore │
│ (SQLite persistence) │
└───────┴─────────────┴─────────────┴─────────────┘
When a message arrives from any platform:
MessageEvent/approve, /deny, /stop → bypass guard (dispatched inline)_session_key_for_source() (format: agent:main:{platform}:{chat_type}:{chat_id})/stop, /statusAIAgent instance and run conversationSession keys encode the full routing context:
agent:main:{platform}:{chat_type}:{chat_id}
For example: agent:main:telegram:private:123456789
Thread-aware platforms (Telegram forum topics, Discord threads, Slack threads) may include thread IDs in the chat_id portion. Never construct session keys manually — always use build_session_key() from gateway/session.py.
When an agent is actively running, incoming messages pass through two sequential guards:
Level 1 — Base adapter (gateway/platforms/base.py): Checks _active_sessions. If the session is active, queues the message in _pending_messages and sets an interrupt event. This catches messages before they reach the gateway runner.
Level 2 — Gateway runner (gateway/run_inbound.py): Checks _running_agents. Intercepts specific commands (/stop, /new, /queue, /status, /approve, /deny) and routes them appropriately. Everything else triggers running_agent.interrupt().
Commands that must reach the runner while the agent is blocked (like /approve) are dispatched inline via await self._message_handler(event) — they bypass the background task system to avoid race conditions.
The gateway uses a multi-layer authorization check, evaluated in order:
TELEGRAM_ALLOW_ALL_USERS) — if set, all users on that platform are authorizedTELEGRAM_ALLOWED_USERS) — comma-separated user IDsGATEWAY_ALLOW_ALL_USERS) — if set, all users across all platforms are authorizedAdmin: /pair
Gateway: "Pairing code: ABC123. Share with the user."
New user: ABC123
Gateway: "Paired! You're now authorized."
Pairing state is persisted in gateway/pairing.py and survives restarts.
All slash commands in the gateway flow through the same resolution pipeline:
resolve_command() from hermes_cli/commands.py maps input to canonical name (handles aliases, prefix matching)GATEWAY_KNOWN_COMMANDS_handle_message() (gateway/run_inbound.py) looks the handler up by name — _handle_<name>_command on the gateway/slash_commands_*.py mixins — via _command_handler_table over _IDLE_COMMANDS / _PLAIN_COMMANDS in gateway/run_busy.py; there is no if canonical == ... chaingateway_config_gate on CommandDef)Commands that must NOT execute while the agent is processing are rejected early:
While _quick_key in self._running_agents, _dispatch_busy_slash_command() in gateway/run_busy.py routes each recognized command by its CommandDef.busy_policy / busy_handler: a mid-run variant (_busy_<key>_command) if one exists, otherwise the normal handler when busy_policy allows it, otherwise a reject message ("⏳ Agent is running — /model can't run mid-turn…").
Bypass commands (/stop, /new, /approve, /deny, /queue, /status) have mid-run handlers and are dispatched inline.
The gateway reads configuration from multiple sources:
| Source | What it provides |
|---|---|
~/.hermes/.env | API keys, bot tokens, platform credentials |
~/.hermes/config.yaml | Model settings, tool configuration, display options |
| Environment variables | Override any of the above |
Unlike the CLI (which uses load_cli_config() with hardcoded defaults), the gateway reads config.yaml directly via YAML loader. This means config keys that exist in the CLI's defaults dict but not in the user's config file may behave differently between CLI and gateway.
Most messaging platforms ship as plugin adapters under plugins/platforms/<name>/adapter.py; a few legacy adapters still live directly in gateway/platforms/. All extend BasePlatformAdapter from gateway/platforms/base.py:
plugins/platforms/ # plugin-packaged adapters (one dir each)
├── telegram/adapter.py # Telegram Bot API (long polling or webhook)
├── discord/adapter.py # Discord bot via discord.py
├── slack/adapter.py # Slack Socket Mode
├── whatsapp/adapter.py # WhatsApp Business Cloud API
├── matrix/adapter.py # Matrix via mautrix (optional E2EE)
├── mattermost/adapter.py # Mattermost WebSocket API
├── email/adapter.py # Email via IMAP/SMTP
├── sms/adapter.py # SMS via Twilio
├── dingtalk/adapter.py # DingTalk WebSocket
├── feishu/adapter.py # Feishu/Lark WebSocket or webhook
├── wecom/adapter.py # WeCom (WeChat Work) callback
├── line/adapter.py # LINE Messaging API
├── teams/adapter.py # Microsoft Teams
├── irc/adapter.py # IRC (canonical scoped-lock example)
├── homeassistant/adapter.py # Home Assistant conversation integration
└── … # google_chat, ntfy, photon, raft, simplex, …
gateway/platforms/ # core base + legacy direct adapters
├── base.py # BasePlatformAdapter — shared logic for all platforms
├── signal.py # Signal via signal-cli REST API
├── weixin.py # Weixin (personal WeChat) via iLink Bot API
├── bluebubbles.py # Apple iMessage via BlueBubbles macOS server
├── qqbot/ # QQ Bot (Tencent QQ) via Official API v2 (sub-package)
├── yuanbao.py # Yuanbao (Tencent) DM/group adapter
├── msgraph_webhook.py # Microsoft Graph change-notification webhook (Teams, Outlook, etc.)
├── webhook.py # Inbound/outbound webhook adapter
└── api_server.py # REST API server adapter
Deferred loading: Bundled kind: platform plugins register cheap register_deferred loaders in gateway/platform_registry.py (via hermes_cli/plugins.py) so platform SDKs import only when the gateway starts, delivers, or runs setup/status — not on plain hermes chat. Resolution loads one adapter on lookup; full enumeration runs pending loaders only on paths that need every platform.
Experimental connector-backed platforms use the generic relay adapter in gateway/relay/ instead of a direct platform module. When GATEWAY_RELAY_URL or gateway.relay_url is configured, the gateway registers the relay platform, dials the connector over an outbound WebSocket, and receives descriptor, inbound, and interrupt_inbound frames on that same socket. The connector advertises a CapabilityDescriptor; Hermes can send normal outbound replies, token-less follow_up operations, and interrupt frames back through the relay. The source-grounded wire contract lives in docs/relay-connector-contract.md.
Adapters implement a common interface:
connect() / disconnect() — lifecycle managementsend() — outbound message deliveryMessageEvent and forwarded via handle_message()Internal push wakes use gateway.wake.admit_internal_event: the public
handle_message() still returns None, but the event's process-local
_gateway_accepted receipt is set only after scheduling or queue insertion.
A missing handler, mismatched explicit session key, or queue-cap drop is not
acceptance. Custom adapters overriding ingress should delegate internal events to
BasePlatformAdapter.handle_message() (or explicitly record actual admission),
not equate a consumed/dropped callback with acceptance. This receipt is separate
from heartbeat execution accounting and does not bypass authorization, emergency
stop, or later turn-preparation gates.
Adapters that connect with unique credentials call acquire_scoped_lock() in connect() and release_scoped_lock() in disconnect(). This prevents two profiles from using the same bot token simultaneously.
A lock conflict is emitted as {scope}_lock with retryable=True so a mid-run reconnect can recover once the other holder exits. At startup, though, a live foreign holder is a configuration conflict: gateway/restart.py::is_global_startup_conflict() recognizes the *_lock / lock_conflict code families and the startup router parks the platform fatal instead of retry-queueing it. With nothing else connected the gateway exits 78 (EX_CONFIG, gateway_state=startup_failed) so the supervisor stops restarting it; alongside a genuinely transient peer failure the gateway stays alive and only the peer retries.
Outgoing deliveries (gateway/delivery.py) handle:
telegram:-1001234567890, exposed via the hermes send CLI for shell scripts and via cron deliver: targetsCron job deliveries are NOT mirrored into gateway session history — they live in their own cron session only. This is a deliberate design choice to avoid message alternation violations.
Gateway hooks are Python modules that respond to lifecycle events:
| Event | When fired |
|---|---|
gateway:startup | Gateway process starts |
session:start | New conversation session begins |
session:end | Session completes or times out |
session:reset | User resets session with /new |
agent:start | Agent begins processing a message |
agent:step | Agent completes one tool-calling iteration |
agent:end | Agent finishes and returns response |
command:* | Any slash command is executed |
Hooks are discovered from gateway/builtin_hooks/ (an extension point — currently empty in the shipped distribution; _register_builtin_hooks() is a no-op stub) and ~/.hermes/hooks/ (user-installed). Each hook is a directory with a HOOK.yaml manifest and handler.py.
When a memory provider plugin (e.g., Honcho) is enabled:
AIAgent per message with the session IDMemoryManager initializes the provider with the session contexthoncho_profile, viking_search) are routed through:AIAgent._invoke_tool()
→ self._memory_manager.handle_tool_call(name, args)
→ provider.handle_tool_call(name, args)
on_session_end() fires for cleanup and final data flushExplicit conversation boundaries (such as /new, /reset, or /resume) flush and finalize the outgoing session. Idle time and daily boundaries never finalize it.
Resource-only TTL, LRU, and memory-pressure eviction commits the cached transcript to configured memory providers before releasing the agent's clients. It does not close the durable conversation: the next turn reloads the same transcript and identity.
The gateway runs periodic maintenance alongside message handling:
The gateway runs as a long-lived process, managed via:
hermes gateway start / hermes gateway stop — manual controlsystemctl (Linux) or launchctl (macOS) — service management~/.hermes/gateway.pid — profile-scoped process trackingProfile-scoped vs global: start_gateway() uses profile-scoped PID files. hermes gateway stop stops only the current profile's gateway. hermes gateway stop --all uses global ps aux scanning to kill all gateway processes (used during updates).