Human-in-the-loop and approval gates
Interrupt a run for approval, resume it safely, design escalation, and match autonomy to the risk of each action.
Autonomy by risk tier
Approval is not a single switch. Classify actions by whether they are reversible and whether they are externally visible, then decide the autonomy level per tier. This is the decision that makes an agent safe enough to be useful.
| Tier | Example actions | Autonomy | Why |
|---|---|---|---|
| 0 - Read | Search, fetch, list, query | Full, no gate | Nothing changes; log and rate-limit only |
| 1 - Reversible write | Create draft, add label, open ticket | Automatic with an undo path | A mistake costs a click to reverse |
| 2 - Hard to reverse | Send an email, publish, apply a config change | Notify after, or approve before, by audience | Visible outside the system |
| 3 - Irreversible or financial | Charge a card, delete data, transfer funds | Always approve, always identify the approver | Cannot be undone and carries liability |
| 4 - Out of policy | Anything outside the declared scope | Refuse, then escalate to a human queue | The safe answer is no |
The failure mode of over-gating is approval fatigue: when everything needs a click, humans approve without reading, and the gate becomes decoration. Gate tier three and four, automate the rest, and measure how often approvals are rejected — a zero rejection rate means the gate is in the wrong place.
Interrupts and resumable state
An approval gate is an interrupt inside the run: stop before the action, persist enough state to continue, wait for a decision that may arrive hours later, then resume exactly where you stopped.
def run(goal, thread_id):
state = {
"run_id": thread_id,
"goal": goal,
"messages": [{"role": "user", "content": goal}],
"steps": [],
"approved": False,
}
while state["steps_run"] < MAX_STEPS:
action = decide(state) # model proposes the next action
if action["risk"] >= 3 and not state["approved"]:
checkpoint(thread_id, {**state, "status": "awaiting_approval",
"pending_action": action})
return {"status": "awaiting_approval", "request_id": new_request(state, action)}
execute(action)
state["steps"].append(action)
checkpoint(thread_id, state) # after EVERY step, not at the end
return finalise(state)
def resume(thread_id, decision, approver):
state = load(thread_id)
if decision == "approved":
state["approved"] = True
state["approved_by"] = approver
elif decision == "amended":
state["pending_action"] = amend(state["pending_action"], decision["changes"])
else:
state["status"] = "rejected"
return finalise(state)
return continue_run(state)- Checkpoint after every step. If state is only saved at the end, an interrupt has nothing to resume from.
- The interrupt lives in your code, not in the prompt. Asking the model to pause is not a gate.
- Give the pending action an idempotency key so a resumed run cannot execute it twice after a retry.
- Set an expiry on every approval request and default to deny. A request nobody answers must not become an approval by silence.
- Record who approved what, with a timestamp. That record is what turns "the agent did it" into an answerable question.
Designing the approval request
{
"request_id": "apr_9f31c",
"run_id": "run_2026_09_18_0041",
"risk": 3,
"action": "refund_order",
"target": { "order_id": "A-1042", "amount": "40.00", "currency": "GBP" },
"reason": "Customer reports non-delivery; tracking shows no scan for 9 days.",
"evidence": [
{ "tool": "lookup_order", "result_id": "res_8812" },
{ "tool": "track_shipment", "result_id": "res_8813" }
],
"reversible": false,
"expires_at": "2026-09-18T18:00:00Z",
"options": ["approve", "amend", "reject"]
}- Show the concrete effect, not the intention: the amount, the recipient, the exact record to be changed. A summary line is not consent.
- Offer amend. Many rejections are really "almost right", and a third option keeps the run alive instead of discarding the work.
- Attach the tool results the decision rests on, so the approver can check the evidence without leaving the screen.
- State clearly when it expires and what happens then. Silent default-allow is the worst possible behaviour.
- Never let the agent write its own approval token, and never accept an approval produced by the model's own output.
FAQ
Should approval happen before or after the action?
How do I stop approvals becoming rubber stamps?
Related
Guardrails, budgets and stopping conditions Agent security and permissions
Last refreshed 2026-09-18.