Skip to content

Commit 7bde714

Browse files
committed
docs(architecture): decide how channels bind to agents before building more channel code
Channel work kept growing bespoke agent loops per platform; this records the decision that channels are edges, agents are shared, and why v1 goes direct to ACP instead of through a neutral namespace. Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 652a2bb commit 7bde714

2 files changed

Lines changed: 454 additions & 0 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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

Comments
 (0)