File size: 16,732 Bytes
6aa76ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | ---
sidebar_position: 7
title: "Gateway Internals"
description: "How the messaging gateway boots, authorizes users, routes sessions, and delivers messages"
---
# Gateway Internals
The messaging gateway is the long-running process that connects Hermes to 20+ external messaging platforms through a unified architecture.
## Key Files
| 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, β¦) |
## Architecture Overview
```text
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β GatewayRunner β
β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β Telegram β β Discord β β Slack β β
β β Adapter β β Adapter β β Adapter β β
β ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ β
β β β β β
β βββββββββββββββΌββββββββββββββ β
β βΌ β
β _handle_message() β
β β β
β βββββββββββββΌββββββββββββ β
β βΌ βΌ βΌ β
β Slash command AIAgent Queue/BG β
β dispatch creation sessions β
β β β
β βΌ β
β SessionStore β
β (SQLite persistence) β
βββββββββ΄ββββββββββββββ΄ββββββββββββββ΄ββββββββββββββ
```
## Message Flow
When a message arrives from any platform:
1. **Platform adapter** receives raw event, normalizes it into a `MessageEvent`
2. **Base adapter** checks active session guard:
- If agent is running for this session β queue message, set interrupt event
- If `/approve`, `/deny`, `/stop` β bypass guard (dispatched inline)
3. **GatewayRunner._handle_message()** receives the event:
- Resolve session key via `_session_key_for_source()` (format: `agent:main:{platform}:{chat_type}:{chat_id}`)
- Check authorization (see Authorization below)
- Check if it's a slash command β dispatch to command handler
- Check if agent is already running β intercept commands like `/stop`, `/status`
- Otherwise β create `AIAgent` instance and run conversation
4. **Response** is sent back through the platform adapter
### Session Key Format
Session 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`.
### Two-Level Message Guard
When an agent is actively running, incoming messages pass through two sequential guards:
1. **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.
2. **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.
## Authorization
The gateway uses a multi-layer authorization check, evaluated in order:
1. **Per-platform allow-all flag** (e.g., `TELEGRAM_ALLOW_ALL_USERS`) β if set, all users on that platform are authorized
2. **Platform allowlist** (e.g., `TELEGRAM_ALLOWED_USERS`) β comma-separated user IDs
3. **DM pairing** β authenticated users can pair new users via a pairing code
4. **Global allow-all** (`GATEWAY_ALLOW_ALL_USERS`) β if set, all users across all platforms are authorized
5. **Default: deny** β unauthorized users are rejected
### DM Pairing Flow
```text
Admin: /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.
## Slash Command Dispatch
All slash commands in the gateway flow through the same resolution pipeline:
1. `resolve_command()` from `hermes_cli/commands.py` maps input to canonical name (handles aliases, prefix matching)
2. The canonical name is checked against `GATEWAY_KNOWN_COMMANDS`
3. `_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 == ...` chain
4. Some commands are gated on config (`gateway_config_gate` on `CommandDef`)
### Running-Agent Guard
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.
## Config Sources
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.
## Platform Adapters
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`:
```text
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 [Relay β Connector contract](relay-connector-contract.md).
Adapters implement a common interface:
- `connect()` / `disconnect()` β lifecycle management
- `send()` β outbound message delivery
- inbound events are normalized into a `MessageEvent` 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.
### Token Locks
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.
## Delivery Path
Outgoing deliveries (`gateway/delivery.py`) handle:
- **Direct reply** β send response back to the originating chat
- **Home channel delivery** β route cron job outputs and background results to a configured home channel
- **Explicit target delivery** β the send engine specifying `telegram:-1001234567890`, exposed via the [`hermes send` CLI](/guides/pipe-script-output) for shell scripts and via cron `deliver:` targets
- **Cross-platform delivery** β deliver to a different platform than the originating message
Cron 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.
## Hooks
Gateway hooks are Python modules that respond to lifecycle events:
### Gateway Hook 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`.
## Memory Provider Integration
When a memory provider plugin (e.g., Honcho) is enabled:
1. Gateway creates an `AIAgent` per message with the session ID
2. The `MemoryManager` initializes the provider with the session context
3. Provider tools (e.g., `honcho_profile`, `viking_search`) are routed through:
```text
AIAgent._invoke_tool()
β self._memory_manager.handle_tool_call(name, args)
β provider.handle_tool_call(name, args)
```
4. On session end/reset, `on_session_end()` fires for cleanup and final data flush
### Memory Flush Lifecycle
Explicit 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.
## Background Maintenance
The gateway runs periodic maintenance alongside message handling:
- **Cron ticking** β checks job schedules and fires due jobs
- **Session housekeeping** β reclaims cached resources without ending transcripts
- **Memory flush** β commits memory before soft cache eviction
- **Cache refresh** β refreshes model lists and provider status
## Process Management
The gateway runs as a long-lived process, managed via:
- `hermes gateway start` / `hermes gateway stop` β manual control
- `systemctl` (Linux) or `launchctl` (macOS) β service management
- PID file at `~/.hermes/gateway.pid` β profile-scoped process tracking
**Profile-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).
## Related Docs
- [Session Storage](./session-storage.md)
- [Cron Internals](./cron-internals.md)
- [ACP Internals](./acp-internals.md)
- [Agent Loop Internals](./agent-loop.md)
- [Messaging Gateway (User Guide)](/user-guide/messaging)
|