data-flow.md
July 26, 2026 · View on GitHub
Mental model
A message that arrives at a bridge does not go through the layers in stack order. It goes through them in dispatch order: bridge → adapter → cowork (persona resolution) → router (if no persona pinned) → claude subprocess → tool calls (possibly through forge) → audit append → outbox → daemon → user. The layers are infrastructure; the dispatch is the path.
The picture in one sentence: one Python adapter holds the loop, one claude subprocess does the thinking, every other moving piece is a daemon or an on-disk file.
End-to-end walkthrough
We trace one concrete message: "find me the cheapest train Berlin → Munich for tomorrow" sent as plain text from a whitelisted WhatsApp number.
Stage 1 — daemon receives
The daemon holds a long-lived WhatsApp Web socket via Baileys. When a new message arrives:
currentSettings()readsbridges/whatsapp/settings.jsonfresh (mtime cache). Whitelist, rate limit,enabled_chatsapply as of right now — even if you edited the file ten seconds ago.- The whitelist check rejects unknown senders silently.
is_bot/fromMefilters reject echoes and bot replies.- The message is normalized into an inbox envelope — JSON with
chat_key,text,audio_path(if voice note),attachments[],timestamp. - The envelope is written to
/tmp/corvin-voice-bridge/whatsapp/inbox/<id>.json.
The daemon now waits for an outbox file with the matching id. It does not call Claude.
Stage 2 — adapter picks up
A single adapter process watches every channel's inbox directory at once. On a new file:
_load_channel_settings("whatsapp")reads the daemon's settings fresh — same mtime-cache pattern.- Voice-note transcription — if the envelope has
audio_path, the audio is transcribed via Whisper and the result replacestext. The original audio is kept for the audit entry. - Profile resolution —
_resolve_chat_profile(chat_key)looks upchat_profiles[chat_key]first, then JID-normalised forms (WhatsApp), then"default", then the empty profile (legacy max-open). The result is a base profile —permission_mode,allowed_tools,disallowed_tools,append_system,add_dirs,mcp_servers, optionalpersona. - Cowork merge (if installed) — if
coworkis importable and the profile haspersona: <name>,cowork.resolver.resolve()loads the persona JSON and merges its fields into the profile: lists union, scalars → profile wins,mcp_serversshallow-merged,append_systemconcatenated.
Stage 3 — auto-routing (only if no persona pinned)
If the profile has no explicit persona, _apply_auto_routing() calls bridges/shared/router.py:
- Heuristic pass.
_HEURISTIC_PATTERNSmatchestext.lower()against per-persona regexes. "find me the cheapest train" hits thebrowserpersona's pattern (transactional verb + travel noun). Confidence high → return{persona: "browser", confidence: 0.9, why: "heuristic:travel"}. Done in <1 ms. - Embedding pass (only if heuristic returned nothing or low confidence). The text is embedded via
text-embedding-3-small; cosine-similarity against each persona'srouting_anchors(cached on disk) picks the best. - Fallback. Below threshold →
assistantpersona. The router never returnsNone; the fallback is part of the contract.
The picked persona is merged into the profile (its tools, MCP servers, append_system, possibly its FORGE_ALLOWED_TOOLS). A [browser] prefix is queued for the eventual reply.
Stage 4 — claude subprocess
_build_claude_args() materialises the resolved profile into command-line flags:
--permission-mode default|plan|acceptEdits|bypassPermissions--allowedTools/--disallowedTools--mcp-config <tempfile.json>ifmcp_serversis non-empty--add-dir <path>for each entry inadd_dirs--append-system-prompt <text>- The user message (transcribed text + attachment hints) as the prompt
Two environment variables travel with the subprocess:
FORGE_ALLOWED_TOOLS— the per-persona allowlist (or unset for max-open)VOICE_HOOK_RECURSION— set to1if thisclaudeinvocation is itself a hook (e.g. the summarizer callingclaude -p), to prevent the Stop hook firing on its own output.
Stage 5 — Claude Code runs
This is the only stage we do not own. Claude Code:
- Reads its own MCP config (including the
forgeserver, if it is registered) →tools/listexposes the forged tools the persona may call. - Runs the agent loop: tool calls, sub-agent invocations, file edits, reads.
- Tool calls hit either standard Claude Code tools (Bash, Read, etc.) or MCP tools, including
mcp__forge__<name>for forged tools.
If the agent decides it wants a new tool that does not exist yet, it calls mcp__forge__forge_tool({...}). See forge.md §Lifecycle for the inner loop here. The relevant bit for data flow: the new tool surfaces via tools/list_changed, the agent waits one tick, then calls mcp__forge__<name> with the input it wants. Forge runs the tool in bwrap, writes a runs/<id>/run_manifest.json, and returns the JSON envelope to Claude.
Stage 6 — assistant reply
The adapter parses Claude's JSONL output stream. The final assistant message is captured. Two side-effects in parallel:
- Stop hook fires in Claude Code itself. If running at the desk, the voice plugin's Stop hook intercepts and starts the read-aloud pipeline (summarize → TTS → audio output). This pipeline does not go through the adapter; it uses the same hook system Claude Code provides for any plugin.
- Adapter writes the outbox. A JSON envelope with
text, optionalattachments[](files Claude wrote), optionalaudio_path(ifvoice_summary_modesays to attach a voice note), and optionalexecution_context(Phase 4 K=1: engine metadata + performance metrics) is written to/tmp/corvin-voice-bridge/whatsapp/outbox/<id>.json. While the turn is still running, the adapter also writes interim_progress/_heartbeatenvelopes (samemsg_id) so the daemon can show a live status — seeclaude-ref/adapter-runtime.md§Sticky progress messages for how every daemon renders those as one edited-in-place message instead of a flood. Theexecution_contextfield (when present) is consumed by daemon.js in K=2-5 for per-channel rendering (Discord embed, WhatsApp footer, etc.).
Stage 7 — audit append
For every inbox-to-outbox cycle the bridge audit wrapper appends one entry. Fields include chat_key, persona, routing.why, tool_calls[], forge_calls[], outbox_id, prev_sha, sha. The chain field links the new entry's hash to the previous entry's hash; a tampered or reordered file fails voice-audit verify.
If forge is uninstalled, the wrapper silently no-ops here. The conversation works the same; you simply lose audit until forge is back.
Stage 8 — daemon delivers
The daemon picks up the outbox file, attaches files / voice note, and sends to the chat. It deletes the outbox file on success. The user sees the reply prefixed with [browser] so the persona pick is visible.
Hot-reload boundaries
Several files in this dispatch path are read on every message:
| File | Read by | Effect when edited |
|---|---|---|
bridges/<channel>/settings.json | daemon (currentSettings()) + adapter (_load_channel_settings) | Whitelist, rate limits, chat_profiles, audience toggle apply on next message |
bridges/shared/settings.json | adapter | voice_summary_mode, routing.mode, progress_updates apply on next message |
operator/cowork/personas/<name>.json | resolver, when persona is invoked | Persona's tools, MCP servers, system prompt apply on next message that uses that persona |
operator/forge/policy.json | forge (mtime cache) | Breaker thresholds and forbidden-name patterns apply on next forge call |
Files that do not hot-reload — bound at daemon/adapter boot:
| File | Why |
|---|---|
~/.config/corvin-voice/service.env | API keys read by every daemon at startup; rotating a key needs bridge.sh restart |
| daemon code itself | Structural changes (new envelope fields, new hook logic) — the running daemon does not pick up code edits |
| HTTP ports | Bound to the listening socket at boot |
This split is documented in CLAUDE.md §Hot-reload convention as the rule for editing the codebase.
What can go wrong, where
A short failure-mode atlas, in dispatch order:
| Symptom | Likely stage | Where to look |
|---|---|---|
| Bot does not reply at all | Stage 1 (whitelist / rate limit) | Daemon log: whitelist rejected / rate-limit hit |
| Bot replies but ignores recent setting change | Stage 2 (settings cache) | Adapter log: settings.json reloaded (mtime=...) should appear right after the file edit |
| Bot picks the wrong persona | Stage 3 (router) | Adapter log: routing decision: <persona> via <heuristic|embedding> conf=... |
| Persona's MCP tool not visible | Stage 4 (--mcp-config) | Adapter log: temp MCP file path + claude --mcp-config <path> invocation |
| Forged tool not available right after registration | Stage 5 (tools/list_changed race) | Need a one-tick wait between forge_tool and the first call (see forge.md §Race) |
| Forged tool blocked unexpectedly | Stage 5 (policy hot-reload) | voice-audit tail should show policy.reloaded and the blocking entry |
| Reply text arrives but no voice note in chat | Stage 6 (voice_summary_mode) | bridges/shared/settings.json value vs. expected |
| Audit entry missing | Stage 7 (forge install) | voice-audit verify will say "forge not installed" if so |
Why one adapter, one subprocess
The adapter holds a per-chat_key lock so two messages in the same chat run sequentially. Cross-chat parallelism is allowed: chat A's message 1 can run concurrently with chat B's message 1, but A's message 2 waits for A's message 1 to finish.
The single claude subprocess per message is a deliberate choice over keeping a long-running one:
- A fresh subprocess per message means no state leaks between chats. Whatever the agent did in chat A is invisible to chat B, period.
- Per-chat memory is restored explicitly via the
--append-system-promptglue from cowork + the persona, plus the bridge-wide memory tiers (/profile,/memory,/vault). Nothing escapes that surface. - The price is a few hundred milliseconds of
claudestartup per turn. We pay it.
Next
- forge.md — the inner loop of stage 5 when a forged tool is involved.
- security.md — the four enforcement surfaces (ACL, policy, sandbox, audit) and where each one trips.
- agent-behavior.md — what stages 4-7 look like from inside Claude, including how I (the agent) see hot-reload.