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.

TierExample actionsAutonomyWhy
0 - ReadSearch, fetch, list, queryFull, no gateNothing changes; log and rate-limit only
1 - Reversible writeCreate draft, add label, open ticketAutomatic with an undo pathA mistake costs a click to reverse
2 - Hard to reverseSend an email, publish, apply a config changeNotify after, or approve before, by audienceVisible outside the system
3 - Irreversible or financialCharge a card, delete data, transfer fundsAlways approve, always identify the approverCannot be undone and carries liability
4 - Out of policyAnything outside the declared scopeRefuse, then escalate to a human queueThe 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.
💡
The size of the human interface is the size of the trust boundary. If the approval screen does not make the consequence obvious in five seconds, the approver is rubber-stamping, and you have a compliance control rather than a safety control.

FAQ

Should approval happen before or after the action?
Before, for anything irreversible or externally visible. For reversible actions, act first and notify: a person reviewing a draft is faster than a person approving one, and undo is cheaper than a queue.
How do I stop approvals becoming rubber stamps?
Track the rejection and amendment rates per action type. If nothing is ever rejected, the gate is mis-placed — either automate it, or move it to the point where the agent's judgement is genuinely weak.

Guardrails, budgets and stopping conditions Agent security and permissions

Last refreshed 2026-09-18.