DEV Community

Cover image for Your agent's approval gate is probably just a warning label
Chad Priest
Chad Priest

Posted on Originally published at blog.vodou.ai

Your agent's approval gate is probably just a warning label

Every agent framework lets a model call a tool. Most will print something before a dangerous one. Far fewer actually stop.

That gap is easy to ship without noticing, because the two states look identical in a screenshot. A card that says "this will post to Slack, approve?" and a card that says the same thing and then posts anyway render the same pixels. The difference only shows up in the executor, and the executor is the part nobody screenshots.

I spent the last three days building graph execution into Vodou: a readable recipe format, a plan you see before anything runs, real parallel tool calls, and an approval gate. Thirty commits, fifty-nine files. Two of those commits exist because my gate was a warning label for two days and my parallel executor was never parallel.

Every caller compiles to actions.json through one compiler

A recipe is the readable form of a plan. It compiles down to actions.json, which is what the engines actually run.

together sources:
  calendar: google-calendar.list-events {"calendarId":"primary"}
  mail:     gmail.messages_list {"labelIds":["UNREAD"]}
  slack:    slack.slack_search_messages {"query":"mentions:me"}
then:
  need: 2 of 3
  briefing: write a morning briefing from {calendar, mail, slack}
ask me:
  - post to #daily?
Enter fullscreen mode Exit fullscreen mode

The compiler lives in exactly one place. Chat, the CLI, the setup wizard and the skill proposer all call it, and none of them get to form a private opinion about what need: 2 of 3 means. That was a deliberate call, and the alternative, a TypeScript parser for the UI and a Rust one for the runtime, is how you end up with a plan card that disagrees with the run.

Compiling produces a card, and nothing executes until a button is pressed:

Here's how I'd run that
together — these don't need each other, so they run at once
  calendar  google-calendar·list-events
  mail      gmail·messages_list
  ping      slack·slack_post_message   ⚠
join — needs 2 of 3
⚠ slack·slack_post_message changes something outside Vodou, so this
  will stop and ask before running it. Say "without asking" to opt out.
[Run once] [Save + schedule] [Edit recipe]
Enter fullscreen mode Exit fullscreen mode

The resolved server·tool is on every row. A wrong tool resolution is now visible while it is still free to fix, instead of being discovered by a Slack message that should not have been sent.

A sentence becomes a recipe, then a plan card, then a gated run, with the compiler as the single translation point

One sentence to one run

futures::join_all looked parallel, the executor ran sequentially

The fan was never a fan. Every draft of my plan described the existing parallel tool executor as "already the diamond": the piece I could build on. It wrapped tool calls in futures::join_all and dispatched a set of them at once. It looked correct.

The tool client underneath it was not async. It was a blocking stdio write followed by a blocking read. join_all polls every branch on one task, so the first branch blocked the poll loop and the rest waited their turn. The fan ran strictly one at a time and reported itself as parallel.

Three read-only tools on three different MCP servers:

board 412ms · recall 268ms · proc 83ms
total 770ms   sum 763ms   max 412ms
Enter fullscreen mode Exit fullscreen mode

770 is the sum, not the max. If the fan were real, the wall clock would sit near 412. That code had been in the multi-intent path since it shipped, quietly charging the sum of every branch. After handing each branch to a real thread so the blocking call owns something it is allowed to block:

Before the fix, wall clock 770ms equals the 763ms sum of branches. After, wall clock 633ms against a 1398ms sum.

Wall clock vs sum of branches

The second run had heavier branches. That is the point. Before, the wall clock tracked the sum. After, it tracked the slowest branch. The absolute numbers matter less than the relationship between the two bars in each pair, and that relationship is the only honest test of whether your fan is a fan.

The lesson I actually paid for: async fn in the signature is not the thing that makes it concurrent. A blocking call inside a future is a blocking call. If you have never measured wall clock against the summed durations of the individual branches, you do not know which one you have.

The gate was decoration. The plan card printed the ⚠ and the sentence about stopping to ask. I shipped it. It was, for two days, a caption.

The reason it mattered more than a cosmetic bug: Vodou's parameter engine auto-fills declared booleans as true. So a fan containing slack_post_message would not fail for want of arguments. It would post, with arguments nobody typed, under a warning that promised it wouldn't. A warning the executor does not honour is worse than no warning, because it buys the user's trust and then spends it.

The fix was structural, not cosmetic. Any send-, post-, delete- or pay-shaped tool now gets an approval step appended to the compiled actions themselves. The gate is a node in the graph. The driver that walks the graph stops at it because it is there, not because a renderer remembered to be careful.

Before, the warning was rendered by the card and the executor ignored it. After, the gate is a compiled node the driver must stop at.

That change dragged a second one behind it. A parked run has to survive the question. If a run's life ended when the turn ended, the answer had nowhere to land, so the buttons on the ask were dead. I added a parked state, and then found that a fully-gated plan produced no run record at all, which meant the one case that most needed buttons was the one case that couldn't have them.

The smaller ones, unflattering and in order. A check rendered as a step with no tool. A fan handed downstream steps its tick marks instead of its data. The parser dropped every field the executor needed, which is the kind of bug where both halves look fine in isolation. The recipe author had no tool catalog, so it invented tools that don't exist. The plan rendered twice, once as a card and once as text. The ask arrived twice, once as buttons and once as a numbered list.

And the one that stung: the run store had recorded every graph run since day one, and /api/graph/runs had served them faithfully, and no screen had ever read either. Months of correct data with no consumer. I now treat "written but never displayed" as the same defect class as "displayed but never written."

The approval gate is enforced in the compiler, not the renderer

A control is only as real as the thing that enforces it, and that is never the renderer.

Write down, for your own system, where a safety property is enforced. If the answer is "the UI shows a confirmation," you have a caption. If the answer is "the plan contains a node the executor cannot walk past," you have a control. The test is mechanical: delete the front end entirely and run the plan headlessly. Does the dangerous tool still fire?

Two corollaries I'd apply on any stack:

Compile the plan once, in one language, and let every surface read that artifact. My CLI plan card was blank for every recipe that sends, purely because it had drifted from the same compiler the web card used. One implementation, four consumers.

Make edges earn their place. In my recipe compiler, a then: entry only counts as a dependency if it reads something from upstream. One that reads nothing is not "after" anything. It's independent work that happened to be typed on a later line, and leaving it sequential buys nothing but wall clock. The compiler moves it into the fan and tells the author why, in their own words. Most hand-written agent chains are like this: the order is an artifact of how someone typed, not of what depends on what.

GAP and Flash-Searcher: ReAct loops serialize independent branches

The research has been ahead of the tooling here for a while. GAP puts it plainly: ReAct-style loops rely on sequential reasoning and execution and fail to exploit the parallelism among independent sub-tasks. Flash-Searcher makes the same argument from the other end, moving from sequential chains to DAGs with explicit dependencies. That is the same fake-edge problem, generalized.

The scheduler-theoretic framing in this survey of structured agent graphs is the one that changed how I thought about the gate. It separates constructive parallelism, where every branch must complete, from competitive parallelism, where one branch wins and the rest get cancelled, and it deliberately excludes the competitive kind for controllability reasons. I hadn't thought of controllability as a scheduling property. It is. A run you can approve is a run whose branch set you can enumerate before it starts.

The gap is that MCP itself doesn't help you here, and shouldn't. As Ginger Labs puts it, MCP is not an orchestration engine: it doesn't decide which agent runs next or manage workflow state. It gives you a uniform way to call tools. The scheduling, the dependency graph, the gate and the run record are yours to build, and if you don't build them you get an LLM calling tools in whatever order it felt like, one at a time, with no record.

The multi-intent router still pays the sum of its branches

The sequential fan is fixed in the graph path only. The multi-intent router still calls the old executor and still pays the sum of its branches. Fixing that caller is a separate change with its own blast radius, and I'd rather it be its own commit than a footnote in this one. It's logged.

Vodou has two skill systems and only one has graphs. Scheduled skills are still an LLM prompt on a timer with no steps, no fan and no gate. A user cannot tell them apart by looking, which is a genuine wart.

Only constructive parallelism exists. There is no first-to-succeed branch, for the same controllability reason the research gives.

A finished run leaves a searchable trace by appending one line to today's log in the format the memory extractor already writes, so it becomes findable on the extractor's next pass rather than immediately. That was deliberate: the direct write path into the memory store carries a purge that once deleted 23,090 archived chunks, and a new writer that doesn't know those invariants is a good way to learn them expensively. The cost is latency to searchability, and I'd take that trade again.

Seven of nine intent routes are confirmed working end to end. The other two I haven't proven, so I'm not claiming them.


Source: Your agent's approval gate is probably just a warning label by Chad Priest, from Building Vodou in Public.

Top comments (0)