|
| 1 | +# Multi-Channel Agent Routing |
| 2 | + |
| 3 | +How a chat channel (Telegram first, Discord and others later) binds to an AI |
| 4 | +agent. This document records two things: the **v1 implementation**, which goes |
| 5 | +directly from the raw Telegram stream to an ACP agent through one bridge |
| 6 | +worker, and the **multi-channel end state**, whose seams v1 keeps as code |
| 7 | +boundaries so the extraction later is mechanical rather than a rewrite. |
| 8 | + |
| 9 | +## The shape in one paragraph |
| 10 | + |
| 11 | +There is no per-channel intelligence. A bridge translates between a platform |
| 12 | +and the agent protocol; agents are plural and protocol-diverse (ACP today, A2A |
| 13 | +and HTTP later) and are reached through in-process adapters behind a single |
| 14 | +trait, never through a new NATS namespace. All conversational state (identity, |
| 15 | +bindings, sessions) lives in JetStream KV, owned by exactly one worker, and is |
| 16 | +designed channel-neutral from day one even while only Telegram exists. |
| 17 | + |
| 18 | +## V1: the direct path |
| 19 | + |
| 20 | +``` |
| 21 | + SUBJECTS / PROTOCOL WORKER |
| 22 | +
|
| 23 | +Telegram ─HTTP─▶ telegram.{update_type} trogon-gateway (exists) |
| 24 | + stream TELEGRAM, raw verbatim JSON |
| 25 | + │ |
| 26 | + ▼ durable consumer |
| 27 | + normalize: parse Update, endpoint, chat-bridge-telegram |
| 28 | + eager-download attachments (one worker) |
| 29 | + identity + binding via KV |
| 30 | + dispatch prompt via AgentPort |
| 31 | + │ |
| 32 | + ▼ acp-nats (already NATS-native) |
| 33 | + ═══ agent works, streams notifications ═══ |
| 34 | + │ |
| 35 | + ▼ render notifications |
| 36 | + Telegram Bot API calls chat-bridge-telegram |
| 37 | + (send, edit-in-place, chunk, throttle) |
| 38 | +``` |
| 39 | + |
| 40 | +Two workers total, one of which already exists. The bridge is the fusion of |
| 41 | +what the end state calls the "edge" and the "router". We fuse them because: |
| 42 | + |
| 43 | +- The prompt/notification traffic **already crosses NATS** inside `acp-nats`; |
| 44 | + a `chat.>` middle namespace would add hops without adding a capability v1 |
| 45 | + needs. ACP over NATS is our version of the direct function call OpenClaw and |
| 46 | + Hermes make in-process (both reference systems are monoliths whose channel |
| 47 | + handlers call the agent loop as a library). |
| 48 | +- Raw-inbound replay is already covered by the gateway's `TELEGRAM` stream. |
| 49 | +- The multi-channel benefits of the middle namespace only exist once there is |
| 50 | + a second channel or a second consumer. |
| 51 | + |
| 52 | +The guard that keeps this from becoming a monolith: everything channel-neutral |
| 53 | +(the KV schemas and binding logic, the `AgentPort` trait, the inbound event |
| 54 | +and render-command types) lives in a **shared crate**, not in the Telegram |
| 55 | +binary. A second channel imports the same brain; it never copies it. |
| 56 | + |
| 57 | +## The multi-channel end state |
| 58 | + |
| 59 | +When a second channel or a second consumer of conversations (audit, analytics) |
| 60 | +arrives, the bridge splits along the seams the shared crate already defines: |
| 61 | + |
| 62 | +``` |
| 63 | +telegram.{update_type} (stream TELEGRAM) trogon-gateway |
| 64 | + │ |
| 65 | + ▼ |
| 66 | +chat.{prefix}.in.{channel}.{account}.{peer} chat-edge-telegram |
| 67 | +stream CHAT_IN_{prefix}, neutral inbound events (normalize half) |
| 68 | + │ |
| 69 | + ▼ |
| 70 | +[identity, binding, conversation KV, chat-router |
| 71 | + per-conversation serialization, AgentPort] (generic, channel-blind) |
| 72 | + │ |
| 73 | + ▼ |
| 74 | +chat.{prefix}.out.{channel}.{account}.{peer} chat-router publishes, |
| 75 | +stream CHAT_OUT_{prefix}, render commands chat-edge-telegram |
| 76 | + │ (render half) consumes |
| 77 | + ▼ |
| 78 | +platform API calls |
| 79 | +``` |
| 80 | + |
| 81 | +- `{channel}.{account}.{peer}` is the **endpoint address**; tokens must be |
| 82 | + subject-safe and edges own the encoding. |
| 83 | +- The router subscribes `chat.{prefix}.in.>` and is channel-blind; a new |
| 84 | + channel is a new edge binary and zero router changes. |
| 85 | +- The subjects carry exactly the types the shared crate already defines; the |
| 86 | + extraction is deployment surgery, not schema design. |
| 87 | + |
| 88 | +## Domain model |
| 89 | + |
| 90 | +``` |
| 91 | +endpoint (channel, account, peer) where messages arrive and leave |
| 92 | + │ many-to-one |
| 93 | + ▼ |
| 94 | +principal the human, across all channels |
| 95 | + │ |
| 96 | + ▼ |
| 97 | +conversation the shared context, cross-channel |
| 98 | + ├── agent_id sticky: set at creation by routing policy |
| 99 | + └── current_session ephemeral: belongs to the agent |
| 100 | +``` |
| 101 | + |
| 102 | +- **Conversations are cross-channel.** The same conversation can be picked up |
| 103 | + from Telegram, Discord, or the CLI. The conversation is the root object and |
| 104 | + endpoints are pointers into it, never the other way around. |
| 105 | +- **Binding is the session-routing record itself**, not a layer in front of |
| 106 | + it: an incoming message resolves endpoint to conversation and follows it. |
| 107 | + Routing policy (which agent handles a new conversation) is consulted exactly |
| 108 | + once, at conversation creation, then the binding is sticky. Operator config |
| 109 | + changes affect new conversations only; live conversations never silently |
| 110 | + change agents. |
| 111 | +- **Sessions belong to agents and churn freely.** A session id alone is not |
| 112 | + routable (it is only meaningful at the agent that created it) and sessions |
| 113 | + die for boring reasons (reset, expiry, agent restart). Session replacement |
| 114 | + never re-runs routing policy and never changes the bound agent. |
| 115 | +- Operations map onto the hierarchy: `/new` replaces `current_session` and |
| 116 | + keeps the agent; rebind is an explicit mutation of `agent_id` and discards |
| 117 | + the session; a stale session is repaired in place. |
| 118 | +- **Reply-to-origin is per prompt, not per conversation.** Several endpoints |
| 119 | + can attach to one conversation, so each prompt records the endpoint it came |
| 120 | + from and the response renders there. Per-conversation serialization is |
| 121 | + mandatory: prompts from two channels into one session queue in order. |
| 122 | + |
| 123 | +## State: JetStream KV buckets |
| 124 | + |
| 125 | +All stateful registries live in JetStream KV, owned exclusively by the bridge |
| 126 | +(the router, after extraction). Config files carry only wiring (NATS |
| 127 | +connection, agent registry). The admin surface for these buckets (CLI, config |
| 128 | +seeding, later GUI or MCP) is deliberately out of scope; KV is the source of |
| 129 | +truth and whatever tool mutates it is pluggable. |
| 130 | + |
| 131 | +| Bucket | Key | Value | |
| 132 | +| --- | --- | --- | |
| 133 | +| `chat_principals_{prefix}` | principal id | display info, policy flags | |
| 134 | +| `chat_endpoints_{prefix}` | endpoint address | principal id | |
| 135 | +| `chat_bindings_{prefix}` | endpoint address | conversation id | |
| 136 | +| `chat_conversations_{prefix}` | conversation id | principal id, agent_id, current_session, activity timestamps | |
| 137 | + |
| 138 | +Access control is identity: an endpoint that resolves to no principal is |
| 139 | +rejected (or ignored) at the bridge. This replaces the per-channel allowlist |
| 140 | +concept with one channel-neutral mechanism. |
| 141 | + |
| 142 | +## Shared-crate types (the wire schemas in waiting) |
| 143 | + |
| 144 | +**Inbound chat event** (a Rust type in v1; the `chat.*.in.*` payload after |
| 145 | +extraction): |
| 146 | + |
| 147 | +``` |
| 148 | +{ |
| 149 | + endpoint: { channel, account, peer }, |
| 150 | + sender: { platform_user_id, display_name }, |
| 151 | + text: string | null, |
| 152 | + attachments: [ { kind, mime, size, object_ref, platform_ref } ], |
| 153 | + message_ref: platform message id (for dedup, replies, edits), |
| 154 | + occurred_at: timestamp |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +**Render commands** (a Rust enum in v1; the `chat.*.out.*` payload after |
| 159 | +extraction): |
| 160 | + |
| 161 | +| Command | Purpose | |
| 162 | +| --- | --- | |
| 163 | +| `send_text` | new message | |
| 164 | +| `edit_text` | streaming preview via edit-in-place | |
| 165 | +| `send_attachment` | upload a produced file, by `object_ref` | |
| 166 | +| `typing` | activity indicator | |
| 167 | +| `react` | acknowledge without text | |
| 168 | + |
| 169 | +The render vocabulary is the one contract every channel implements; it stays |
| 170 | +small on purpose. Both reference systems studied (OpenClaw, Hermes) converged |
| 171 | +on essentially this set. |
| 172 | + |
| 173 | +**Attachments are eager claim-check.** At normalize time the bridge downloads |
| 174 | +the media from the platform (only the token holder can redeem a Telegram |
| 175 | +`file_id`), stores the bytes in the object store, and the event carries the |
| 176 | +reference. Nothing downstream ever needs platform credentials or a callback. |
| 177 | +Lazy fetch-on-demand was rejected: it needs a request/reply surface, fails |
| 178 | +mid-conversation instead of at ingestion, and platform download URLs expire. |
| 179 | +Size is capped at the bridge. |
| 180 | + |
| 181 | +## Agent dispatch: the AgentPort trait |
| 182 | + |
| 183 | +The bridge reaches agents through one in-process trait: |
| 184 | + |
| 185 | +``` |
| 186 | +AgentPort: |
| 187 | + create_session / resume_session |
| 188 | + prompt(session, content) -> stream of agent events |
| 189 | + cancel(session) |
| 190 | +``` |
| 191 | + |
| 192 | +- v1 ships exactly one implementation: ACP, using the existing `acp-nats` |
| 193 | + client machinery. A2A and HTTP become additional implementations later. |
| 194 | +- The agent registry is config: `agent_id -> { protocol, address }` (for ACP: |
| 195 | + the acp prefix; the agent's workspace/cwd is agent configuration, never a |
| 196 | + channel concern). |
| 197 | + |
| 198 | +## Carrying platform structure over ACP: the `_meta` convention |
| 199 | + |
| 200 | +ACP reserves a `_meta` field on nearly every type (`PromptRequest`, every |
| 201 | +`ContentBlock` variant, session notifications) explicitly for attaching |
| 202 | +arbitrary metadata; `acp-nats` already uses it for prompt correlation. Three |
| 203 | +tiers of Telegram structure map as follows: |
| 204 | + |
| 205 | +1. **Content** (text, images, voice, documents): ACP content blocks directly. |
| 206 | + No loss. Claim-check references travel as embedded resources or links. |
| 207 | +2. **Conversational context** (sender, reply-to, group vs DM, forwards): |
| 208 | + carried **twice, deliberately**. A human-readable prefix in the text block |
| 209 | + (works with any ACP agent, since only prompt text reaches the model) and a |
| 210 | + structured object in `PromptRequest._meta` (works richly with agents that |
| 211 | + opt in). `_meta` is machine-visible, not model-visible: a generic agent |
| 212 | + carries it and ignores it, which is safe. |
| 213 | +3. **Platform interactivity** (inline buttons, callback queries, polls, |
| 214 | + edits): inbound, handled at the bridge and translated to synthetic prompt |
| 215 | + text ("user chose: Approve"). Outbound, an agent that participates in the |
| 216 | + convention attaches e.g. `{ telegram: { buttons: [...] } }` to a |
| 217 | + notification's `_meta` and the bridge renders it; event-shaped extensions |
| 218 | + use ACP `ExtNotification`. Agents that do not participate simply produce |
| 219 | + plain text, and the bot degrades gracefully. |
| 220 | + |
| 221 | +Whatever the bridge does not carry is not destroyed: the raw `TELEGRAM` stream |
| 222 | +retains full fidelity for replay when a future need appears. |
| 223 | + |
| 224 | +## Decisions and rejected alternatives |
| 225 | + |
| 226 | +1. **V1 goes direct: one bridge worker, no `chat.>` subjects yet.** The |
| 227 | + neutral vocabulary ships as types in a shared crate; the namespace is the |
| 228 | + documented extraction path, triggered by a second channel or a second |
| 229 | + consumer. Rationale: acp-nats already provides the NATS seam and its |
| 230 | + buffering/observability; the middle namespace pays off only at channel two. |
| 231 | +2. **Channel-neutral vocabulary from day one** even while fused: the shared |
| 232 | + crate, not the Telegram binary, owns the schemas, KV logic, and AgentPort. |
| 233 | + The `tgbot.>` subject space introduced during the Telegram refactor is |
| 234 | + transitional and gets absorbed. |
| 235 | +3. **No `agents.>` NATS namespace; adapters are libraries.** Protocol-neutral |
| 236 | + agent addressability already exists twice in this workspace (`acp-nats` |
| 237 | + for ACP, `a2a-gateway` for A2A). A generic namespace would add a second |
| 238 | + hop and force redesigning streaming RPC over NATS, which `acp-nats` |
| 239 | + already solved. Revisit only if a service other than the bridge/router |
| 240 | + needs to prompt agents. |
| 241 | +4. **Conversation is the root; binding is sticky; policy runs once at |
| 242 | + creation.** Live conversations never hop agents because config changed. |
| 243 | +5. **State in JetStream KV, not config files.** Admin surface out of band and |
| 244 | + unspecified (CLI/config now, GUI or MCP later). |
| 245 | +6. **Eager claim-check attachments**, size-capped at the bridge. |
| 246 | +7. **Platform structure over ACP via `_meta`**, dual-carried (text for any |
| 247 | + agent, `_meta` for participating agents); interactivity degrades |
| 248 | + gracefully with non-participating agents. |
| 249 | +8. **Text rendering via edit-in-place streaming** (`edit_text`), the pattern |
| 250 | + both OpenClaw and Hermes converged on. |
| 251 | +9. **No ADRs for this**: local domain design, recorded here. |
| 252 | + |
| 253 | +## Consequences for existing crates |
| 254 | + |
| 255 | +- `telegram-agent`: its `llm.rs` and `conversation.rs` are the wrong layer |
| 256 | + (channels must not own a model loop) and disappear. Its consumer skeleton |
| 257 | + seeds `chat-bridge-telegram`. |
| 258 | +- `telegram-bot`: its bridge/transform and outbound halves fold into |
| 259 | + `chat-bridge-telegram`, re-targeted at the shared-crate types; the typed |
| 260 | + Telegram event vocabulary in `telegram-types` is explicitly not the neutral |
| 261 | + model and shrinks to whatever the bridge still needs internally. |
| 262 | +- `telegram-nats` (`tgbot.>` subjects, per-prefix streams): transitional, |
| 263 | + removed with the fusion (the bot-to-agent bus it modeled no longer exists |
| 264 | + as a NATS boundary in v1). |
| 265 | +- `trogon-gateway`: unchanged. Its Telegram source stays the single raw |
| 266 | + ingress. Evolution path, not v1: a generic **sink** concept (NATS to |
| 267 | + HTTP-out) symmetric to its sources, which would centralize outbound token |
| 268 | + custody; today the bot token intentionally lives in both the gateway |
| 269 | + (webhook registration) and the bridge (API calls). |
| 270 | + |
| 271 | +## End-to-end walkthrough (v1) |
| 272 | + |
| 273 | +1. User sends "hello" to the bot on Telegram. Telegram POSTs the webhook; |
| 274 | + trogon-gateway validates and publishes the raw Update to |
| 275 | + `telegram.message` (stream `TELEGRAM`). |
| 276 | +2. chat-bridge-telegram consumes it, parses the Update, encodes the endpoint |
| 277 | + address, and eager-downloads any attachments into the object store. |
| 278 | +3. The bridge resolves endpoint to principal (reject if unknown), endpoint to |
| 279 | + conversation (create via routing policy if absent, writing the sticky |
| 280 | + `agent_id`), and ensures a live session on that agent through the ACP |
| 281 | + adapter (create or resume). |
| 282 | +4. The bridge dispatches the prompt with conversational context dual-carried |
| 283 | + (text prefix + `_meta`), recording the origin endpoint for this prompt. |
| 284 | +5. The agent streams session notifications over acp-nats. The bridge renders |
| 285 | + them: `typing`, then edit-in-place preview updates, finally the completed |
| 286 | + text, chunked at 4096 chars with edit throttling, plus any `_meta`-carried |
| 287 | + interactivity (buttons) the agent attached. |
| 288 | +6. The same user later opens the CLI or Discord: a different endpoint mapped |
| 289 | + to the same principal binds to the same conversation and continues it; |
| 290 | + replies go to whichever endpoint prompted. |
0 commit comments