Skip to main content
This guide covers adding a new messaging platform to the Mibyan gateway. A platform adapter connects Mibyan to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service.
There are two ways to add a platform:
  • Plugin (recommended for community/third-party): Drop a plugin directory into ~/.mibyan/plugins/ — zero core code changes needed. See Plugin Path below.
  • Built-in: Modify 20+ files across code, config, and docs. Use the Built-in Checklist below.

Architecture Overview

Every adapter extends 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)
Inbound messages are received by the adapter and forwarded via self.handle_message(event), which the base class routes to the gateway runner. 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. The requires_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:
With 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 in config.yaml:
Or via environment variables (which the adapter reads in __init__).

What the Plugin System Handles Automatically

When you call ctx.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 direct mibyan 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.
Target resolution is shared across all three outbound surfaces. Parser output is normalized first and channel-directory IDs are trusted. A plugin parser must explicitly accept native target syntax; unresolved strings are never passed through opaquely. Unknown platforms and validator failures return a diagnostic instead of silently attempting delivery. Plugin force-reload/profile transitions unregister owned entries, so parsers and handlers cannot leak into the next profile.

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 setting config.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.
The hook is invoked during 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 let deliver=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:
The scheduler reads this env var when resolving the home target for 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:
Why this hook is necessary: built-in platforms (Telegram, Discord, Slack, etc.) ship direct REST helpers in 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.
Supported dict keys: 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.
These are real constraints the base 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:
Key points:
  • 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 /stop cancels 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_activity to resolve any orphan UX state when the user issues /stop. For LINE, this means transitioning the postback cache entry from PENDING to ERROR so 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:
  1. Pending postback active for this chat → cache the response under the request_id, don’t send anything visible.
  2. System busy-ack (⚡ Interrupting, ⏳ Queued, ⏩ Steered) → bypass the cache and send visibly so the user sees the gateway’s response to their input.
  3. 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.
Use the simpler 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 supports both: the threshold defaults to 45s for free postback fetch, and LINE_SLOW_RESPONSE_THRESHOLD=0 reverts to “always Push fallback.”

Reference Implementation

See plugins/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)

See plugins/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 the Platform enum in gateway/config.py:

2. Adapter File

Create plugins/platforms/newplat/adapter.py:
For inbound messages, build a MessageEvent and call self.handle_message(event):

3. Gateway Config (gateway/config.py)

Three touchpoints:
  1. get_connected_platforms() — Add a check for your platform’s required credentials
  2. load_gateway_config() — Add token env map entry: Platform.NEWPLAT: "NEWPLAT_TOKEN"
  3. _apply_env_overrides() — Map all NEWPLAT_* env vars to config

4. Gateway Runner (gateway/run.py + gateway/run_*.py siblings)

Six touchpoints:
  1. _BUILTIN_ADAPTERS table (gateway/run.py) — Add a Platform.NEWPLAT: (module, class, check_fn, error_msg) entry; _instantiate_adapter() (gateway/run_adapters.py) consults the plugin registry, then this table — there is no elif chain to extend. The _create_adapter() wrapper binds every successful adapter to its gateway runner.
  2. _is_user_authorized() allowed_users map — Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"
  3. _is_user_authorized() allow_all map — Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"
  4. Startup access-policy check (gateway/run_startup.py) — Add "NEWPLAT" to _ALLOWLIST_ENV_PLATFORMS (derives both NEWPLAT_ALLOWED_USERS and NEWPLAT_ALLOW_ALL_USERS)
  5. Startup _BUILTIN_ALLOW_ALL_VARS (gateway/run_startup.py) — derived from the same _ALLOWLIST_ENV_PLATFORMS tuple; nothing extra to add
  6. _UPDATE_ALLOWED_PLATFORMS frozenset — Add Platform.NEWPLAT

5. Cross-Platform Delivery

  1. gateway/platforms/webhook.py — Add "newplat" to the delivery type tuple
  2. cron/scheduler_delivery.py — Add to _KNOWN_DELIVERY_PLATFORMS frozenset and _deliver_result() platform map

6. CLI Integration

  1. mibyan_cli/config.py — Add all NEWPLAT_* vars to _EXTRA_ENV_KEYS
  2. mibyan_cli/gateway.py — Add entry to _PLATFORMS list with key, label, emoji, token_var, setup_instructions, and vars
  3. mibyan_cli/platforms.py — Add PlatformInfo entry with label and default_toolset (used by skills_config and tools_config TUIs)
  4. mibyan_cli/setup.py — Add _setup_newplat() function (can delegate to gateway.py) and add tuple to the messaging platforms list
  5. mibyan_cli/status.py — Add platform detection entry: "NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")
  6. mibyan_cli/dump.py — Add "newplat": "NEWPLAT_TOKEN" to platform detection dict

7. Tools

  1. tools/send_message_tool.py — Add "newplat": Platform.NEWPLAT to platform map
  2. tools/cronjob_tools.py — Add newplat to the delivery target description string

8. Toolsets

  1. toolsets.py — Add "mibyan-newplat" toolset definition with _mibyan_CORE_TOOLS
  2. toolsets.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:
Not all platforms need hints — only add one if the agent’s behavior should differ.

10. Tests

Create tests/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:
Repeat for .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:
For platforms with tight response deadlines (e.g., WeCom’s 5-second limit), always acknowledge immediately and deliver the agent’s reply proactively via API later. Agent sessions run 3–30 minutes — inline replies within a callback response window are not feasible.

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:
When the gateway’s reconnect watcher replaces a failed adapter with a new instance, it copies every 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.

Token Locks

If the adapter holds a persistent connection with a unique credential, add a scoped lock to prevent two profiles from using the same credential:

Reference Implementations