Files
hermes-agent/website/docs/developer-guide/gateway-internals.md
Teknium f3af936694 docs: hermes_cli, gateway and desktop AGENTS.md keep rules, long form moves to docs
An agent working in an area gets root + that area's AGENTS.md as one project-context
block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k):
update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway
(20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md.
apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant
kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal ->
BUILDING.md.
2026-10-04 23:05:42 -07:00

382 lines
27 KiB
Markdown

---
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:{namespace}:{platform}:{chat_type}:{chat_id}`; the namespace is `main` for the default profile, `<profile>` under multiplexing — see [Multiplexed profiles](#multiplexed-profiles))
- 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:{namespace}:{platform}:{chat_type}:{chat_id}
```
For example: `agent:main:telegram:private:123456789` for the default profile, or
`agent:work:telegram:private:123456789` when the multiplexer routes that chat to profile `work`
(`gateway/session.py::_session_key_namespace`; a profile literally named `main` is marked `main~`).
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`, or `gateway.allow_all_users` in `config.yaml`, bridged to the env var by `gateway/config_loader.py::bridge_core_env_settings`) — 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)
└── … # 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: systemd via `RestartPreventExitStatus=78`, s6 via finish→125, launchd via `KeepAlive.SuccessfulExit=false` after the stderr wrapper maps 78→0. 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.md) 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 `<profile home>/hooks/` (user-installed; `~/.hermes/hooks/` for the default profile, one directory per served profile under multiplexing — paths resolve at call time, never at import). 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. Standalone (one gateway per profile), `hermes -p x gateway stop` stops only that profile's gateway. Under multiplexing there is ONE gateway process per host, owned by whichever profile launched it (`gateway/host_rendezvous.py` publishes its PID, home and served set; `gateway/host_attach.py` is the attach/rescan/refuse decision every lifecycle verb goes through): `hermes gateway stop` on the owner takes every served profile down, and `hermes -p x gateway stop` for a served secondary refuses with exit 78 (it has no gateway of its own). A second `gateway run` for a served profile attaches and exits 0 — under a service supervisor it exits 75 (EX_TEMPFAIL) instead, so the redundant unit is RETRIED rather than parked: "someone else serves me right now" is a runtime observation that ends when that process does, and 78 (which systemd, s6 and launchd all treat as permanent) would strand the profile. ATTACH requires a live `identify` answer from the owner; a rendezvous record with nothing answering behind it proves an owner exists but never that it serves you, so it yields a transient refusal (exit 75), never an attach. An owner that answers `multiplex: False` to the rescan is another profile's *standalone* gateway, not a multiplexer that excluded you: the verb starts this profile's own gateway beside it (the one-process-per-profile topology), it does not refuse — refusing there exited 78 and parked every launchd unit but the first to claim the host lock. `hermes gateway stop --all` uses global `ps aux` scanning to kill all gateway processes (used during updates). Liveness is decided by `gateway.status.live_gateway_pid_for_home` (PID + start-time fingerprint), never bare PID existence.
## Background process notifications
`terminal(background=true, notify_on_complete=true)` starts a gateway watcher that detects
completion and triggers a new agent turn. Verbosity: `display.background_process_notifications`
(or `HERMES_BACKGROUND_NOTIFICATIONS`): `concise` (default; one line, failures append an output
tail), `all` (running updates + final raw output), `result` (final raw output only), `error`
(final raw output only on non-zero exit), `off`.
The watcher is armed on the gateway loop at registration time (`terminal_tool_background.py::
_register_completion_watcher` → `run_notifications.py::arm_process_watcher`); `pending_watchers`
is only the fallback for processes registered before the gateway serves (checkpoint recovery) or
while it stops, drained at startup and post-turn. Agent-notify watchers send no user-facing
receipt (the agent's next turn is the report) unless the launching turn is still running at exit
— then the injection only queues a follow-up, so the concise receipt goes out immediately.
The idle completion watcher also drains `watch_match` / `watch_disabled`; no user follow-up is
required. Notify-off drains these without waking. Transport failures are retried; unavailable
durable completion owners/transports do not spend delivery attempts. Profile-namespaced process
events keep their owning adapter, including raw progress/final notices. Adapter acceptance is
at-least-once admission, not proof that a model turn or final outbound reply completed. API-server
async completions remain durable delivery rows, never autonomous new model turns. Require the
`admit_internal_event` receipt for completion/watch injection: a handler returning None is not
acceptance. Refused admission refunds every claimed batch sibling without spending an attempt;
actual delivery errors keep their bounded retry policy. Recognized raw API routes resolve after
persisted messaging origins and defer quietly when unavailable; malformed routes still warn.
Cron execution has its own session. Eligible continuable deliveries may mirror or seed the
reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home,
or opted-in explicit targets. `all` expansions do not gain home mirror eligibility. Mirrored
briefs are labelled user turns appended at a turn boundary, preserving role alternation
(`cron/AGENTS.md`).
## `/login` (off-turn, paired DM only)
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
an agent peer, so posting a consent link there would publish it.
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
DM the bot can run it. Operators of shared gateways must set it.
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
task is dying and its adapters may already be gone — but if the server had already completed the
transfer it still persists, because that transfer is irreversible.
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
session model override pinned to it. It does not switch agents in place and does not write a model
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
an expiring access token that nothing refreshes.
## Multiplexed profiles
With `gateway.multiplex_profiles: true` one process serves the default profile plus every live directory under `profiles/` (`hermes_cli/profiles.py::profiles_to_serve(multiplex=True)`). `os.environ` and module globals hold the **launch** profile's values, so every activity for a secondary binds its scope explicitly — a profile is home + secret scope + terminal scope together:
| Activity | Binding |
|---|---|
| Routed turn | `gateway/run.py::_profile_runtime_scope(home)` via `run_turn.py::_profile_scope_for_source` |
| Agent release / eviction (TTL, LRU, memory pressure) | `gateway/run_agent_cache.py::_run_release_in_profile_scope` |
| Shutdown | `gateway/run_shutdown.py::_finalize_session` |
| Post-turn media delivery | `gateway/platforms/base.py::_media_delivery_scope` |
| Cron tick | `cron/scheduler_provider.py::_profile_cron_scope(home)` (one ticker, profiles in sequence) |
| Child processes (`hermes -p X` workers, relay turns, browser drivers) | `tools/environments/local.py::served_profile_child_env` |
| Background threads | `agent/memory_provider.py::spawn_context_thread` |
Secret reads fail closed (`agent.secret_scope.get_secret` raises `UnscopedSecretError`) only after `set_multiplex_active(True)`, which the gateway, cron, `gateway migrate` and the Desktop/dashboard `serve` backend set. Adapter YAML never reaches `os.environ` under multiplex: `gateway/platforms/_shared.py::apply_yaml_bridge` seeds `PlatformConfig.extra` and skips the environ write under a secondary's scope; gates read through `platform_gate_env`. Shared-ingress platforms (WhatsApp bridge, Relay) run on the default profile only; a secondary that enables one is logged once and stamped into runtime status (`run_adapters.py::_note_unserved_secondary_platform`). Per-profile isolation as the user sees it: [Multi-profile gateways § What is isolated per profile](../user-guide/multi-profile-gateways.md#what-is-isolated-per-profile).
## Mid-run plugin loading
Plugins that load after the adapters connected (install/enable from the CLI, Desktop, dashboard or
`plugins.manage`; a tool-triggered force re-discovery) re-wire their platform handlers without a restart
(#87770). The pieces, all in `gateway/run_plugin_rewire.py`:
- **Discovery listener** — `_start_recover_previous_run` subscribes `PluginManager.on_plugin_loaded` for the
launch profile and `_load_secondary_profile_config` does so per served profile. The event fires from inside
`discover_and_load` (never from an RPC) for the newly loaded plugins; the callback hops onto the gateway
loop with `call_soon_threadsafe`.
- **Idempotent re-wire** — `BasePlatformAdapter.rewire_plugin_handlers()` re-reads
`get_platform_handler_factories(platform)` and runs only factories not yet wired on the live native
client (keyed `(plugin, qualname)` because a force reload hands back new function objects). Telegram
hoists the added handlers ahead of core's catch-alls; Slack also re-registers missing
`register_slack_action_handler` callbacks once per `AsyncApp`.
- **`reload-plugins` control verb** — other processes (`hermes plugins install`, `hermes serve`) ask the
running gateway to force-rescan the requested (served) home; the answer carries `plugins`, per-plugin
`activations` and `adapters_rewired`, so the caller can say "active now" truthfully.
- **Scope limit** — handlers only. Tools and system-prompt sections of a late plugin wait for the next
session (prompt-cache invariant); portable MCP servers wait for `mcp.reload`. Nothing un-wires on disable.
## 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/index.md)