Architecture Overview
BasePlatformAdapter from gateway/platforms/base.py and implements:
connect()— Establish connection (WebSocket, long-poll, HTTP server, etc.) (abstract)disconnect()— Clean shutdown (abstract)send()— Send a text message to a chat (abstract)send_typing()— Show typing indicator (optional override)get_chat_info()— Return chat metadata (optional override)
self.handle_message(event), which the base class routes to the gateway runner.
Plugin Path (Recommended)
The plugin system lets you add a platform adapter without modifying any core Mibyan code. Your plugin is a directory with two files:plugin.yaml
Plugin metadata. Therequires_env and optional_env blocks auto-populate mibyan config UI entries (see Surfacing Env Vars below).
Outbound client tools: provides_tools
kind: platform plugins are deferred: the adapter module (and its SDK
imports) only load when a gateway, cron, or send_message path first asks the
platform registry for the platform. If your plugin also ships outbound client
tools the agent should be able to call from any session (the bundled a2a
plugin’s a2a_call / a2a_discover etc.), put them in a dedicated tools.py
with a register_tools(ctx) function and declare them in the manifest:
provides_tools declared, Mibyan imports only tools.py during plugin
discovery and registers the client tools in every process — CLI and TUI
included — while the adapter stays deferred. Keep the package __init__.py
import-light and pull the adapter in from inside register() so the eager
import stays cheap. Without the field, nothing changes: the whole plugin stays
deferred.
Users enable the toolset per platform like any other, e.g.
mibyan tools enable my_platform --platform cli, or by listing the toolset
key under platform_toolsets in config.yaml. Plugin platform names are
also valid --platform targets, so an inbound session on your platform can
be granted its own outbound tools.
adapter.py
Configuration
Users configure the platform inconfig.yaml:
__init__).
What the Plugin System Handles Automatically
When you callctx.register_platform(), the following integration points are handled for you — no core code changes needed:
Standalone send-path extensions
A standalone platform can participate in host-driven outbound delivery through directmibyan send --to ... and cron deliver=platform:... by declaring send
behavior on the same PlatformEntry created by ctx.register_platform().
send_message is intentionally not an agent-callable model tool; plugins must
not register an equivalent model surface that lets the agent initiate outbound
messages on its own.
Env-Driven Auto-Configuration
Most users set up a platform by dropping env vars into~/.mibyan/.env rather than editing config.yaml. The env_enablement_fn hook lets your plugin pick those env vars up before the adapter is constructed, so mibyan gateway status, get_connected_platforms(), and cron delivery see the correct state without instantiating the platform SDK.
Read env through gateway.platforms._shared.get_scoped_secret — never os.getenv. Under gateway.multiplex_profiles the process env holds the DEFAULT profile’s values; a secondary profile’s .env exists only in its secret scope, and a raw read would enable your platform for the wrong profile with the wrong credentials. seed_extra_from_env builds the seed dict from a (ENV_VAR, extra_key, conv) table through that reader.
YAML→env Config Bridge
Some users prefer settingconfig.yaml keys (my_platform.require_mention, my_platform.allowed_channels, etc.) over env vars. The apply_yaml_config_fn hook lets your plugin own this translation instead of forcing core gateway/config.py to know your platform’s YAML schema.
load_gateway_config() after the generic shared-key loop (which handles common keys like unauthorized_dm_behavior, notice_delivery, reply_prefix, require_mention, etc.) and before _apply_env_overrides(), so your plugin only needs to bridge platform-specific keys.
Exceptions raised by the hook are swallowed and logged at debug level — a misbehaving plugin never aborts gateway config load.
Cron Delivery
To letdeliver=my_platform cron jobs route to a configured home channel, set cron_deliver_env_var to the env var name that holds the default chat/room/channel ID:
deliver=my_platform jobs, and also treats the platform as a valid cron target in _KNOWN_DELIVERY_PLATFORMS-style checks. If your env_enablement_fn seeds a home_channel dict (see above), that takes precedence — cron_deliver_env_var is the fallback for cron jobs that run before env seeding.
Out-of-process cron delivery
cron_deliver_env_var makes your platform a recognized deliver= target. To make the actual send succeed when the cron job runs in a separate process from the gateway (i.e., mibyan cron run separate from mibyan gateway), register a standalone_sender_fn:
tools/send_message_tool.py so cron can deliver without holding the gateway in the same process. Plugin platforms historically depended on _gateway_runner_ref(), which returns None outside the gateway process, so without standalone_sender_fn the cron-side send fails with No live adapter for platform '<name>'.
The function receives the same pconfig and chat_id that the live adapter would, plus optional thread_id, media_files, and force_document keyword arguments. Returning {"success": True, "message_id": ...} is treated as a successful delivery; returning {"error": "..."} surfaces the message in cron’s delivery_errors. Exceptions raised inside the function are caught by the dispatcher and reported as Plugin standalone send failed: <reason>. Reference implementations live in plugins/platforms/{irc,teams,google_chat}/adapter.py.
Surfacing Env Vars in mibyan config
mibyan_cli/config.py scans plugins/platforms/*/plugin.yaml at import time and auto-populates OPTIONAL_ENV_VARS from requires_env and (optional) optional_env blocks. Use the rich-dict form to contribute proper descriptions, prompts, password flags, and URLs — the CLI setup UI picks them up for free.
name (required), description, prompt, url, password (bool; auto-detected from *_TOKEN / *_SECRET / *_KEY / *_PASSWORD / *_JSON suffix when omitted), category (defaults to "messaging").
Bare-string entries (- MY_PLATFORM_TOKEN) still work — they get a generic description auto-derived from the plugin’s label. If a hardcoded entry for the same var already exists in OPTIONAL_ENV_VARS, it wins (back-compat); the plugin.yaml form acts as the fallback.
Platform-Specific Slow-LLM UX
Some platforms have constraints that change how a slow LLM response should be presented:- LINE issues a single-use reply token that expires roughly 60 seconds after the inbound event. Replying with that token is free; falling back to the metered Push API is not. If the LLM hasn’t finished by the deadline, the choice is “burn paid Push quota” or “do something cleverer with the reply token before it expires.”
- WhatsApp marks a session inactive after 24h, after which only template messages are accepted.
- SMS has no concept of typing indicators or progressive updates — long responses just look like the bot is offline.
BasePlatformAdapter can’t anticipate. The plugin surface intentionally leaves the room for an adapter to layer platform-specific UX on top of the base typing loop without expanding the kwarg list.
Pattern: subclass _keep_typing to layer mid-flight UX
BasePlatformAdapter._keep_typing is the typing-indicator heartbeat — it runs as a background task while the LLM is generating, and is cancelled when the response is delivered. To layer a platform-specific behavior at a threshold (e.g. send a “still thinking” bubble at 45s), override _keep_typing in your adapter, schedule your own task alongside super()._keep_typing(), and tear it down in finally:
- Always
await super()._keep_typing(...). The typing heartbeat is independently useful — don’t replace it, layer on top of it. - Tear down the side task in
finally. When the LLM finishes (or/stopcancels the run), the gateway cancels the typing task. Your side task must observe that cancellation too, otherwise it lingers and may fire after the response was already delivered. - Pair with
interrupt_session_activityto resolve any orphan UX state when the user issues/stop. For LINE, this means transitioning the postback cache entry fromPENDINGtoERRORso the persistent “Get answer” button delivers a “Run was interrupted” message instead of looping.
Pattern: subclass send to route through a cache instead of sending immediately
If your slow-response UX caches the response for later retrieval (LINE’s postback flow), your send override needs to recognize three modes:
- Pending postback active for this chat → cache the response under the request_id, don’t send anything visible.
- System busy-ack (
⚡ Interrupting,⏳ Queued,⏩ Steered) → bypass the cache and send visibly so the user sees the gateway’s response to their input. - Normal response → send via reply-token-or-push as usual.
_SYSTEM_BYPASS_PREFIXES are the gateway’s own busy-acknowledgment prefixes (⚡, ⏳, ⏩, 💾). Always let those through visibly, regardless of cached UX state.
When this pattern is appropriate
Use the typing-loop override approach when:- The platform’s outbound API has a hard time-window constraint (single-use reply token, expiring sticky session, etc.) AND
- A visible mid-flight bubble is acceptable UX on that platform.
slow_response_threshold = 0 always-Push path when:
- The platform doesn’t have a meaningful free vs. paid distinction, OR
- The user community prefers “loading… loading… DONE” silence-then-response over an interactive intermediate bubble.
LINE_SLOW_RESPONSE_THRESHOLD=0 reverts to “always Push fallback.”
Reference Implementation
Seeplugins/platforms/line/adapter.py for the full LINE postback implementation — a RequestCache state machine (PENDING → READY → DELIVERED, plus ERROR for /stop), a _keep_typing override that fires the Template Buttons bubble at threshold, a send override that routes through the cache, and an interrupt_session_activity override that resolves orphan PENDING entries.
Reference Implementations (Plugin Path)
Seeplugins/platforms/irc/ in the repo for a complete working example — a full async IRC adapter with zero external dependencies. plugins/platforms/teams/ covers Bot Framework / Adaptive Cards, plugins/platforms/google_chat/ covers OAuth-based REST APIs, and plugins/platforms/line/ covers webhook-driven Messaging APIs with platform-specific slow-LLM UX.
Step-by-Step Checklist (Built-in Path)
This checklist is for adding a platform directly to the Mibyan core codebase — typically done by core contributors for officially supported platforms. Community/third-party platforms should use the Plugin Path above.
1. Platform Enum
Add your platform to thePlatform enum in gateway/config.py:
2. Adapter File
Createplugins/platforms/newplat/adapter.py:
MessageEvent and call self.handle_message(event):
3. Gateway Config (gateway/config.py)
Three touchpoints:
get_connected_platforms()— Add a check for your platform’s required credentialsload_gateway_config()— Add token env map entry:Platform.NEWPLAT: "NEWPLAT_TOKEN"_apply_env_overrides()— Map allNEWPLAT_*env vars to config
4. Gateway Runner (gateway/run.py + gateway/run_*.py siblings)
Six touchpoints:
_BUILTIN_ADAPTERStable (gateway/run.py) — Add aPlatform.NEWPLAT: (module, class, check_fn, error_msg)entry;_instantiate_adapter()(gateway/run_adapters.py) consults the plugin registry, then this table — there is noelifchain to extend. The_create_adapter()wrapper binds every successful adapter to its gateway runner._is_user_authorized()allowed_users map —Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"_is_user_authorized()allow_all map —Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"- Startup access-policy check (
gateway/run_startup.py) — Add"NEWPLAT"to_ALLOWLIST_ENV_PLATFORMS(derives bothNEWPLAT_ALLOWED_USERSandNEWPLAT_ALLOW_ALL_USERS) - Startup
_BUILTIN_ALLOW_ALL_VARS(gateway/run_startup.py) — derived from the same_ALLOWLIST_ENV_PLATFORMStuple; nothing extra to add _UPDATE_ALLOWED_PLATFORMSfrozenset — AddPlatform.NEWPLAT
5. Cross-Platform Delivery
gateway/platforms/webhook.py— Add"newplat"to the delivery type tuplecron/scheduler_delivery.py— Add to_KNOWN_DELIVERY_PLATFORMSfrozenset and_deliver_result()platform map
6. CLI Integration
mibyan_cli/config.py— Add allNEWPLAT_*vars to_EXTRA_ENV_KEYSmibyan_cli/gateway.py— Add entry to_PLATFORMSlist with key, label, emoji, token_var, setup_instructions, and varsmibyan_cli/platforms.py— AddPlatformInfoentry with label and default_toolset (used byskills_configandtools_configTUIs)mibyan_cli/setup.py— Add_setup_newplat()function (can delegate togateway.py) and add tuple to the messaging platforms listmibyan_cli/status.py— Add platform detection entry:"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")mibyan_cli/dump.py— Add"newplat": "NEWPLAT_TOKEN"to platform detection dict
7. Tools
tools/send_message_tool.py— Add"newplat": Platform.NEWPLATto platform maptools/cronjob_tools.py— Addnewplatto the delivery target description string
8. Toolsets
toolsets.py— Add"mibyan-newplat"toolset definition with_mibyan_CORE_TOOLStoolsets.py— Add"mibyan-newplat"to the"mibyan-gateway"includes list
9. Optional: Platform Hints
agent/prompt_builder.py — If your platform has specific rendering limitations (no markdown, message length limits, etc.), add an entry to the PLATFORM_HINTS dict. This injects platform-specific guidance into the system prompt:
10. Tests
Createtests/gateway/test_newplat.py covering:
- Adapter construction from config
- Message event building
- Send method (mock the external API)
- Platform-specific features (encryption, routing, etc.)
11. Documentation
Parity Audit
Before marking a new platform PR as complete, run a parity audit against an established platform:.md and .ts files. Investigate each gap — is it a platform enumeration (needs updating) or a platform-specific reference (skip)?
Common Patterns
Long-Poll Adapters
If your adapter uses long-polling (like Telegram or Weixin), use a polling loop task:Callback/Webhook Adapters
If the platform pushes messages to your endpoint (like WeCom Callback), run an HTTP server:Inbound Deduplication
Platforms redeliver: websocket resumes replay recent events, webhooks retry, and an unacknowledged poll batch comes back. Drop repeats with the shared helper, keyed on the platform’s message ID:MessageDeduplicator attribute’s live IDs from the old instance to the new one, so a replay right after the reconnect is still dropped. A cache kept in another structure (a plain dict or set) starts empty on the new instance.

