An approval gate your executor doesn't enforce is only a suggestion
An agent skill's stop-and-ask step is only safe if the step runner enforces it and the run state can hold a question. Here's what broke, and a check you can run.
Most agent stacks now have some kind of “skill”: a markdown file that tells a model how to do a multi-step job. Most of those files also contain a line like “ask the user before sending.” That line is prose, and a model reads it at runtime. Nothing guarantees the model stops there. If your approval step lives in the instructions and not in the thing that runs the steps, you have a hope where you think you have a gate.
I spent a stretch of late August turning that line into something an executor enforces. I built it into Vodou, a local-first AI system with persistent memory and MCP tool orchestration. Most of what I learned applies to any runner that fans out tool calls and waits on a person.
A skill that parks at ask me: until someone picks an option
The idea is small. You write the workflow once as a markdown skill and run it by name. The skill can declare stopping points. When the run reaches one, the engine stops, shows a numbered menu, and does nothing else until you pick. A skill cannot quietly do the destructive step, because the step isn’t in the list the runner is allowed to execute yet.
This is the test fixture I used for most of the build. It is public in the repo at skills/my-skills/graph-ask-demo/SKILL.md:
together probes:
cpu: mcp-monitor.get_cpu_info {}
mem: mcp-monitor.get_memory_info {}
then:
need: 1 of 2
brief: write one short line about the machine from {cpu, mem}
ask me:
save this reading?
Two probes run in parallel, a join needs one of the two to succeed, a model writes a line, and then the run asks. The important part is what that compiles to. The steps and the question go to different places:
{
"initial_steps": [
{"id": "cpu", "server": "mcp-monitor", "tool": "get_cpu_info", "parallel_group": "probes", "on_fail": "skip"},
{"id": "mem", "server": "mcp-monitor", "tool": "get_memory_info", "parallel_group": "probes", "on_fail": "skip"},
{"id": "join_probes", "kind": "join", "in": ["cpu", "mem"], "min_success": 1},
{"id": "brief", "depends_on": ["join_probes"], "prompt": "write one short line about the machine from {cpu, mem}"}
],
"stopping_points": [
{"id": 1, "type": "menu", "title": "save this reading?",
"options": {"1": {"label": "Yes", "steps": []}, "2": {"label": "No", "steps": []}}}
]
}
What happens after “Yes” lives inside the option, not in the main step list. The runner can’t reach it without an answer.

The [Run once] button sent the string “run it” and hoped
The first version of “run this plan now” on the plan card posted the chat message run it and relied on the model to do the right thing. It usually did. I wanted “usually” gone.
The obvious fix was also wrong: compile the recipe and hand the steps straight to the step executor. That skips the gate completely. The approval lives in stopping_points, and a bare step executor never reads that field. It would have been worse than the chat shim, because the shim at least had a model that might read “ask me” and pause.
So POST /api/graph/run compiles the recipe, registers it as a live workflow, and runs it through the same driver a saved skill uses, in MCP-servers/Vodou-Console/src/workflow-driver.ts. Nothing is written to disk as a skill. The run record is written, because a run that happened is a run that happened whether or not anyone saved the recipe. I verified it live: an ad-hoc run executed its fan and parked at “save this reading to memory?”, with the run row reading ad-hoc | parked.
A run waiting on a person was already marked complete
This was the one that took the longest to see. My run outcomes were running, complete, partial, blocked, failed and cancelled. None of them meant “the steps are done and the work isn’t.” The executor closes a run when the step list ends. The ask me: menu gets shown afterward by a layer above it. So every run waiting on a human was recorded as complete.
Two guards, each correct, then made the question impossible to answer. The ask lookup only matched live runs. The pending-ask reader refused to give a question to a finished run, and I had added that guard the same morning after it stopped nine dead runs from being answerable. Neither guard was wrong. The state they needed didn’t exist.
I added parked. Recording the ask and parking the run now happen in the same call, because two writes that have to agree will eventually drift. The outcome the run parked from is stored on the ask itself, so answering restores it. A phase whose branches partly failed comes back partial, not complete.
Two more bugs came out of the same area. When I made finished runs collapse to one line, the collapse ran just before the ask was announced, so a parked run hid its own question and waited forever. The ask is now exempt from collapsing, and announcing one reopens the card. And a four-phase skill wrote four run rows for one thing a person did once, because every menu answer started a new run id. I grouped them by passing the parent explicitly from the code answering the menu. It reads the group before answering, because answering clears the ask that identifies it. A time window would have had to guess how long someone takes to press a button.
memory_store ran, then the run parked to ask permission for it
The live test that proved [Run once] worked also found the worst bug of the lot. The gate stopped the workflow correctly. But a side-effecting step, Vodou-Recall.memory_store, sat in an earlier parallel block. It executed as part of the fan, and then the run parked to ask whether it should save. The question was real and the answer no longer mattered.
A stopping point only protects the steps after it. Parallel fans make this easy to miss, because authors think of the block as “gathering” when one branch of it writes.
The fan had its own problems that week. A transport failure in one branch erased the results of its siblings, and a prose step inside a together: block was dropped before the fan ever ran. CI was also running every test twice. Each of these makes a run’s record disagree with what actually happened, and a gate can only be as good as that record.
Invariant: every side effect in a run starts after the approval that governs it, and a run holding a question is never terminal
Both halves can be checked against a real codebase. The first says no code path executes compiled steps without going through the component that reads the stopping points, and no side-effecting tool is ordered ahead of its gate. The second says a run with an unanswered question can’t have a terminal status. If either is false, you have runs that did things nobody approved, or questions nobody can answer.
Query your own run table for approvals that can’t be answered
This takes five minutes on any stack that logs runs, tool calls and approvals. Adjust the names to your schema.
First, questions stuck on finished runs:
SELECT r.id, r.status, a.prompt, a.created_at
FROM runs r
JOIN approvals a ON a.run_id = r.id
WHERE a.answered_at IS NULL
AND r.status IN ('complete', 'failed', 'cancelled');
Passing is zero rows. Any row is a person looking at a question your system will refuse to accept an answer to.
Second, side effects that started before their gate was answered:
SELECT t.run_id, t.tool_name, t.started_at, a.answered_at
FROM tool_calls t
JOIN approvals a ON a.run_id = t.run_id
WHERE t.is_side_effecting = 1
AND (a.answered_at IS NULL OR t.started_at < a.answered_at);
Passing is zero rows. A row like memory_store | 14:02:11 | NULL is my bug from above. If you have no is_side_effecting flag, you’ve learned the first thing to add.
Third, find every caller of your step executor:
grep -rnE "executeSteps|run_steps|execute_plan" src/ | grep -v -i driver
Every hit is a path that can run steps without reading stopping points. My [Run once] shim would have been one.
Skill-compilation research enforces order and still has no word for waiting
The closest academic work is SIGIL, which starts from the same observation: skills are natural-language specs interpreted at runtime, so “required tool calls, ordering constraints, and checks may be skipped even when explicitly prescribed.” Their answer is to compile skills into typed harnesses, which matches what I did. AWS’s post on controlled tool orchestration in MCP servers enforces order inside the server. The Ginger Labs guide states the key point plainly: MCP “is not an orchestration engine” and doesn’t manage workflow state.
What I didn’t find anywhere is the waiting state. Ordering guarantees say B runs after A. They don’t say what the run is while a person decides about B, and that’s where both of my guards broke. Cloudflare’s agents SDK has an open issue with the same underlying problem: several recovery paths answer “did this turn finish?” from different artifacts instead of one authoritative record. parked is my one record for “not finished, waiting on you.”
A fan branch can still write before the question that governs it
The executor enforces the gate. It doesn’t yet stop an author from putting a write in a block ahead of it. The memory_store case is written up as a known issue. Right now the protection is the recipe’s shape, not a check on it. A compiler that refused a side-effecting tool upstream of an ask me: would close it. That compiler check doesn’t exist yet.
If you want workflows you teach once in markdown, run by name, and that can’t get past a stopping point without your answer, that’s what skills do in vodou.ai.