#1. Problem
Three disconnected message paths exist. None knows about the others.
External channels (Telegram, Signal, WhatsApp, Matrix) flow through the filix-gateway (:18790) to the gateway bridge (gateway_bridge.py), which writes to the agent mailbox JSONL. The router polls the mailbox every 15 seconds and delivers messages to in-room agents. Latency: 15–45 seconds. The response path reverses through the mailbox — agents write SEND: messages, the bridge polls, the gateway delivers. Round-trip: 30–90 seconds for a conversation that should feel interactive.
Browser chat connects to the engagement viewer or console, which calls the Anthropic API directly and stores the exchange as session rounds in data/sessions/. The router never sees these messages. An agent in a room cannot see or respond to a browser conversation, and a browser user cannot see room activity.
Claude Code sessions produce JSONL transcripts inscribed as rounds by the inscription hook. These are full tool-enabled environments — rich, but isolated. A Claude Code session has no awareness of concurrent room conversations or browser chats.
The result: three siloed records, three addressing models, three response latencies. An agent working through the router cannot reach someone in the browser. A Telegram user's message takes 30 seconds to arrive while a browser message arrives instantly. The "authoritative record" is split across room logs, session databases, and mailbox JSONL.
#2. Target state
One path for all interactive messaging. The router's room system (:18793) is the hub.
Browser ──WebSocket──┐
│
Telegram ──bridge────┤
Signal ──bridge────┼──→ Router Room ──→ Agent(s) respond
WhatsApp ──bridge────┤ │
Matrix ──bridge────┘ ├──→ Room log (authoritative record)
└──→ Broadcast to all subscribers
(browser WebSocket, bridge → channel)
Claude Code sessions remain separate — they are full tool environments, not chat. But their rounds are tagged with room and engagement IDs so the engagement viewer can show a unified timeline.
#3. Room model
Rooms already exist in the router. This section codifies their role as the universal conversation container.
Identity. A room is a named conversation space. Name is a lowercase slug (council, triage, inscription-work). Room state persists to disk:
- Log:
data/rooms/<name>.jsonl— append-only event stream (already implemented) - Members:
data/rooms/<name>-members.json— current membership (already implemented)
Participants. Two kinds:
- Agents — Claude instances identified by roster name (
reed,veda,cora). Managed by the router: enqueue messages, run turns, broadcast responses. - Users — humans arriving via any channel. Identified by
<channel>:<identity>: browser:zach— browser WebSocket connectionsignal:+351916722028— Signal usertelegram:@username— Telegram userwhatsapp:+351...— WhatsApp usermatrix:@user:server— Matrix user
Membership. Adding a user to a room means their messages route there and they receive room broadcasts. The members file tracks both agents and users. Users don't get enqueued messages (they're not agents) — they receive broadcasts via their transport (WebSocket push or bridge → gateway → channel).
Event types. The router already emits these; this is the canonical list:
| Type | Meaning | Fields |
|---|---|---|
user_message | Input from any user | from, content, room, channel |
turn_start | Agent begins processing | from, room |
dialogue_chunk | Streaming response fragment | from, content, room |
dialogue | Complete response (non-streaming) | from, content, room |
turn_end | Agent finished processing | from, room |
pass | Agent declines to respond | from, room |
system | Router announcements (join, leave, clock) | from: "router", content |
tool | Tool invocation (for display) | from, tool, args, room |
All events carry id, sig, ts, room. All are appended to the room log.
#4. Browser → router
The browser currently calls the Anthropic API directly for chat. The target: the browser connects to the router via WebSocket and participates in rooms like any other client.
Connection. The engagement viewer opens a WebSocket to ws://127.0.0.1:18793. On connect, the router sends a rooms event listing all rooms with member counts and urgency levels (this already happens).
Subscription. User clicks a room (or an agent tab, which maps to that agent's default room) and sends:
{"type": "subscribe", "room": "council"}
The router adds the WebSocket to that room's subscriber set and sends back a subscribed event with current members (already implemented).
Sending. User types a message:
{"type": "user_message", "room": "council", "content": "check the ASC timeline"}
The router dispatches to agents in the room via sequential turn ordering (already implemented). The browser receives turnstart, dialoguechunk (streaming), turn_end events and renders them in real time.
Display. The chat view in Architecture 41 renders room events instead of API responses. The conversation history comes from the room log (loaded on subscribe, then live-updated via WebSocket). The room log replaces the per-session data/sessions/<agent>/browser-<timestamp>/ directories currently created for browser chat.
Per-agent default rooms. Each living agent has a default room named after them (reed, veda, cora). Clicking an agent tab in the browser subscribes to that agent's room. This provides the same "chat with one agent" UX as before, but through the room system. Multi-agent rooms (council, triage) are shown separately in the room list.
#5. External channels → router
The gateway bridge currently writes to the agent mailbox. The router polls the mailbox every 15 seconds. This is the primary latency bottleneck.
Target. The bridge opens a persistent WebSocket connection to the router (ws://127.0.0.1:18793). It subscribes to rooms where external users are active.
Inbound flow:
- External message arrives at gateway (
:18790) - Gateway writes to
gateway.db(unchanged — message log of record for external channels) - Bridge detects new message (polls
gateway.dbevery 5s, down from 30s) - Bridge sends to router via WebSocket:
``json {"type": "user_message", "room": "triage", "content": "message text", "from": "signal:+351916722028", "channel": "signal", "contact": "Johannes Stelzer"} ``
- Router dispatches to agents in room (same sequential turn logic)
- Agents respond —
dialogueevents broadcast to all room subscribers
Outbound flow:
- Bridge receives
dialogueevents via WebSocket subscription - Bridge checks if the original sender is an external user (by
channelfield in the room's active conversations) - Bridge POSTs response to gateway (
/send) with the correct channel and contact - Gateway delivers to external channel
Latency improvement. Inbound: 5s poll + near-instant WebSocket dispatch = ~5s (down from 15–45s). Future: gateway push notification to bridge via webhook eliminates the poll entirely.
What the bridge stops doing:
- Writing to
agent-mailbox.jsonlfor inbound messages - Reading
agent-mailbox.jsonlforSEND:outbound messages - The mailbox remains for agent-to-agent dispatch; the bridge no longer uses it as a message transport
#6. External user room navigation
Telegram and Signal users interact via text — they have no GUI for room selection. The bridge parses command prefixes before routing:
| Command | Action |
|---|---|
/rooms | List available rooms with member counts |
/join <room> | Join a room; subsequent messages route there |
/leave | Leave current room, return to default |
/agents | List agents in current room |
/dm <agent> | Direct message — creates or joins a 1:1 room named dm-<user>-<agent> |
Default behavior. If a user has no room assignment, their message goes to the triage room. The triage agent (Cora) can redirect them. The bridge maintains a channel:identity → room mapping in memory, persisted to data/gateway-bridge-rooms.json.
Command responses. The bridge responds directly to command messages (not via agents). /rooms returns a formatted list. /join confirms with room members. These responses go back through the gateway to the external channel.
#7. Room ↔ engagement relationship
Rooms are conversation containers. Engagements are method-application containers (Architecture 39). They intersect but are not identical.
Association. A room can carry an engagement_id tag in its members file. When set, all room events are cross-referenced to that engagement.
Cardinality. Usually 1:1 — an engagement has a room, a room serves an engagement. But an engagement can span multiple rooms (a review engagement might have review-panel and review-editorial rooms). And a room can exist without an engagement (standing rooms like council, triage).
Unified view. The engagement viewer queries both:
- Room logs (
data/rooms/<name>.jsonl) for interactive conversations - Session rounds (
data/sessions/) for Claude Code work
Both are shown in the engagement's chronological stream, distinguished by source badge (room icon vs session icon). The engagement timeline is the union of room events and session rounds, ordered by timestamp.
#8. Room logs as authoritative record
For browser and external channel conversations, the room log is the canonical store.
What room logs replace:
data/sessions/<agent>/browser-<timestamp>/directories (browser chat sessions)- Mailbox-mediated external message records
What room logs do not replace:
- Claude Code session JSONLs (full tool context, different granularity)
gateway.db(external message log of record, kept for channel-level audit)
Indexing. Room logs are indexed for search:
data/rooms.db— SQLite with FTS5 over room event content- Schema:
roomevents(id, room, type, from, content, ts)+roomevents_fts(content) - Populated by the inscription system, which already watches for new session data and now additionally watches room logs
Inscription. The inscription system (method 53) adds room log inscription alongside session JSONL inscription. Room events map to rounds: each user_message + subsequent dialogue responses form one round. Room rounds are tagged with the room name and, if present, the engagement ID.
#9. Migration path
Six steps, each independently deployable and testable.
Step 1: Per-agent default rooms. Create a room for each living agent on router startup. When a new agent registers, create its room. No behavior change — rooms exist but aren't used by the browser yet.
Step 2: Browser → router WebSocket. The engagement viewer's chat mode connects to the router WebSocket instead of calling the Anthropic API. On "Chat" with an agent, subscribe to that agent's default room, send usermessage, render dialoguechunk events. Test: chat works identically to before, but now goes through the router.
Step 3: Room log display. The engagement viewer loads room logs for display. Clicking a room in the UI shows its event history. Room events appear in the engagement timeline alongside session rounds. Test: room conversations are visible and searchable in the browser.
Step 4: Gateway bridge → router. Update the bridge to send inbound messages to the router via WebSocket instead of the mailbox. Update outbound to receive dialogue events from room subscription instead of polling the mailbox. Test: send a Signal message, receive a response in under 10 seconds.
Step 5: External user commands. Add command parsing to the bridge. Test: send /rooms from Telegram, receive a room list. Send /join council, send a message, receive a response from a council agent.
Step 6: Room log indexing. Add room log FTS5 indexing to the inscription system. Test: search for text from a room conversation in the browser, results appear with room badges.
#10. What doesn't change
- Claude Code sessions. Full tool-enabled environments with their own JSONL transcripts. Not routed through rooms. Cross-referenced via engagement tags.
- The inscription system. Still inscribes sessions. Adds room log inscription as a new source.
- The engagement model. Engagements tag both room rounds and session rounds. Phase graphs, obligations, lifecycle — all unchanged.
- Agent roster and dispatch. The mailbox remains for agent-to-agent messages. The bridge stops using it as a transport, but the protocol is unchanged.
- The gateway. Still receives external messages, still delivers outbound. The bridge is its only consumer; the bridge changes how it forwards, not what the gateway does.
- Room persistence format. JSONL logs and JSON member files — already implemented, already working.
#11. Constraints
No new ports. The router stays on 18793. The browser connects to it directly. The bridge connects to it directly. No proxy, no intermediary.
No new databases (except rooms.db for FTS5 indexing). Room logs remain JSONL — they are append-only event streams, not relational data.
No breaking changes to the WebSocket protocol. All new event types (user_message with channel field) are additive. Existing clients that don't send channel continue to work.
Backward compatibility during migration. The bridge can run in dual mode — writing to both mailbox and router — during the transition. The mailbox path is removed only after the router path is verified.
architecture . 42 . unified messaging . 2026-03-22 . zach + claude
Architecture 42 — Unified Messaging — 2026 — Zachary F. Mainen / HAAK