building vodou.

The renderer existed. Nothing on that surface ever called it.

A side panel drew agent plans perfectly and could not run one. Four presses found four bugs a green suite could not see, plus the delivery check that catches them.

Chad Priest / / 10 min read

Every agent framework will let a model call a tool. Most will now show you a plan first. Very few can tell you whether the frame carrying that plan’s approval request actually arrived at the surface the person is standing on.

That gap is where I spent two days. Vodou’s browser side panel could already render an agent’s plan: the step list, the monospace block, the warning glyph on slack·slack_send_message, the sentence explaining that a sending step needs an explicit yes. All of it correct. All of it dead. I opened the panel for the first time, pressed things in the order a person would, and found four bugs in four presses. Every one of them passed the test suite. Every one was visible on the first press.

The plan rendered, and there was nothing under it to press

The panel drew the offer and gave no affordance to accept it. No button. So nothing ever ran: I checked server side and there was no run row created at all, not a failed one, none. Which meant the approval event could never fire, which meant the renderer for that event, written and tested weeks earlier, was unreachable from this surface.

It had two tests. One (graph-surface-parity.test.ts) read the source and asserted that a case 'graph_ask' branch existed. One asserted the text the branch produced. Neither could notice that nothing on this surface would ever call it.

This is the exact defect I keep finding in other people’s agent code, and I had shipped it: built, and unreachable from where a person actually is.

POST /api/graph/rungraph_askanswerPlan renderedthe only link that workedRun buttonbug 1: did not existStream fan-outbug 3: knew only web socketsApproval buttonsbug 4: posted to the chatbotParked runEach link had a test. No test walked the loop.
One run, four links, three of them cut

no WS client matched convId=panel:main:msfdv2a9 seq=1 type=graph_ask

I wired the button. It posted the recipe to /api/graph/run with the panel’s own conversation id, deliberately not by sending the text “run it” into chat, because the run driver is what enforces the approval gate that the plan is warning about. An approval gate you can talk your way past is decoration.

Pressed it. Read the log. The server had done everything right: opened the run, walked to the sending step, parked, announced post the summary to #general?. And then that line.

The streaming helper knew about web WebSocket clients and nothing else. A panel conversation lives on the extension socket. Every frame of the run, the ask, the chunks, the done, went to the wrong door, and the panel showed nothing at all after Run. The fix was small: the bridge module now registers its dependencies when a socket attaches and clears them at every detach site (there were six), the fan-out tries the panel after its own loop, and the frame is translated to the panel’s wire shape and pushed through the same emit path so it lands in the replay ring like anything else. Non-panel conversations return false before doing any work.

110 files / 1041 tests green before that change. Also green after. The suite was never the thing that knew.

Then the person pressed 1, and a model wrote a table about it

Third fix landed, the ask reached the panel, zero no WS client lines for the new run, and a three-way fan ran 3/3 from the panel for the first time. Real progress.

Then I pressed the “1” button on the approval menu, and a language model wrote me a summary of my own recipe, invented a default time of 8 AM, and offered to save it.

The button sent the number as a chat message. The reasoning had been that typing “1” still works, which is true in the web chat, because the web chat intercepts a bare number when a run is parked. The panel lane has no such interception. So “1” arrived as a fresh conversational turn with no run attached, and the model improvised. Nothing it said touched the parked run, which was still sitting there waiting.

The buttons now POST to /api/graph/runs/:runId/answer, the same endpoint the web card uses. The run id was already on the ask payload; it had just never been read. An ask that arrives without one now says so instead of quietly falling back to chat.

press 1plan drew, no runbuttonpress 2plan drew twice,card and prosepress 3run started, frameswent to web socketspress 4approval chatted anumber at a modelnext daypanel runs,approves, saves

brain_standalone absent is not brain_standalone: false

The last commit is a different shape of the same disease. The graph view moved into the console, and the old standalone server on :8767 became opt-in. The panel’s link to it now has to know which world an install is in, so the gateway reports it on the handshake.

The case that needed a decision only exists in the field: a gateway too old to send the flag at all. I nearly collapsed it with === true and routed those users to the console. Then I looked at when the opt-in guard was added, and it was the same commit that moved the graph into the console. So a gateway old enough to omit the field is a gateway that was definitely running :8767, and :8767 is where its graph lives. Routing them to the console would have sent exactly those users to a page with no map on it. Nothing 404s. The link just does not do what it says.

Two statesflag === true -> standaloneeverything else -> consolean old gateway lands in the wrong worldThree statestrue -> standalonefalse -> console, the gateway answeredabsent -> the gateway predates thequestion

The computation came out of the paint path into extension/Store-vodou-bridge/brain-link.js as a pure function, with six tests: the three contract cases, plus a tunnelled gateway that must keep its own host and never fall back to loopback, plus a null status that must not throw. The old-gateway case cannot be produced by loading the extension against the gateway on this machine, which is precisely why it needed a fixture rather than a manual check.

The property: an event type is not implemented until one frame of it has been delivered to that surface

Not advice. A checkable property of a codebase, in two parts:

  1. For every event type your server emits and every surface that should receive it, there is a test or a log line proving one real frame arrived. A test that greps for a handler proves the handler exists, which is a claim about your source, not about your system.
  2. Every boolean read across a version boundary is a three-valued read. === true, !== false, and bare truthiness each silently pick an answer for the absent case. Whoever wrote them may not have known there was a question.

The first one is either true or false of your repo right now, and you can go and look.

Instrument the drop, then press the button once

Five minutes, on your own stack, nothing of ours required.

Start with the cheap half, knowing it is the half that lied to me:

# every event type the server emits
grep -rhoE "type: ?['\"][a-z_]+['\"]" src/ | sort -u > /tmp/emitted
# every event type any client handles
grep -rhoE "case ['\"][a-z_]+['\"]" web/ panel/ ext/ | sort -u > /tmp/handled
comm -23 /tmp/emitted /tmp/handled

Now the half that would have caught all four. Make an undeliverable frame count:

function emit(convId: string, event: StreamEvent) {
  const n = fanOut(convId, event);          // sockets that actually took it
  if (n === 0) console.warn(`undelivered conv=${convId} type=${event.type}`);
  return n;
}

Then open each surface you ship, run one multi-step task on it, and read:

grep undelivered server.log | awk '{print $2, $3}' | sort | uniq -c

Passing looks like no output. Failing looks like what I got: a whole run’s worth of frames, all with the same conversation prefix, one per event type, and nobody on the other side. If you persist your stream events, ask the same question in SQL and let the empty cells find themselves:

SELECT s.surface, t.event_type, COUNT(e.id) AS delivered
FROM (SELECT DISTINCT surface FROM stream_events) s
CROSS JOIN (SELECT DISTINCT event_type FROM stream_events) t
LEFT JOIN stream_events e
       ON e.surface = s.surface AND e.event_type = t.event_type
GROUP BY s.surface, t.event_type
HAVING delivered = 0;

Every row is either an event that surface genuinely cannot receive, or one no human has ever caused. You will know which within a minute of reading it.

And for the second half of the property:

grep -rnE "(capabilities|server_info|serverInfo|handshake)[^;]*(=== ?true|!== ?false)" src/

Each hit is a decision about a peer that predates the field, made by someone who probably was not deciding.

The good writing on this is all about the planner/executor split. Tian Pan calls the planning module the seam most practitioners skip and is right that planning and execution should not share an inference call. The plan-and-execute pattern write-up makes the cost and latency case for the same split. Agents From First Principles 04 puts it best: the important architectural change is not the prompt, it is the interface, goal to planner to plan to executor.

All three stop at the executor. Mine was fine. The DEV piece on why agents cannot execute their own plans gets closest, because it locates the failure in the harness rather than the model, but its harness ends at the tool call. The link that broke for me is downstream of every diagram in that literature: executor to the surface where a human is standing, and back. Agent Surface is the only source I read that treats a surface as a first-class thing with a readiness score, and it scores how legible your software is to an agent. I needed the mirror of that: how reachable a human is from your agent, per surface, per event type.

The ask menu still renders twice, and I know exactly why

The plan no longer double-renders. The server sends a structured card and canonical text, the text carries a flag marking it as an echo of the structure, and a surface that drew the card skips the prose. The panel had been outside that rule at both ends: the forwarder in MCP-servers/Vodou-Console/src/vbb/chat.ts dropped the flag, and the panel had no check even if it had arrived. Both ends are fixed, with parity tests I verified fail when the check is removed rather than trusting that they would.

The approval menu still does it. The server emits that menu as both a structured event with buttons and a text chunk, and the ask’s chunk is not marked as an echo, so the panel cannot skip it. You get buttons, then the same menu again as prose. It is a two-line fix in the emitter and I have not made it, because I want the flag to be set by the one place that decides a frame is structural, not by a fifth caller who remembered.

Suppression stays a rendering decision, never a data one. The echoed text still reaches the full turn text, because that is the only record of the turn for the transcript and for memory extraction. A surface that drops it from the record instead of from the screen has quietly deleted history to fix a layout bug.