Skip to main content

Gateway Internals

The messaging gateway is the long-running process that connects Minerva to external messaging platforms (Telegram, Discord, Slack, webhooks, and the API server) through a unified architecture.

Key Files​

FilePurpose
gateway/run.pyGatewayRunner facade — composes the gateway/run_*.py sibling mixins (startup, adapters, inbound, turn, busy, goals, notifications, shutdown, …) and gateway/slash_commands_*.py handlers
gateway/session.pySessionStore — conversation persistence and session key construction
gateway/delivery.pyOutbound message delivery to target platforms/channels
gateway/pairing.pyDM pairing flow for user authorization
gateway/channel_directory.pyMaps chat IDs to human-readable names for cron delivery
gateway/hooks.pyHook discovery, loading, and lifecycle event dispatch
gateway/mirror.pyCross-session message mirroring for send_message
gateway/status.pyToken lock management for profile-scoped gateway instances
gateway/platform_registry.pyAdapter 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 direct adapters (API server, webhooks)

Architecture Overview​

┌─────────────────────────────────────────────────┐
│ 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. Chat-scoped group grants (e.g., TELEGRAM_GROUP_ALLOWED_CHATS) and {PLATFORM}_ALLOW_BOTS
  2. Per-platform allow-all flag (e.g., TELEGRAM_ALLOW_ALL_USERS) — if set, all users on that platform are authorized
  3. Adapter role auth (e.g., DISCORD_ALLOWED_ROLES)
  4. DM pairing store — users whose pairing code an operator approved
  5. Allowlists — platform (e.g., TELEGRAM_ALLOWED_USERS), group-scoped, and global (GATEWAY_ALLOWED_USERS) user IDs
  6. Global allow-all (GATEWAY_ALLOW_ALL_USERS) — consulted only when no allowlist is configured
  7. Default: deny — unauthorized users are rejected

The full order lives in _is_user_authorized() (gateway/authz_mixin.py).

DM Pairing Flow​

New user (DM): hello
Gateway: "Here's your pairing code: K7M2Q9XA. Ask the bot owner to run:
minerva pairing approve telegram K7M2Q9XA"
Operator shell: minerva pairing approve telegram K7M2Q9XA
New user: (authorized from now on)

Codes are 8 characters from an unambiguous alphabet, expire after one hour, and are rate-limited per user and per platform. There is no in-chat /pair command; approval happens only through minerva pairing approve (or minerva pairing list / revoke). Unknown DMs get a code only while unauthorized_dm_behavior is pair (the default); set it to ignore to drop them silently. Pairing state is stored under ~/.minerva/platforms/pairing/ by 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 minerva_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:

SourceWhat it provides
~/.minerva/.envAPI keys, bot tokens, platform credentials
~/.minerva/config.yamlModel settings, tool configuration, display options
Environment variablesOverride 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:

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

gateway/platforms/ # core base + legacy direct adapters
├── base.py # BasePlatformAdapter — shared logic for all platforms
├── 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 minerva_cli/plugins.py) so platform SDKs import only when the gateway starts, delivers, or runs setup/status — not on plain minerva chat. Resolution loads one adapter on lookup; full enumeration runs pending loaders only on paths that need every platform.

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 BasePlatformAdapter._acquire_platform_lock() (a wrapper around gateway/status.py::acquire_scoped_lock()) in connect() and _release_platform_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 code family 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 minerva send CLI 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​

EventWhen fired
gateway:startupGateway process starts
session:startNew conversation session begins
session:endSession completes or times out
session:resetUser resets session with /new
agent:startAgent begins processing a message
agent:stepAgent completes one tool-calling iteration
agent:endAgent finishes and returns response
command:*Any slash command is executed

Hooks are discovered only from ~/.minerva/hooks/ (user-installed; no hooks ship with Minerva). Each hook is a directory with a HOOK.yaml manifest and a handler.py that defines handle(event_type, context).

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:
AIAgent._invoke_tool()
→ self._memory_manager.handle_tool_call(name, args)
→ provider.handle_tool_call(name, args)
  1. 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:

  • minerva gateway start / minerva gateway stop — manual control
  • systemctl (Linux) or launchctl (macOS) — service management
  • PID file at ~/.minerva/gateway.pid — profile-scoped process tracking

Profile-scoped vs global: start_gateway() uses profile-scoped PID files. minerva gateway stop stops only the current profile's gateway. minerva gateway stop --all uses global ps aux scanning to kill all gateway processes (used during updates).