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.

One message from phone to reply, eight stages with hot-reload boundaries annotated.

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:

  1. currentSettings() reads bridges/whatsapp/settings.json fresh (mtime cache). Whitelist, rate limit, enabled_chats apply as of right now — even if you edited the file ten seconds ago.
  2. The whitelist check rejects unknown senders silently.
  3. is_bot / fromMe filters reject echoes and bot replies.
  4. The message is normalized into an inbox envelope — JSON with chat_key, text, audio_path (if voice note), attachments[], timestamp.
  5. 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:

  1. _load_channel_settings("whatsapp") reads the daemon's settings fresh — same mtime-cache pattern.
  2. Voice-note transcription — if the envelope has audio_path, the audio is transcribed via Whisper and the result replaces text. The original audio is kept for the audit entry.
  3. Profile resolution_resolve_chat_profile(chat_key) looks up chat_profiles[chat_key] first, then JID-normalised forms (WhatsApp), then "default", then the empty profile (legacy max-open). The result is a base profilepermission_mode, allowed_tools, disallowed_tools, append_system, add_dirs, mcp_servers, optional persona.
  4. Cowork merge (if installed) — if cowork is importable and the profile has persona: <name>, cowork.resolver.resolve() loads the persona JSON and merges its fields into the profile: lists union, scalars → profile wins, mcp_servers shallow-merged, append_system concatenated.

Stage 3 — auto-routing (only if no persona pinned)

If the profile has no explicit persona, _apply_auto_routing() calls bridges/shared/router.py:

  1. Heuristic pass. _HEURISTIC_PATTERNS matches text.lower() against per-persona regexes. "find me the cheapest train" hits the browser persona's pattern (transactional verb + travel noun). Confidence high → return {persona: "browser", confidence: 0.9, why: "heuristic:travel"}. Done in <1 ms.
  2. Embedding pass (only if heuristic returned nothing or low confidence). The text is embedded via text-embedding-3-small; cosine-similarity against each persona's routing_anchors (cached on disk) picks the best.
  3. Fallback. Below threshold → assistant persona. The router never returns None; 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> if mcp_servers is non-empty
  • --add-dir <path> for each entry in add_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 to 1 if this claude invocation is itself a hook (e.g. the summarizer calling claude -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 forge server, if it is registered) → tools/list exposes 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:

  1. 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.
  2. Adapter writes the outbox. A JSON envelope with text, optional attachments[] (files Claude wrote), optional audio_path (if voice_summary_mode says to attach a voice note), and optional execution_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/_heartbeat envelopes (same msg_id) so the daemon can show a live status — see claude-ref/adapter-runtime.md §Sticky progress messages for how every daemon renders those as one edited-in-place message instead of a flood. The execution_context field (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:

FileRead byEffect when edited
bridges/<channel>/settings.jsondaemon (currentSettings()) + adapter (_load_channel_settings)Whitelist, rate limits, chat_profiles, audience toggle apply on next message
bridges/shared/settings.jsonadaptervoice_summary_mode, routing.mode, progress_updates apply on next message
operator/cowork/personas/<name>.jsonresolver, when persona is invokedPersona's tools, MCP servers, system prompt apply on next message that uses that persona
operator/forge/policy.jsonforge (mtime cache)Breaker thresholds and forbidden-name patterns apply on next forge call

Files that do not hot-reload — bound at daemon/adapter boot:

FileWhy
~/.config/corvin-voice/service.envAPI keys read by every daemon at startup; rotating a key needs bridge.sh restart
daemon code itselfStructural changes (new envelope fields, new hook logic) — the running daemon does not pick up code edits
HTTP portsBound 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:

SymptomLikely stageWhere to look
Bot does not reply at allStage 1 (whitelist / rate limit)Daemon log: whitelist rejected / rate-limit hit
Bot replies but ignores recent setting changeStage 2 (settings cache)Adapter log: settings.json reloaded (mtime=...) should appear right after the file edit
Bot picks the wrong personaStage 3 (router)Adapter log: routing decision: <persona> via <heuristic|embedding> conf=...
Persona's MCP tool not visibleStage 4 (--mcp-config)Adapter log: temp MCP file path + claude --mcp-config <path> invocation
Forged tool not available right after registrationStage 5 (tools/list_changed race)Need a one-tick wait between forge_tool and the first call (see forge.md §Race)
Forged tool blocked unexpectedlyStage 5 (policy hot-reload)voice-audit tail should show policy.reloaded and the blocking entry
Reply text arrives but no voice note in chatStage 6 (voice_summary_mode)bridges/shared/settings.json value vs. expected
Audit entry missingStage 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-prompt glue 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 claude startup 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.