Skip to main content
The messaging gateway is the long-running process that connects Mibyan to 20+ external messaging platforms through a unified architecture.

Key Files

Architecture Overview

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)
    • 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:
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

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 mibyan_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: 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:
Deferred loading: Bundled kind: platform plugins register cheap register_deferred loaders in gateway/platform_registry.py (via mibyan_cli/plugins.py) so platform SDKs import only when the gateway starts, delivers, or runs setup/status — not on plain mibyan 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; Mibyan 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. 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 mibyan 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

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; ~/.mibyan/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:
  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:
  • mibyan gateway start / mibyan gateway stop — manual control
  • systemctl (Linux) or launchctl (macOS) — service management
  • PID file at ~/.mibyan/gateway.pid — profile-scoped process tracking
Profile-scoped vs global: start_gateway() uses profile-scoped PID files. Standalone (one gateway per profile), mibyan -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): mibyan gateway stop on the owner takes every served profile down, and mibyan -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. mibyan 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.

Multiplexed profiles

With gateway.multiplex_profiles: true one process serves the default profile plus every live directory under profiles/ (mibyan_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: 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.

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 (mibyan plugins install, mibyan 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.