building vodou.

Your first-run path is the only code that runs with defaults off

A liveness ping answered by the transport, and a demo riding a toggle that ships off. Two failures in one first-run path, plus the check to run on yours.

Chad Priest / / 7 min read

Your system has a readiness check somewhere. Something asks “is the worker up,” something answers yes, and then you build a user-facing step on top of that yes: press this button, the thing behind it will be there. The question worth asking is which layer produced the yes. In our case it was Chrome’s network stack, and the worker it was vouching for had been asleep for twenty minutes.

That was one of two failures I hit building a guided first run for Vodou, and the second one was worse, because nothing reported it at all.

What the walkthrough has to prove in three minutes

Vodou keeps memory locally and injects it into other people’s chat products. The first-run promise is narrow and checkable: facts you type at minute one show up inside a ChatGPT answer at minute three, with your permission, and you watch it happen. Three beats. Type a few facts. Watch them cross a vendor boundary. Then an agent runs on its own.

Beat one leans on a distinction that matters to anyone building memory. The normal path is typed, captured, extracted, chunked, and it takes minutes to hours because extraction is poll-driven. A pin bypasses all of it: the chunk exists when the write returns. If a user is going to go looking for a fact in the next ninety seconds, that fact needs a synchronous write path, not a queue. We also had to ensure the vault exists at pin time, because the read side hard-errors on a missing vault and a fresh install has none.

Beat two is the part I want to talk about, because it is where the design I started with was wrong.

exact textprefill confirmedrepliedGatewaycomposes memory block +questionContent scriptverified insert onlyUser presses sendCapture lanelanded turn = step doneEvery step checks itself off from an artifact, not from a function returning.
Beat two, after the rewrite

connected was true and the worker had been suspended for twenty minutes

The demo asks the user to perform. Press inject, press send. It must never ask until the product has already proven the trick will work, so I built a readiness ladder that polls in the background while the user is still typing answers, three rungs, one remedy per red rung.

Rung one was supposed to be trivial: is the extension installed and paired. I had a connected boolean from the socket layer and I used it. It is a lie, and it is the exact lie the ladder exists to catch. A Manifest V3 service worker gets suspended aggressively, and a WebSocket ping against a suspended worker is answered by Chrome’s own network stack. The pong comes back. The worker is not running. We had already been bitten by this once and I reached for the boolean anyway.

Rung one is now gated on last_seen_ms < 30s from a pairing heartbeat rather than on connected. Rung two is a different thing entirely: an on-demand round trip where the reply is composed by worker JavaScript at request time. Receiving it proves code executed milliseconds ago, and the payload carries the two capabilities the demo needs to branch on anyway, so the probe is not ceremony. Pairing fires about four seconds after install, the UI polls every three, so the rung flips green in front of the user instead of behind them.

What I shipped firstsocket says connectedtransport answers the pingsuspended worker looks healthyuser presses a dead buttonWhat it is nowheartbeat under 30sprobe reply composed in JScapability flags ride alonggreen means code just ran

The demo would have died on a toggle that ships off

The second failure never produced an error. My first cut had beat two ride the same inject path a normal user rides, which felt correct: demo the real thing, not a special case. That path is behind a master toggle, and the toggle is off on a fresh install. So the demo would have called the real code, the real code would have correctly declined, and the user would have sat looking at an empty text box in someone else’s product with no explanation. On my machine, where the toggle had been on for months, it worked every time.

The fix moved composition server-side. The gateway builds the exact string to insert, memory block plus the question generated from the user’s own beat-one answer, and the content script performs only a verified insert. The demo no longer reads that toggle at all.

That was the third time in one work session that a default with a sane production value broke a first run. The pattern is consistent enough to name.

The property: a readiness signal must be answerable only by the code that will do the work

Two invariants, both checkable against a codebase rather than argued about.

A readiness signal must be answerable only by the layer that will perform the work. If a proxy, a network stack, a framework middleware, or a cached row can produce the affirmative answer without your code executing, the signal is measuring the wrong thing. This is true or false of any given health endpoint, and you can go look right now.

And: no step in a first-run path may read a setting whose default value fails that step. The first-run path is the only code in your system that executes with every setting at its factory value. Every other path you test has been contaminated by your own configuration. If your onboarding calls into production code, it inherits production’s assumptions about what has already been turned on.

Two curls, a kill -STOP, and one GROUP BY over your step table

Five minutes, nothing of ours involved.

First, find out who answers your health check.

# Does the answer prove your process computed it, right now?
curl -s "localhost:8080/healthz?nonce=$RANDOM"; echo
curl -s "localhost:8080/healthz?nonce=$RANDOM"; echo

# Now suspend the process (not kill: suspend, which is the MV3 case)
kill -STOP "$(pgrep -f my-worker)"
curl -s --max-time 3 "localhost:8080/healthz?nonce=$RANDOM"; echo "exit=$?"
kill -CONT "$(pgrep -f my-worker)"

Passing looks like a body that changes per call and contains something only the process could produce ({"echo":"18412","uptime_ms":417233,"build":"a4f19c2"}), followed by exit=28 when suspended. Failing looks like a byte-identical {"status":"ok"} twice, still returned while the process is stopped. If you get a 200 out of a suspended process, something in front of your app is answering for it, and every user-facing decision you make on that signal is unfounded.

Second, find out whether your guided flow believes itself. Over whatever table records step completion:

SELECT step,
       COUNT(*)                                       AS marked_complete,
       SUM(CASE WHEN evidence_id IS NULL THEN 1 END)  AS complete_without_evidence
FROM onboarding_steps
GROUP BY step
ORDER BY complete_without_evidence DESC;

If complete_without_evidence is near zero, your steps are confirmed by artifacts. If it equals marked_complete, your steps are confirmed by the fact that you called a function. If there is no evidence column to join at all, that is the finding, and it took you thirty seconds.

The architecture maps have no node for the first run

The good writing on agent systems is about the steady state. Anthropic’s guidance is to prefer simple composable patterns over frameworks, which is right and which I follow. The canonical-architecture decomposition opens with “you can’t evaluate what you can’t decompose” and then maps planner, memory, tools, and the places each fails. Mastra’s production piece is blunt that most agent failures are design failures rather than model failures, and cites a Gartner projection that 40% of enterprises will demote or decommission autonomous agents by 2027 over governance gaps found only in production. OpenAI’s practical guide walks orchestration and guardrails the same way.

None of them has a node for the first execution. Even the onboarding material written for agents, like Anthropic’s managed-agents onboarding flow with its describe, agent, environment, session beats, describes the configuration a competent operator supplies, not the state of a machine that has never been configured. The decomposition treats configuration as an input. On a fresh install it is the variable under test, and it is the one setting you cannot reproduce locally because your own machine stopped being fresh months ago.

The one test that would have caught both of these never ran

The end-to-end run on a genuinely fresh Chrome profile did not land in these commits. It is the only test that actually exercises the defaults problem, and I know that, and I shipped without it. Everything above was found by reading code and reasoning about defaults, which is exactly the method that produced the bug in the first place. Until that run exists, the honest claim is that I fixed the two instances I could see, not the class.