Human in the Loop: Approvals and Interrupts
We gave an internal agent one boring chore: delete stale test accounts every night. Anything untouched for ninety days, gone. It ran for three weeks without a hiccup. Then one Tuesday a paying customer emailed asking where their account had gone.
The account had been dormant for ninety-one days. It matched the filter. The agent called deleteAccount, the account was real, there was no undo, and nobody approved it because nobody was asked. When the customer wanted to know how a real account got wiped, the only honest answer we had was “the model decided to.” That is not an answer you can give a paying customer.
The moment an agent can spend money, send a message, delete data, or touch production, “the model decided to” stops being acceptable. A human checkpoint on the right actions is the whole difference between a useful assistant and an incident writeup. This article is about where to put those checkpoints, how the pause actually works in code, and the one mistake that quietly disables the entire mechanism.
Not every action deserves a gate
The instinct after a scare like ours is to gate everything. That is a worse mistake than gating nothing, and I will come back to why at the end. The useful question is not “should a human approve tool calls.” It is one question you ask about each tool, one at a time:
What is the worst thing a wrong call could do?
That worst case is the tool’s blast radius, and it sorts your tools into tiers.
- Read-only.
getOrder,searchDocs,readFile. A wrong call wastes some tokens and maybe reads a record it did not need. Annoying, cheap, reversible. Let it run. - Mutating but recoverable. Draft a reply, set a low-stakes field, create a record you can trivially delete. A wrong call is a quick cleanup. Usually let it run, sometimes gate it.
- Irreversible, expensive, or outward-facing.
sendEmail,refundOrder,deleteAccount,deploy,wireTransfer. A wrong call spends money you cannot claw back, or says something to a customer you cannot unsay, or destroys data with no undo. Gate these. Always.
This tiering is not a new idea you have to invent. It is the same instinct behind authorization: decide what an actor is allowed to do based on the damage it could cause. The gate is a second thing layered on top. Authorization asks “is this actor permitted to call deleteAccount at all,” and the human gate asks “for this specific call, with these specific arguments, did a person say yes.” You need both. A gate is not a substitute for a permission check, and a permission check does not ask the question a gate asks.
Tier your tools where you define them, in the tool catalog, so the gate travels with the tool. A tool that can be called by three different agents should not rely on each of them remembering to wrap it.
How the pause actually works
Strip an agent down and it is a while loop around tool calling: the model asks for a tool, your code runs it, you feed the result back, the model decides again. A gate is one if inside that loop. When the model asks for a guarded tool, you do not run it. You stop, save where you are, and surface the proposed call to a human.
Here is the loop with the guard in it. The only new line is the check before you execute:
while (reply.toolCalls?.length) {
messages.push(reply); // the assistant turn that asked
for (const call of reply.toolCalls) {
if (needsApproval(call)) {
await parkRun(runId, { messages, call }); // persist, then stop
return; // the loop halts here
}
const result = await runTool(call.name, call.arguments);
messages.push(toolResult(call.id, result));
}
reply = await callModel(messages, { tools });
}
Read the return. The loop does not spin, does not poll, does not sleep. It halts and the function exits. The run now lives entirely in whatever parkRun wrote to storage. Later, when a human makes a decision somewhere else entirely (a different HTTP request, a Slack button, a review queue), a second function loads that state and continues:
async function onDecision(runId, decision) {
const { messages, call } = await loadRun(runId);
if (decision.type === "reject") {
// hand the rejection back as a normal tool result; the model can adapt
messages.push(toolResult(call.id, { error: "rejected by human", reason: decision.reason }));
} else {
const args = decision.editedArgs ?? call.arguments; // approve, or approve-with-edits
const result = await runTool(call.name, args);
messages.push(toolResult(call.id, result));
}
await resumeRun(runId, messages); // pick the while loop back up from here
}
Two things in that handler are worth slowing down on.
A rejection is not an exception. You feed it back to the model as an ordinary tool result that says “a human said no.” The model reads that and adapts: it picks a different action, asks a clarifying question, or gives up gracefully. If you throw instead, you lose that recovery and the run just dies. Rejection is data, not a crash.
And notice decision.editedArgs. The human is not limited to yes or no. They can fix the arguments and approve the corrected version. More on why that matters shortly.
The run has to survive the wait
Look again at parkRun and loadRun, because they are carrying the weight. A human might approve in ten seconds or ten hours. They might approve after they get back from lunch, or after a Slack notification wakes them at night, or after the shift changes and someone else picks it up. You cannot hold a live while loop, an open HTTP request, and a chunk of process memory open across that gap. The process will be redeployed out from under you long before the human clicks.
So the parked run has to live somewhere durable: a row in a database, a record in a key-value store, a document keyed by run id. This is exactly the durable execution problem from the last article, and a human approval is just a very slow step. Everything you did to make a run survive a crash (checkpoint after each step, keep state in a store and not in a process, make the resume idempotent) is what lets a run survive a human going to lunch. If you have not made your runs durable, you cannot do human-in-the-loop properly, because the pause is the longest, least predictable step in the whole run.
The approval card that people can actually use
A gate is only as good as the decision the human makes at it, and the human makes that decision in about five seconds while doing three other things. The interface has to make a good five-second decision the easy one. Four things get you there.
Say what will happen in plain language. Not {"tool":"refundOrder","args":{"orderId":"88213","cents":4000}}. A person should not have to parse JSON to approve a refund. Show “Refund $40.00 to Dana Lee for order 88213.” The plain sentence is what the human is accountable for, so it is what you show.
Show the real target and the real amount. Who actually gets the email. How much money actually moves. Which account actually gets deleted. The dangerous approvals are the ones where the summary looks fine and a single argument is wrong (right action, wrong customer). Put the arguments that determine blast radius where the eye lands first.
Let them edit, not just accept or reject. The model got the amount slightly wrong, or picked the right customer but a stale email address. If the only options are yes and no, the human has to reject, go back, and re-prompt to fix one field. That is slow enough that people just approve the wrong thing to avoid the detour. Let them correct the argument in the card and approve the corrected version. That is what editedArgs was for.
Default to the safe choice. The button your thumb lands on, the one that fires when someone hits Enter on autopilot, should be the one that does nothing. Reject is the default. Approve takes a deliberate act. For genuinely destructive actions, make it more than a click: type the account name, or the word DELETE, to arm the button. Friction is the point.
There is a fifth rule that only shows up under load: batch related approvals. If the agent wants to email the same notice to eight customers, that is one decision (“send this notice to these eight people”), not eight cards. One card with the list, one approve. Nagging someone eight times for one logical action is how you teach them to stop reading, which is the exact failure the next section is about.
Trust that grows
The first week an agent can do something risky, gate it hard. Watch it. Log every decision it proposes and every decision the human makes. After a few hundred approvals where the human clicked yes and nothing went wrong, you have earned the right to loosen the gate on the safe slice of that action, while keeping it on the rest.
The important word is per-action. Trust is not a global dial you turn up. It is earned separately for each tool, and often for a slice of each tool’s argument space. needsApproval is a function, so encode exactly that:
const ALLOWLIST = new Set(["[email protected]", "[email protected]"]);
const ALWAYS_GATE = new Set(["deleteAccount", "deploy", "wireTransfer"]);
function needsApproval(call) {
if (ALWAYS_GATE.has(call.name)) return true; // destructive: never graduates
if (call.name === "refundOrder") return call.arguments.cents > 5000; // gate refunds over $50
if (call.name === "sendEmail") return !ALLOWLIST.has(call.arguments.to); // internal auto, external gated
return false; // everything else runs
}
Read what that encodes. Small refunds flow, big ones stop. Mail to known internal addresses sends itself, mail to anyone else waits for a human. And the three genuinely destructive actions never graduate, no matter how well the agent has behaved, because the cost of the one bad call is too high to ever pay to save a few clicks. An agent proving reliable on drafting replies earns auto-send to the support inbox. It does not earn the right to delete accounts unattended, ever.
Trust can also move backwards. If a gated action starts producing rejections again (the agent’s behaviour drifted after a model upgrade, or the task changed shape), tighten the gate back up. The allowlist is a living thing, not a one-way ratchet.
The log is the point
When a human clicks approve, something more than a UI event happened. An accountability record was created. “The model decided to delete the account” is not a sentence you can stand behind. “Priya approved this deletion at 14:03, and here is the exact card she was shown” is. The gate is what converts an autonomous action into an approved action, and the log is what makes that conversion mean something a week later when someone asks what happened.
So log the whole decision, not just the click:
{
"runId": "run_9f2c",
"tool": "refundOrder",
"proposedArgs": { "orderId": "88213", "cents": 4000 },
"approvedArgs": { "orderId": "88213", "cents": 4000 },
"decision": "approved",
"decidedBy": "[email protected]",
"decidedAt": "2026-07-18T14:03:11Z",
"cardShown": "Refund $40.00 to Dana Lee for order 88213"
}
The field people forget is cardShown. Approving a misleading card is a different event from approving an accurate one, and if a bad action ever gets through a gate, the first question is whether the human was shown the truth. Log what they saw, not only what they clicked. Keep proposedArgs and approvedArgs separate too, so an edit is visible: the model asked for one thing, the human corrected it to another, and the record shows both.
This is the same tracing discipline you would apply to any part of an AI run, pointed at the moments where a human intervened. An approval is a span with an owner. The emerging OpenTelemetry conventions for GenAI give these a standard shape, though as of 2026 that spec is still moving, so expect the attribute names to shift and do not over-fit to them yet.
Approval fatigue is the failure that eats everything
Here is the mistake I promised at the top, and it is the one that quietly undoes all of the above.
Gate too much, and you flood the human with trivial prompts. Thirty an hour, twenty-nine of them a rubber stamp for reading a file. The human learns the only reflex that keeps the queue moving: approve, approve, approve, without reading. And the one prompt that mattered, the deleteAccount buried in the flood, slides through on the same reflex. A gate that is always clicked yes is not a gate. It is a slow yes.
The fix is the tiering from the very top of this article. Gate the actions that matter, let the rest flow, and the prompts that reach a human are few enough to read. Attention is a budget. Every trivial prompt you spend it on is attention that is not there when the dangerous one arrives.
This is also where the gate becomes your last line of defence against prompt injection. A poisoned document or a crafted message can steer the model into calling a tool you never intended, emailAllCustomers with an attacker’s address, wireTransfer to an account nobody recognises. If that tool is gated, a human sees the proposed call before it fires and stops it. But that safety net only works if the human is still reading. Over-gate the system, exhaust their attention on trivia, and the injected action arrives to a human who has been trained by your own UI to click yes. The gate that would have saved you is the one you disabled by using it too much.
Try it: an agent that stops at the risky calls
Below is a simulated agent working through a short plan. Everything runs in your browser with canned data and no network, so there is no real model and no real deletion. Read-only calls (readFile, searchAccounts) run on their own and just appear in the log. The risky calls (sendEmail, deleteAccount) stop and open an approval card. Edit the recipient before you approve the email. Notice the delete makes you type to confirm. Every decision lands in the action log with who and when, because that log is the accountability record from the section above.
Run it a few times. Approve the email once, reject it once, and watch the log record both with a timestamp and the decision. That log is the difference between “the model did something” and “a person named in the record chose to let it happen.”
Summary
- The moment an agent can send, pay, delete, or deploy, “the model decided to” stops being an acceptable answer. Put a human checkpoint on the actions where that is true.
- Tier every tool by blast radius. Read-only runs freely, mutating pauses, irreversible or outward-facing or destructive gets an explicit confirm. Tier it in the catalog so the gate travels with the tool.
- A gate is one
ifin the agent loop: halt at the guarded call, persist the run, surface the action and its arguments, resume only on a decision. Feed a rejection back to the model as a normal result, not an exception. - The run must survive the wait, which can be hours. That is the durable execution problem, with a human as a very slow step.
- Make the card humane: plain language, the blast-radius arguments up front and editable, a safe default that does nothing, and batched approvals so you are not nagging.
- Grow trust per action with argument-level allowlists. The safe slice can graduate to auto-run. Destructive actions never do.
- Log the whole decision, including the card the human saw, because an approval is an accountability record and your trace of who chose what.
- Approval fatigue is the failure that disables everything else. Gate too much and people rubber-stamp, so gate only what matters. A gate you always click yes on is a slow yes, and it is also the prompt-injection defence you just switched off.