Let Your Agent Ask Before It Does the Irreversible Thing
Most of what an agent does is safe to let run. A few things are not: the refund, the production deploy, the email to a customer. The answer is not to make the agent slower everywhere. It is an approval gate that pauses the run before the risky action, persists the pending decision, and resumes exactly where it stopped once a human says yes or no.
An agent is working a support ticket. It reads the account, checks the order, decides the customer is owed a refund, and calls issue_refund(amount=4200, account="..."). It is probably right. It is right most of the time. But the one time it misreads the currency, or refunds the wrong line item, or hallucinates an amount off a malformed invoice, there is no undo. The money has left. The customer got an email. Someone in finance now has a morning.
The reflex is to make the agent more careful: better prompts, more validation, a stricter model. All of that helps and none of it closes the gap, because the failure you are worried about is not the common case. It is the rare, expensive, irreversible case, and no amount of average-case accuracy makes a one-way door safe to walk through blind. The honest move for that small set of actions is to put a person in the loop: let the agent do all the reasoning and preparation, then stop it just before the irreversible step and ask.
The trick is doing that without turning your agent into a thing that asks permission for everything, and without holding a process open while a human takes forty minutes to answer. This post is the buildable version of a human-in-the-loop approval gate: what to gate, how to pause, and how to resume exactly where you left off.
Gate the one-way doors, nothing else
The failure mode of human-in-the-loop is not too little oversight, it is too much. A system that prompts before every tool call trains its reviewers to hit approve on reflex, and a rubber-stamp is worse than no gate because it manufactures a false record of human judgment. So the first design decision is a short, explicit list of what actually needs a human, and the rule for that list is reversibility.
Ask one question of each action: if the agent gets this wrong, can I quietly undo it before it matters? Reading a database row, yes. Writing a draft, yes. Adjusting an internal ticket, yes. Issuing a refund, sending a customer email, deploying to production, deleting a record, signing anything: no. Those are one-way doors. Everything reversible runs at full speed with no gate. Everything irreversible stops and asks. Keep that list small enough that a human approval means something, because the whole value of the gate is that a person actually reads the handful of things that reach them.
This is also why an approval gate is a different control from the ones around it. It is not tool authorization, which decides whether the agent may touch a tool at all and runs on every call. It is not an output guardrail, which inspects the text on the way to the user. The gate sits on a specific high-risk call and inserts one human decision between the model choosing to act and the action happening. You want all three, doing different jobs.
The shape: propose, pause, resume
The mechanic is simple to state. When the agent decides to call a gated tool, it does not execute it. It emits a proposal: here is the action, here are the exact arguments, here is why. The run then suspends. A human sees the proposal, approves or rejects it, and the run resumes from that point, either executing the tool with the approved arguments or taking the rejection back to the model to reconsider.
The part people get wrong is the pause. The naive version blocks the worker, holds the request open, and waits. That works in a demo and falls apart in production, because a human decision can take minutes or hours, and you cannot afford a pinned thread, an open socket, and a live context window sitting idle for every pending refund. The pause has to be durable: you serialize the pending action and enough state to continue, let the worker go do other things, and rebuild the run when the decision arrives. This is the same checkpoint-and-resume discipline behind durable execution for agents, pointed at a deliberate stop instead of a crash.
Start with the classifier that decides whether a proposed call even needs a gate. It is deliberately boring: a lookup, not a model call, because you do not want the thing that decides whether to ask a human to itself be a probabilistic guess.
from dataclasses import dataclass
# The one-way doors. Everything not listed runs without a gate.
GATED_TOOLS = {"issue_refund", "send_customer_email", "deploy", "delete_record"}
@dataclass
class ToolCall:
name: str
args: dict
def needs_approval(call: ToolCall) -> bool:
# Deterministic. The decision to involve a human is never itself a guess.
return call.name in GATED_TOOLS
When needs_approval is true, instead of running the tool you persist a pending record and stop. The record holds everything you need to do two things later: show a human what they are approving, and resume the exact run that proposed it.
import json, time, uuid
from pathlib import Path
PENDING = Path("/var/agent/approvals") # any durable, shared store
def request_approval(run_id: str, call: ToolCall, rationale: str) -> str:
approval_id = str(uuid.uuid4())
record = {
"approval_id": approval_id,
"run_id": run_id, # which run to resume
"tool": call.name,
"args": call.args, # the EXACT args the human approves
"rationale": rationale, # why the agent wants to do this
"status": "pending",
"created_at": time.time(),
}
(PENDING / f"{approval_id}.json").write_text(json.dumps(record))
return approval_id
Two details in that record matter more than they look. The human approves the exact args, not the intent, so what executes on resume is byte-for-byte what a person saw, with no room for the agent to quietly change the amount between approval and execution. And run_id is the thread back to the suspended run, which only works if the run itself was checkpointed rather than living in a variable on a now-released worker.
Resuming without executing twice
Approval arrives out of band: a reviewer clicks a button, an on-call engineer replies in Slack, a manager signs off in a console. Whatever the channel, it lands as a decision on an approval_id, and this is the point where the whole design either holds or leaks. If two reviewers act at once, or someone double-clicks, or a retry fires the callback twice, a careless implementation runs the refund twice. The gate you built to prevent one bad irreversible action just caused two.
The fix is to make the decision a single atomic state transition, and to make execution conditional on winning it. A decision only takes effect if the action is still pending; the first writer flips it and proceeds, and everyone after finds it already resolved and does nothing.
import os
def resolve(approval_id: str, decision: str, reviewer: str) -> dict | None:
"""Atomically move pending -> approved/rejected. First caller wins."""
path = PENDING / f"{approval_id}.json"
record = json.loads(path.read_text())
if record["status"] != "pending":
# Already decided. A second click or a racing reviewer is a no-op.
return None
record["status"] = decision # "approved" or "rejected"
record["reviewer"] = reviewer
record["decided_at"] = time.time()
# Write-then-rename is atomic on a POSIX filesystem, so a concurrent
# resolve() sees either the old record or the new one, never a torn write.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(record))
os.replace(tmp, path)
return record
In a real deployment the store is Postgres or a queue, not a directory, and the atomic move is a conditional update: UPDATE approvals SET status=$1 WHERE id=$2 AND status='pending', where zero rows updated means someone beat you to it. The filesystem version makes the invariant visible: the state transition is the lock, and the side effect only runs for the caller who performed the transition.
Only once a decision is recorded do you rehydrate the run and continue it. On approval you execute the approved call with the saved args and feed the result back to the model as the tool's observation. On rejection you feed back a message the model can act on, so it revises its plan instead of hammering the same blocked action.
def continue_run(record: dict):
run = load_run(record["run_id"]) # rebuild the checkpointed agent state
if record["status"] == "approved":
call = ToolCall(record["tool"], record["args"])
observation = execute_tool(call) # the actual side effect, now, once
else:
observation = (
f"A human rejected the proposed {record['tool']} call. "
f"Reviewer note: {record.get('note', 'none')}. "
f"Do not retry it; find another resolution or escalate."
)
run.append_observation(record["tool"], observation)
return run.step() # hand control back to the model
Notice that a rejection is not an error and not a dead end. It is information. The model proposed something, a human said no, and that no goes back into context as a fact the agent now reasons around. Done well, the agent learns within the run that this path is closed and reaches for the next best option, which is exactly the behavior you want from a system that is meant to collaborate with people rather than fight them.
The pitfalls that bite in production
A pending approval that no one answers is a silent hang. Humans forget, go home, miss the Slack ping. If a pending action can sit forever, some fraction of your runs quietly die in the waiting state and no one notices until a customer asks where their refund went. Every pending record needs a timeout and an owner. Decide the default when the clock runs out, and it should almost always be reject-and-escalate, never approve-by-default, because timing out into the irreversible action defeats the entire point of the gate.
The reviewer needs the why, not just the what. A prompt that says "Approve issue_refund for $4,200?" with no context gets approved on autopilot, because the human has no way to judge it. Give them the rationale, the customer, the order, the relevant history, enough to actually decide in five seconds. The quality of a human-in-the-loop system is capped by the quality of what you show the human, and a bare tool name with arguments is not enough to make a real decision.
Do not let the agent approve its own gate. It sounds obvious until someone wires a "confidence" self-check where the model rates its own action and skips the human above some threshold. That is not a gate, it is the agent deciding when the rules apply to it, and the one time it is confidently wrong is precisely the time you needed a person. If an action is on the gated list, a human decides, full stop. Confidence scores can route and prioritize; they cannot waive the gate.
Approval fatigue is a real failure mode, not a UX nitpick. If the gate fires too often, reviewers stop reading and the human in the loop becomes a human rubber-stamp, which gives you the latency cost of oversight with none of the safety. This loops back to the first rule: the gated list has to stay short. If approvals are becoming noise, the fix is not a better approval UI, it is gating fewer things. Move the reversible actions off the list and let them run.
Test the gate like the safety control it is. The approval path is exactly the kind of code that rots in a refactor and fails invisibly, because most runs never hit it. Pin the behavior: assert a gated call suspends instead of executing, assert a rejected call never runs the side effect, assert a double-resolve executes once. This is the same discipline as catching regressions with evals in CI, aimed at a safety invariant instead of answer quality.
The takeaway
Autonomy is not all-or-nothing, and treating it that way is what makes teams either ship agents that can do damage or refuse to ship them at all. The middle path is narrow and buildable: let the agent run freely across everything reversible, and put exactly one human decision in front of the small set of actions that cannot be undone. The gate is a deterministic check on a short list, a durable pause that persists the pending action instead of holding a process open, and an idempotent resume that runs the side effect once and only for the reviewer who actually approved it. A few hundred lines, and it turns an agent that will eventually walk through a one-way door blind into one that stops, shows its work, and waits for a yes.
If your agents are getting close to actions that move money, change production, or reach customers, this is worth building before the incident that forces it. Book a consultation call and we can map out which of your actions are one-way doors and put a gate in front of them.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
When should an action require human approval instead of running automatically?+
Gate the actions that are hard or impossible to undo and that carry real cost when wrong: money movement, production changes, outbound messages to customers, deletions, anything legally binding. Everything else, the reads and the reversible writes, should run without a gate. The goal is a small list of gated actions, not a prompt-before-everything system that trains people to click approve without reading. If your gate fires on more than a handful of tool types, you are gating too much and the approvals become noise.
How is an approval gate different from output guardrails or tool authorization?+
They sit at different points. Tool authorization decides whether this agent is allowed to call this tool at all, and it runs on every call. Output guardrails inspect the text the agent is about to send a user. An approval gate sits on a specific high-risk tool call and inserts a human decision between the model deciding to act and the action happening. You often want all three: authorization to bound what is reachable, a gate to require a human on the few irreversible actions, and output filtering on what finally ships.
Does pausing for approval mean holding a process open the whole time?+
No, and you should not build it that way. A human might take minutes or hours to respond, and a blocked worker or open socket for that long is wasteful and fragile. Persist the pending action and the run state to a store, release the worker, and resume from the saved state when the decision arrives. This is the same checkpoint-and-resume discipline durable execution uses for crash recovery, applied to a deliberate pause instead of an accidental one.
What happens if two people approve the same pending action, or someone approves twice?+
Make resumption idempotent. Key the pending action by a stable id and record its state transition atomically: a decision only takes effect if the action is still pending, and the first write wins. A second approval finds the action already resolved and does nothing. Without this, a double-click or two reviewers acting at once can execute the side effect twice, which for a refund or a deploy is exactly the failure the gate was supposed to prevent.
Related Articles
Your Agent Just Leaked One Customer's Data to Another
Input defenses stop bad instructions from getting in. They do nothing about what your agent says on the way out. One generated reply that leaks another customer's data or makes a promise you never authorized is a message you cannot unsend. A fail-closed egress layer that checks every output before it ships is how you keep that message from ever leaving.
One Customer Burned Your Month of LLM Budget by 2pm
LLM spend is per request and wildly variable, so a single runaway agent or one heavy tenant can externalize its cost straight onto your margin. Cheaper models and caching lower the average, but nothing stops the bill. A per-tenant spend ledger with the cap enforced before the call does.
Your Tool Returned 40,000 Tokens. The Agent Needed 12.
A single tool call can dump a whole file, a full API response, or a thousand log lines straight into the context window. The agent needed one field. Now every turn after that re-pays for the whole blob. Offloading the payload to a store and keeping only a handle in context fixes both the cost and the window.