building vodou.

Our registry called three context lanes dead. One was live.

We re-measured a declared registry of prompt lanes against 3,148 logged rows and found 23 side LLM calls outside the trace. Here's the check to run on yours.

Chad Priest / / 9 min read

If you run an agent in production, you probably have a trace per turn. You log the prompt, the tool calls and the reply. You may also keep a file that lists what goes into the context window: system prompt, memory, tool results, summaries. Do both of those match what your process actually sends to a model? I assumed ours did. Neither one did.

I build Vodou, a local-first AI system with memory, MCP tools and multi-step workflows. A workflow turns one sentence into a plan: resolved tools, parallel steps, checks with fresh context, and your approval before anything sends. Planning a workflow takes more than the one model call you see in chat. Some helpers write the step prose, some draft a skill, some generate trigger phrases. Each is a one-shot completion, and until September 3 none of them showed up on the turn that caused them.

What a turn record has to hold before you can trust a plan

The idea is simple. When you approve a workflow plan, the turn record should hold every model call that shaped the plan, with its full prompt, since the model saw that prompt. It also has to say which world the run executes in. On a dev machine that is either the real install or an isolated test instance I break on purpose. A test harness whose output gets read as the live system is a bad kind of wrong.

Infographic: same brain, it can also act. Skills, MCP tools, automations and workflows with approvals.

Deciding the world used to be a guess based on the path. Now it follows one precedence order, in MCP-servers/Vodou-Console/src/exec-world.ts:

setunsetdeclaredno declarationOperator overrideexplicit setting in theprocess envLaunching stackdeclared exec_world instacks.tomlPath heuristictemp-dir project rootmeans test lablocal or labThe guess still exists, but it only answers when nobody declared anything.
Which world is this process running in?

Each process now carries the name of the stack that launched it. The build inspector reads that name from the live process environment (ps -E on macOS, /proc on Linux) instead of trusting a config file. It prints two things: the stack the process is actually running as, and the stack that declares it. It says undeclared only after it has read an environment and found nothing there. It prints a dash when there is no pid to ask. Those are two different facts, and the old code gave both the same blank.

The milestone marked COMPLETE had three of five changes unbuilt

The build story isn’t flattering. The plan for this work said “complete” in one section, while three of its five stated changes didn’t exist. There were no stack keys, no way to start a stack by name, and the exec seam never read the stack registry. My own notes filed it as an issue: P4 marked “COMPLETE” with three of five stated changes unbuilt. The plan header was worse. It still said the later phases had “not started” while the tree showed several of them shipped. A reader who trusted the header would rebuild finished work, and one who trusted the body would skip unfinished work.

Then I followed the spec and got it wrong. The spec said each process row and each lane row should “gain stacks = [...]”. I started writing that and stopped, because which processes belong to a stack is already written down once, in the stack registry. A second key on every row would copy one fact N times, and that is the kind of drift this whole plan exists to remove. So I didn’t add the keys. The engine derives “which stacks is this process in” and “which world does this stack run in” from the one declaration, and tests pin that derivation. It was the only deliberate deviation from the spec, and I wrote it into the commit so nobody later “fixes” it back.

The lane registry was the bigger surprise. lanes.toml lists nine sources of context that can reach the model. Each lane has a budget, a trust label and an emits field: log, receipt (the user sees it, the log doesn’t) or none (declared, written by nothing). On August 30 a source search had marked three lanes dead and two as receipt-only. On September 3 I re-measured against 3,148 logged inject rows instead of grepping for names.

Registry, from a source grep (Aug 30)hook_intent: deadlenses: deadrolling_summary: deadskill: receipt-onlyautomation: receipt-onlyRegistry, from 3,148 rows (Sep 3)hook_intent: wired, bytes were insideanother lanelenses: retired, logged undersystem_promptrolling_summary: retired, logged as latecontextskill: logs, but zero traffic since thelog beganautomation: logs, but zero traffic

None of the five labels held up. hook_intent was called dead because its name appeared nowhere in the source. Its bytes were reaching the model the whole time, inside the memory hook’s payload, as a suggested-skill hint. Nobody gave them a name. lenses and rolling_summary were called dead, but their text was already logged under other lanes, so a separate lane would have counted the same bytes twice. I deleted them. skill and automation weren’t receipt-only. Both have emitters that write to the log. The log simply had no rows, because no turn had taken those paths since logging began. The only two skill receipts ever recorded were older than the log.

Dead, unexercised and double-counted all look the same in a registry: a lane with no rows. Only measurement tells them apart.

23 one-shot completions, and a test that fails on the undeclared ones

Then the side calls. My notes said there were 33 raw LLM call sites. The real count was 23. The wrapper had always accepted a conversation id, and the pooled variant always carried one, but it only used it for a log line. Now, whenever it gets a conversation id, the wrapper writes a tool/call event with the full prompt and a tool/result event with the completion. Both are tagged with the world and the calling agent (step prose, skill author, trigger generator).

Nine sites really have no turn. The setup wizard, for example, runs before any conversation exists. I didn’t ban those. I banned leaving them unexplained:

on a turn14declared TURNLESS9
One-shot LLM call sites after the change

The gate lives in MCP-servers/Vodou-Console/src/__tests__/oneshot-llm-gate.test.ts. It walks every source file, finds each call, and fails unless the call passes a conversation id or has a // TURNLESS: <why> comment within the three lines above it. A new side call can’t join the codebase silently. Its author has to either put it on the record or write down why it can’t be.

The invariant: every model-visible byte has a named producer that logs it

As a property you can check, it’s this: for every call that sends text to a model, the text is written to the trace under a name, or the call site says in code why it isn’t. The companion rule: whether a context source is dead gets decided from logged rows, never from a name search.

Both halves are true or false of a codebase today. An agent that takes the “simplest solution” approach from Anthropic’s agent guidance still ends up with a helper call that summarizes, classifies or rewrites. Those helpers are exactly the calls that tend to skip the tracing wrapper.

Find your untraced model calls in five minutes

First, list every place your code calls a model client and count the ones that carry no trace or request id nearby. Change the call pattern and the id name to match your SDK:

grep -rnE "(chat\.completions\.create|messages\.create|generateText|invoke)\(" src \
  --include='*.ts' --include='*.py' -A8 \
  | awk '/(chat\.completions\.create|messages\.create|generateText|invoke)\(/{if(site&&!ok)print site; site=$0; ok=0} /trace_id|traceId|run_id|conversation_id|conversationId/{ok=1} END{if(site&&!ok)print site}'

Passing output is empty. Failing output is one line per call site whose next eight lines never mention an id. Each of those calls can put text in front of a model with no record attached.

Second, if your trace store has a span or event table, compare the context sources you declare with the ones that appear:

-- declared_sources: the names in your context config
SELECT d.name,
       COUNT(e.id)            AS rows_logged,
       MAX(e.created_at)      AS last_seen
FROM declared_sources d
LEFT JOIN trace_events e ON e.source = d.name
GROUP BY d.name
ORDER BY rows_logged ASC;

A row with rows_logged = 0 isn’t proof of dead code. Before you delete it, answer three questions. Does an emitter exist? Has any traffic taken that path since logging started? Are its bytes already logged under another name? Then do the reverse: SELECT DISTINCT source FROM trace_events minus your declared names. Anything left over reaches the model without a declaration.

Procedural Graphs and skill papers audit structure, not the prompt bytes

Recent work is clear about structure. The hierarchical skill architecture paper argues for auditability and capability boundaries. Procedural Graphs turns implicit procedure into explicit triples so agents stop calling tools out of order. LangChain’s multi-agent piece says context engineering is the hard part. All three are about what the planner should see. None of them treats the planner’s own helper completions as context that needs a record. Nor do they ask whether the document declaring the structure matches the running system. Our registry was a thoughtful declaration, and the measurement disagreed with it five times out of five.

Still open: skill and automation, two lanes with zero rows

skill and automation are still unexercised. They have emitters, and I believe they log. But “believe” is exactly the word this post argues against, and neither lane has produced a single row since the log began. Workflows themselves are at Phase 1. Later phases are open, and the stack registry can declare a world for a process that service can’t start yet (it names the owning command instead). Until a real automation-scoped turn runs, one registry line is still a claim.

If you want multi-step agent work where the plan, every model call that shaped it, and the world it runs in all land on one turn you can read before approving, that’s what workflows in vodou.ai are built to give you.