Key Files
Architecture Overview
Message Flow
When a message arrives from any platform:- Platform adapter receives raw event, normalizes it into a
MessageEvent - 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)
- GatewayRunner._handle_message() receives the event:
- Resolve session key via
_session_key_for_source()(format:agent:{namespace}:{platform}:{chat_type}:{chat_id}; the namespace ismainfor 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
AIAgentinstance and run conversation
- Resolve session key via
- Response is sent back through the platform adapter
Session Key Format
Session keys encode the full routing context: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:-
Level 1 — Base adapter (
gateway/platforms/base.py): Checks_active_sessions. If the session is active, queues the message in_pending_messagesand 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 triggersrunning_agent.interrupt().
/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:- Per-platform allow-all flag (e.g.,
TELEGRAM_ALLOW_ALL_USERS) — if set, all users on that platform are authorized - Platform allowlist (e.g.,
TELEGRAM_ALLOWED_USERS) — comma-separated user IDs - DM pairing — authenticated users can pair new users via a pairing code
- Global allow-all (
GATEWAY_ALLOW_ALL_USERS, orgateway.allow_all_usersinconfig.yaml, bridged to the env var bygateway/config_loader.py::bridge_core_env_settings) — if set, all users across all platforms are authorized - Default: deny — unauthorized users are rejected
DM Pairing Flow
gateway/pairing.py and survives restarts.
Slash Command Dispatch
All slash commands in the gateway flow through the same resolution pipeline:resolve_command()frommibyan_cli/commands.pymaps input to canonical name (handles aliases, prefix matching)- The canonical name is checked against
GATEWAY_KNOWN_COMMANDS _handle_message()(gateway/run_inbound.py) looks the handler up by name —_handle_<name>_commandon thegateway/slash_commands_*.pymixins — via_command_handler_tableover_IDLE_COMMANDS/_PLAIN_COMMANDSingateway/run_busy.py; there is noif canonical == ...chain- Some commands are gated on config (
gateway_config_gateonCommandDef)
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 underplugins/platforms/<name>/adapter.py; a few legacy adapters still live directly in gateway/platforms/. All extend BasePlatformAdapter from gateway/platforms/base.py:
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 managementsend()— outbound message delivery- inbound events are normalized into a
MessageEventand forwarded viahandle_message()
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 callacquire_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 themibyan sendCLI for shell scripts and via crondeliver:targets - Cross-platform delivery — deliver to a different platform than the originating message
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:- Gateway creates an
AIAgentper message with the session ID - The
MemoryManagerinitializes the provider with the session context - Provider tools (e.g.,
honcho_profile,viking_search) are routed through:
- 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 controlsystemctl(Linux) orlaunchctl(macOS) — service management- PID file at
~/.mibyan/gateway.pid— profile-scoped process tracking
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
Withgateway.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 orplugins.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_runsubscribesPluginManager.on_plugin_loadedfor the launch profile and_load_secondary_profile_configdoes so per served profile. The event fires from insidediscover_and_load(never from an RPC) for the newly loaded plugins; the callback hops onto the gateway loop withcall_soon_threadsafe. - Idempotent re-wire —
BasePlatformAdapter.rewire_plugin_handlers()re-readsget_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 missingregister_slack_action_handlercallbacks once perAsyncApp. reload-pluginscontrol verb — other processes (mibyan plugins install,mibyan serve) ask the running gateway to force-rescan the requested (served) home; the answer carriesplugins, per-pluginactivationsandadapters_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.

