Design Note: Bridge Mirror
June 16, 2026 · View on GitHub
- Status: Draft
- Tracking issue: to be filed
- Author: @shangdinggu
- Last updated: 2026-05-08
- Builds on:
0009-agent-mailbox.md,0010-agent-registry.md
This RFC defines how existing bridges (Telegram, WeChat, Slack, …)
expose their inbound / outbound message streams through the kernel
mailbox primitive. It does not touch any bridge code under
bridges/. The deliverable is a small helper module
(kernel/bridge_mirror.py) that:
- Defines a canonical topic naming scheme so any agent in the system can subscribe to bridge traffic without knowing which bridge wrote which row.
- Defines a canonical message shape (
BridgeMessage) so subscribers don't need to parse N different formats. - Provides publish + drain helpers so a future, optional patch to a bridge module can mirror its messages with two function calls — without rewriting the bridge.
The existing RuntimeContext callbacks in
bridges/{telegram,wechat,slack}.py keep working unchanged. A bridge
can adopt the mirror at its leisure; an agent can subscribe to the
canonical topic with no awareness of whether the mirror is active.
1. Goals & non-goals
Goals:
- One agent, all bridges. A research agent that wants every
inbound chat message subscribes to
bridge.*.inbound(well — subscribes per-bridge in v1; topic-glob matching is a future RFC) and gets messages from all bridges in one inbox. - One bridge, many subscribers. Telegram inbound goes to one topic; the REPL, the research agent, and the audit logger all subscribe; each gets a copy.
- Outbound queue. A bridge worker subscribes to
bridge.<kind>.outbound; any agent that wants to send a message publishes to the same topic. The bridge worker drains and emits. - No required bridge changes. The mirror is a library used from outside the bridge or via a small voluntary patch inside the bridge.
- Backwards compatible message shape. The canonical
BridgeMessagefields are the lowest-common-denominator across the existing bridges; specific bridges' richer fields go inmetadata.
Non-goals (v1):
- Routing rules. No filtering ("only forward if the chat_id matches X"); subscribers do that themselves.
- Acknowledgement / retries. Bridges' delivery semantics vary too much. The mirror just enqueues; the bridge worker's send callback owns retry policy.
- Encryption. Bridge payloads ride in the kernel mailbox cleartext (same trust boundary as the kernel itself).
- Cross-bridge orchestration. A user "send the same message to all my bridges" is a layer on top — write a small fan-in helper using publish-to-each-topic.
2. Topic naming
bridge.<kind>.inbound — messages received from the world
bridge.<kind>.outbound — messages queued for the world
Where <kind> is a lowercase bridge identifier. The four shipped
helpers cover:
| Constant | Value |
|---|---|
BridgeKind.TELEGRAM | "telegram" |
BridgeKind.WECHAT | "wechat" |
BridgeKind.SLACK | "slack" |
BridgeKind.DISCORD | "discord" |
Custom kinds ("matrix", "signal", …) are valid as long as they
match ^[a-z][a-z0-9_-]*$. Validation rejects upper-case, dots,
and other reserved characters.
A future RFC may add bridge.<kind>.<chat_id>.inbound for
per-channel topics. For v1, all messages from a bridge land on one
topic; subscribers filter by payload.sender if needed.
3. Message shape
@dataclass(frozen=True)
class BridgeMessage:
kind: str # "telegram" | "wechat" | "slack" | …
sender: str # opaque sender id (chat_id, user_id, …)
text: str
direction: str # "inbound" | "outbound"
metadata: dict # bridge-specific extras
ts: float # epoch seconds
When sent on kernel.mbox:
kindis"bridge.message"(the kernel mailboxkindfield).payloadis the BridgeMessage as a dict (.to_dict()).topicisbridge.<kind>.<direction>.
This keeps kernel.mbox.recv consumers uniform: they peek at
m.kind to know it's a bridge message, and pull the bridge-specific
fields from m.payload.
4. API
class BridgeMirror:
def __init__(self, kernel: Kernel, *,
sender_pid: int | None = None): ...
def mirror_inbound(self, *,
kind: str, sender: str, text: str,
metadata: dict | None = None,
ts: float | None = None) -> dict:
"""Publish to bridge.<kind>.inbound. Returns the dict
returned by kernel.mbox.publish (delivered/rejected/msg_ids)."""
def queue_outbound(self, *,
kind: str, recipient: str, text: str,
metadata: dict | None = None,
ts: float | None = None) -> dict:
"""Publish to bridge.<kind>.outbound."""
def subscribe_inbound(self, agent_pid: int, kind: str) -> None:
"""Sugar for kernel.mbox.subscribe(pid, inbound_topic(kind))."""
def subscribe_outbound(self, agent_pid: int, kind: str) -> None: ...
class OutboundReceiver:
"""Background drainer for outbound bridge messages.
The agent at ``agent_pid`` must have a mailbox and be subscribed
to ``bridge.<kind>.outbound`` (use mirror.subscribe_outbound).
The receiver thread polls the mailbox, decodes the BridgeMessage,
and calls ``send_fn(message)`` for each.
"""
def __init__(
self, kernel: Kernel, *,
agent_pid: int, kind: str,
send_fn: Callable[[BridgeMessage], None],
poll_interval_s: float = 1.0,
batch_size: int = 32,
): ...
def drain_once(self) -> int: ...
def start(self) -> None: ...
def stop(self) -> None: ...
5. Topology example
┌─────────────┐
│ Telegram │
│ bridge │ bridges/telegram.py
│ (existing) │
└─┬─────────┬─┘
│ │
inbound│ │outbound
▼ ▲
┌─────────────────────────┐
publish │ │ drain
───────►│ bridge.telegram. │◄───────
│ inbound │
│ outbound │
└─┬─────────────────────┬─┘
│ │
subscribers publishers
│ │
┌─────────┴────┐ ┌──────┴────┐
│ research │ │ any │
│ agent │ │ agent │
└──────────────┘ └───────────┘
A bridge wrapper for Telegram would:
- Create a "bridge gateway" agent (or reuse one), give it a mailbox.
- Subscribe to
bridge.telegram.outbound. - Start an
OutboundReceiverwhosesend_fncalls the existing bridge's actual send routine. - On receiving a message from Telegram, call
mirror.mirror_inbound(kind="telegram", sender=chat_id, text=msg).
6. Backwards compatibility
- Bridge code in
bridges/is not touched by this RFC. - The
kernel/bridge_mirror.pymodule is purely additive. - Existing tests for bridges keep passing (they don't use the mirror).
- The mirror requires only the existing
kernel.mboxprimitives; no schema bump, no new RPC method.
7. Open questions
- Glob subscriptions. A subscriber that wants every bridge's
inbound has to subscribe four times today
(
bridge.telegram.inbound,bridge.wechat.inbound, …). Adding a topic glob likebridge.*.inboundis a kernel.mbox feature, not a mirror feature. Out of scope here. - Sender identity. v1 stores
senderas an opaque string. A future RFC may map sender → registered agent (via the AgentRegistry) so messages have proper attribution. - Inbound message ordering across bridges. Each bridge writes
its own topic. If a research agent subscribes to all four, the
relative order of messages from different bridges is FIFO within
the recipient's mailbox (since the kernel uses
INTEGER PRIMARY KEY AUTOINCREMENT), which is good enough.
8. Acceptance criteria
A PR claiming this RFC must:
mirror_inbound(kind, sender, text)produces one mailbox message per subscriber tobridge.<kind>.inboundwith the correctBridgeMessagepayload.- Multi-subscriber fan-out: 3 agents subscribed → 3 message rows on a single publish.
queue_outboundround-trips throughkernel.mbox.publishto any subscriber onbridge.<kind>.outbound.OutboundReceiver: starts a background thread, drains messages into the suppliedsend_fn, stops cleanly viastop(), doesn't leak ifstart()is never called.- Custom (non-
BridgeKind.KNOWN) kinds work, e.g."matrix". - Validation rejects upper-case kinds and reserved characters.
- No file outside
kernel/,tests/,docs/RFC/modified.