~/ nmadiraju.com Design Pattern · Human-in-the-Loop
arrow_back Back to nmadiraju
hub Agentic Design Map chevron_right Decision 19 · Human-in-the-Loop

Human-in-the-Loop Approval for Autonomous Agents

The agent loop has a property nobody designed on purpose: it can complete an irreversible action — wire money, delete a record, deploy to prod — with no human anywhere in the causal chain. Human-in-the-loop puts a person back in that chain. The hard part isn't pausing the agent; it's deciding what's worth pausing for, because a gate on everything is as broken as a gate on nothing.

bolt The pattern in five rules
  • how_to_regGate by risk class, not by step. Route every action through a tier — autonomous, log-and-watch, or pause-for-approval — based on irreversibility, blast radius, and compliance. Most actions never need a human.
  • how_to_regPause/resume is the checkpointer doing security work. The same state snapshot that lets an agent survive a crash lets it freeze at an approval and resume — possibly days later, on another machine — exactly where it stopped.
  • how_to_regApproval fatigue is a vulnerability, not an annoyance. Gate everything and reviewers reflexively click "approve" — which turns the human gate into a clickthrough an injection can sail through. Over-gating destroys the very safety you added it for.
  • how_to_regHITL and HOTL are different architectures. In-the-loop blocks before acting; on-the-loop acts and lets a human intervene after. The difference is whether the gate stops the action — that difference is the whole safety story.
  • how_to_regA paused agent is a production liability until it isn't. Frozen threads need timeouts, abandonment handling, and an audit trail. Design the wait, not just the pause.

1The property the agent loop quietly removed

The security article ended on a human gate as the last line of defense; this article builds it properly, because it's more than a security control — it's the thing that makes autonomy safe to deploy at all. The objective of this section is to be precise about what a human gate restores, because naming it tells you exactly where to put one.

Think about what the agent loop, as built across this series, actually does. It reasons, calls a tool, observes, and loops — with no inherent stopping point before a consequential action. A traditional program asks before it does something dangerous because a developer wrote a confirmation step. An agent has no such instinct: handed a send_payment tool, it will call it the instant its reasoning says to, and the money is gone. menu_book The loop removed a property every careful system has — that an irreversible action cannot complete without a human being part of the causal chain — and did it so quietly that most teams only notice when the agent deletes the wrong record.

The agent loop's superpower is acting without asking. That's also the exact thing you sometimes need it to stop doing.

Same loop, same action — the gate inserts the one missing linkWITHOUT A GATE · no human in the chainreasondecidesend_payment()fires instantlymoney gone · no undoWITH A GATE · a human sits in the causal chainreasondecidepauseinterrupt()HUMANapprovesruns — vettedthe gate inserts the one link the loop left out: a person, before the point of no return
Figure 1 — the property the loop removed. Without a gate (top), reasoning flows straight into an irreversible action with no human anywhere in the chain. The gate (bottom) inserts exactly one missing link — a person in the causal chain, before the point of no return.

Human-in-the-loop is how you put that property back. At its core it's simple: before a consequential action fires, the agent pauses, hands a human the proposed action and its context, and resumes only on approval. The agent does the mechanical labor — analysis, drafting, formatting the proposal; the human does the judgment — approve, edit, or reject. But "pause before consequential actions" hides the two questions that make this a design pattern rather than a checkbox: which actions, and how do you pause a stateless model mid-thought and pick up later? The rest of this article is those two questions.

lightbulb

This is the same move the whole series keeps making. Reasoning is fluid and probabilistic; control and consequences must be deterministic. Memory made state deterministic; evaluation made quality measurable; security made the blast radius bounded. HITL makes irreversibility gated — it forces a deterministic human checkpoint into a non-deterministic actor's path, exactly where an undo button doesn't exist.

2Gate by risk class, not by step

If §1 said the human restores a missing property, the immediate temptation is to restore it everywhere — pause before every action, to be safe. This section is about why that instinct is wrong, and what to do instead, because the placement of the gate is the entire design decision and the two obvious extremes are both failures.

Picture the spectrum. At one end, gate nothing: the agent runs free, fast and frictionless — until its first irreversible mistake, when the missing gate on a bad deploy or a wrong payment turns out to be very expensive. At the other end, gate everything: every read-only lookup demands a confirmation. It feels safe and it is the more common mistake, but it quietly fails, for a reason §4 makes its own point. Neither extreme is the pattern. The pattern is in the middle: route each action by its risk class.

Risk isn't one number — it's a few dimensions you assess per action: irreversibility (can this be undone?), blast radius (how many people/systems does it touch?), compliance (does a regulation demand oversight?), and confidence (is the agent sure?). Score an action on those and it falls into a tier, and the tier decides the oversight:

TierLooks likeOversight
1 · Read-onlyLook up an order, search docs, summarizeAutonomous. No gate. Just log it.
2 · Reversible writeDraft a reply, tag a ticket, update a non-critical fieldAutonomous + watch. Acts now; a human can review after.
3 · ConsequentialRefund within a limit, send external email, change configConditional gate. Pause if it crosses a threshold.
4 · Irreversible / high-stakesWire money, delete data, deploy to prod, legal actionAlways pause. Explicit human approval, every time.

The win is proportionality: reviewer attention is a scarce resource, and you spend it only where judgment changes the outcome. Tiers 1 and 2 run at full speed and keep the agent useful; Tiers 3 and 4 buy back the safety where a mistake is costly or permanent. This is also exactly how the regulatory frameworks think — neither the EU AI Act nor NIST's risk framework demands a human review every decision; they require meaningful oversight, calibrated to risk. Universal approval isn't compliance, it's bad engineering. [VERIFY: EU AI Act Article 14 / NIST AI RMF framing before publishing; confirm current text.]

The router reads the risk tier and picks the pathAgent proposesan actionRisk routerclassify tierTier 1–2 · autonomousexecute now · logTier 3 · conditionalover threshold? → pauseTier 4 · always pauseexplicit human approval, every timemost traffic takes the top path; the human only ever sees the bottom two
Figure 2 — risk-class routing. Every proposed action passes through a router that reads its tier. The bulk of traffic (Tiers 1–2) executes autonomously; only Tier 3 (conditionally) and Tier 4 (always) reach a human, so reviewer attention is spent only where it changes the outcome.

3The mechanism: pause/resume on the checkpointer

Section 2 decided which actions to gate. This section answers the how — and the satisfying part is that you already built the machinery, in the memory article. Pausing a stateless model mid-task and resuming it later sounds hard until you realize it's the exact capability the checkpointer already provides. The objective here is to connect the gate to that mechanism so the pattern is concrete, not magical.

Recall from the memory article: the checkpointer snapshots the entire graph state after every step. That snapshot is everything the agent needs to continue — which node was running, every value in state, where execution stopped. Human-in-the-loop is what that snapshot was secretly for. When the agent hits a gate, it calls interrupt(): execution halts at exactly that line, the proposed action is written into the persistence layer, and the thread is marked interrupted. Because the full state is checkpointed, the agent isn't "waiting" in memory — it's frozen on disk, consuming nothing. A human can respond in thirty seconds or thirty days, on a different machine entirely, and Command(resume=...) restores the snapshot and continues from the interrupt as if no time passed.

warning

No checkpointer, no pause/resume — full stop. The entire pattern rests on durable state. Without a checkpointer the graph has no memory between the pause and the resume, so interrupt() has nothing to restore from. This is the memory article's short-term layer being load-bearing for security, not just for conversation. Use a durable backend (Postgres) in production, not in-memory. [VERIFY: pin LangGraph interrupt/Command APIs and checkpointer setup against current docs before publishing.]

Here is the whole mechanism — the gate inside a node, and the two-step run/resume from the caller:

Pause = snapshot to disk. Resume = rehydrate. The wait costs nothing.Agent runninghits the gateinterrupt()halts hereCheckpointerfull state → disksnapshotThread dormant0 CPU · 0 RAM⏳ seconds … or dayshuman reviews on their own timeCommand(resume)rehydrate & continuerestoreResumessame spot, any hostthe whole state lives on disk, so resume can happen on a different machineNo checkpointer → nothing to snapshot → no pause/resume.
Figure 3 — pause/resume as durable state. interrupt() halts the agent and the checkpointer writes the full state to disk; the thread goes dormant, consuming nothing while a human takes seconds or days. Command(resume) rehydrates that snapshot — on any machine — and execution continues from the exact line it stopped.
content_copy Copy
from langgraph.types import interrupt, Command

def approval_gate(state):
    action = state["proposed_action"]
    # interrupt() PAUSES the graph here. The payload is handed to the human;
    # the graph state is checkpointed, and the thread is marked interrupted.
    decision = interrupt({
        "type": "approval_request",
        "action": action,
        "message": f"Approve {action['kind']} of {action['amount']}?",
    })
    # Execution RESUMES here, with whatever the human sent back.
    if decision["approve"]:
        return {"status": "approved"}
    return {"status": "rejected", "reason": decision.get("reason")}
# A checkpointer is REQUIRED — it's what makes pause/resume possible.
graph = builder.compile(checkpointer=checkpointer)
cfg = {"configurable": {"thread_id": "order-9847"}}

# 1) Run until the gate. The agent stops at interrupt() and returns control.
result = graph.invoke({"messages": [...] }, config=cfg)
if result.get("__interrupt__"):
    show_to_human(result["__interrupt__"])   # render the approval card

# ...minutes or days later, on any machine — the thread is frozen on disk...

# 2) Resume with the human's decision. State is restored from the checkpoint.
graph.invoke(
    Command(resume={"approve": True}),
    config=cfg,                          # SAME thread_id — picks up the right checkpoint
)
# Don't gate every tool — gate by tier. A `when` predicate (plain code)
# decides per-call whether to pause, so reviewers only see what matters.
def needs_approval(call) -> bool:
    # Tier 4: irreversible always pauses. Tier 3: only over a threshold.
    if call.name in {"send_payment", "delete_records", "deploy_prod"}:
        return True
    if call.name == "issue_refund" and call.args["amount"] > AUTO_LIMIT:
        return True
    return False                       # Tier 1–2: never interrupts, runs free

# Wire the predicate so only the calls that need a decision ever pause.
# Tier 1-2 calls are never even added to the reviewer's queue.

The three placements of the gate

The gate from §2 isn't always a hard pre-action block. Where you put it on the timeline is itself a choice, and it maps onto the tiers: pre-execution approval blocks before a Tier 4 action and waits (maximum safety, maximum latency); post-execution review lets a Tier 2 action run and surfaces it for inspection after (speed, with a human able to catch and correct); and an escalation trigger runs autonomously until a risk signal fires — low confidence, a policy boundary, an out-of-distribution input — then pauses. The airline rebooking agent is the classic escalation case: it rebooks most passengers automatically and only halts for the first-class international itinerary with the loyalty override, packaging that context for a senior agent.

Approve, reject, or edit — the human has three moves

One detail the simple "approve/reject" framing hides: a reviewer staring at a proposed action often shouldn't be limited to yes or no. The realistic review surface has three decisions, and the resume mechanism carries all three — the human's response is just structured data handed back through Command(resume=...), and the node branches on it:

  1. Approve — the proposed action is correct; run it as-is. The clean path.
  2. Reject (and steer) — don't run it, and optionally attach a correction the agent loops back on: "don't query the internal user table; pull contacts from the verified export instead." The rejection becomes new guidance, not just a stop.
  3. Edit — the action is right in shape but wrong in a parameter. The human fixes the argument — amount: 50000 → 5000 — and the corrected call runs. This is the move that turns a near-miss into a save instead of a round-trip back to the model.
content_copy Copy
def approval_gate(state):
    proposed = state["proposed_action"]
    decision = interrupt({"type": "approval_request", "action": proposed})

    # The human's reply is structured data — branch on its type.
    if decision["type"] == "approve":
        return {"action": proposed, "status": "approved"}

    if decision["type"] == "edit":
        # human corrected an argument — run the FIXED call, validate first
        fixed = {**proposed, "args": validate(decision["args"])}
        return {"action": fixed, "status": "approved_edited"}

    # reject — optionally carry the human's steer back to the planner
    return {"status": "rejected", "feedback": decision.get("reason")}
# The caller resumes the SAME thread with whichever decision the human made.
cfg = {"configurable": {"thread_id": "order-9847"}}

# approve as proposed
graph.invoke(Command(resume={"type": "approve"}), config=cfg)

# edit a parameter, then run the corrected call
graph.invoke(Command(resume={"type": "edit",
                            "args": {"amount": 5000}}), config=cfg)

# reject with a correction the agent loops back on
graph.invoke(Command(resume={"type": "reject",
                            "reason": "use the verified export, not the user table"}), config=cfg)
# NOTE: always validate edited args — a human typo or a bad value is still input.
warning

An edit is human input, so validate it too. The reviewer's corrected argument flows straight into the action — treat it with the same schema validation you'd give any input, or you've swapped a model mistake for a fat-finger one. The gate's job is a correct action, not just a human-blessed one.

4The failure on the safe-looking side: approval fatigue

Section 2 warned that "gate everything" fails quietly. This section is that warning paid off, because the failure is more dangerous than it looks — it doesn't just slow the agent down, it silently disarms the very protection you added. This is the trap, and naming it is what keeps a well-meaning team from building straight into it.

When approval requests come too often, people stop reading them. A reviewer faced with a hundred confirmations a day develops a reflex — approve, approve, approve — and that reflex is the vulnerability. Tie this back to the previous article: a prompt injection that triggers an approval the human clicks through without reading has bypassed the human gate entirely. Confirmation fatigue isn't bad UX; it's a clickthrough attack surface. The over-gated system and the injected agent meet in the same place — a human rubber-stamping a malicious action because they've been trained to.

Gate everything and you don't get more safety — you train your reviewers to ignore the one approval that mattered.

Gate everything and the approval signal goes numbGATE EVERYTHING · the signal goes numbapproveapproveapproveapprove×200/day (all low-risk)reviewer reflexMALICIOUS requestinjection-triggeredclicked through ✗the gate is now a rubber stamp — injection sails through itGATE BY TIER · the signal stays meaningfulTier 1–2 auto · loggedno human asked → no fatigueTier 4 — rarewire $40,000reviewer LOOKSit stands outcaught ✓denied
Figure 4 — why over-gating is a security hole. Gate everything (top) and 200 reflexive approvals a day train the reviewer to rubber-stamp — so an injection-triggered malicious request gets the same reflexive yes. Gate by tier (bottom) and the rare Tier 4 request stands out against a quiet queue, so the human actually looks.

This is the strongest practical argument for risk-tiering, and it closes the loop with §2: tiers aren't only about reviewer throughput, they're about keeping the approval signal meaningful. If the Tier 4 "wire $40,000" request looks identical to the Tier 1 "look up an order" request the reviewer has clicked past two hundred times today, the gate that matters gets the same reflexive yes. Reserving synchronous interruption for genuinely consequential actions is what keeps a human actually looking when it counts.

report

Over-gating and under-gating fail in opposite directions — and both are real. Gate nothing: fast until the first irreversible mistake, then very expensive. Gate everything: feels safe, trains rubber-stamping, and hands an injection a clickthrough. The discipline is to gate proportionally — which is harder than either extreme and is the actual engineering work of this pattern.

5HITL vs. HOTL: does the gate block?

The fatigue problem in §4 pushes you toward gating less — which raises a real architectural question this section settles: when you stop blocking and start merely watching, you've changed patterns entirely, and conflating the two is how teams ship something weaker than they think they did.

The whole distinction is one bit: does the gate stop the action before it happens?

pan_tool Human-in-the-loop (HITL)
The gate blocks. The action cannot complete until a human approves. Maximum safety, real latency. For the irreversible and high-stakes — Tier 4. The human is in the causal chain.
visibility Human-on-the-loop (HOTL)
The gate watches. The action runs; a human monitors and can intervene or roll back after. Speed with oversight. For medium-risk, reversible work where mistakes are recoverable — Tier 2. The human is beside the chain.

Both are valid; they sit at different points on the throughput-vs-safety curve, and a mature system uses both — HOTL for the reversible majority, HITL for the irreversible few. The danger is conflating them. A team that built HOTL but reports it as HITL is not lying — it's confusing two architectures whose only difference is whether the gate blocks. And that difference is the entire safety story: for a Tier 4 action, "a human could have intervened" is not the same as "a human had to approve." Only the blocking gate guarantees a person was in the causal chain before the irreversible thing happened.

The one bit that separates them: does the gate block?HITL · human BEFORE the action (gate blocks)agentHUMAN gatemust approveaction runsperson is IN the chainirreversible is safeHOTL · human AFTER the action (gate watches)agentaction runshuman watchescan roll backaction ALREADY happenedonly ok if reversible
Figure 5 — the one bit that separates them. In HITL the human sits before the action, so the gate blocks and the person is in the causal chain — safe for the irreversible. In HOTL the human sits after, watching and able to roll back — which only helps when the action can actually be undone.
lightbulb

Pick the pattern from the tier, not from your latency budget. It's tempting to downgrade a Tier 4 gate to HOTL because blocking is slow and the SLA hurts. That's choosing throughput over the one guarantee the gate exists to provide. If an action is truly irreversible, "watch and intervene" can't help you — by the time you'd intervene, it's done. Slow is the price of irreversible.

6Designing the wait, not just the pause

Sections 2–5 got the gate right: gate the right tier, with the right mechanism, with the right blocking semantics. But a paused agent introduces a problem a fully-autonomous one never had — time — and this section is about the production realities of that frozen state, because a pattern that's correct in a demo can still fall over in production on exactly this.

The moment you add a blocking gate, you add unbounded latency. A fully-autonomous run finishes in seconds; a gated one can sit frozen for hours or days waiting on a human. That's fine — it's the point — but it has consequences you have to design for:

  1. Abandoned threads. A human is asked to approve and never responds. The checkpointer holds that frozen state indefinitely. You need a TTL job that scans for threads not resumed within a threshold (say, 24 hours) and marks them abandoned — with a safe default, which for a Tier 4 action is reject, never auto-approve.
  2. SLAs and escalation. If your system has response-time commitments, a thread waiting on one reviewer can blow them. Route to a queue with backups, escalate if the first reviewer is slow, and surface aging approvals so nothing rots silently.
  3. The audit trail. Every approval and rejection is a compliance and debugging artifact. Persist who decided, what they saw, when, and why — this is what an auditor asks for and what you'll want at 2 a.m. during an incident. It's also calibration data: reviewer decisions are labeled examples for tuning your tiers.
schedule

The safe default on timeout is the whole ballgame. An abandoned Tier 4 thread that auto-approves on expiry has quietly converted your strongest gate into a delay. Abandonment must fail closed — reject, alert, and require a fresh request. The only thing worse than a slow gate is a gate that opens itself when no one's looking.

A gated action's lifecycle — including the paths people forgetproposepauseinterrupt()frozen on diskcosts nothingapprove → resume & runreject → resume & skiptimeout → abandonfail CLOSED: reject + alertEvery exit → audit trailwho decided · what they saw · when · why → tier-calibration data
Figure 6 — the lifecycle of a gated action. Propose → pause → frozen on disk → one of three exits. The path teams forget is the third: a timeout must fail closed (reject + alert), never auto-approve. Every exit feeds the audit trail, which doubles as calibration data for your tiers.

7The human-in-the-loop checklist

The pattern is complete; this section turns it into a pre-ship gate of its own. Each row distills one decision from the sections above into a single rule.

DimensionThe rule
What to gateRoute by risk tier — irreversibility, blast radius, compliance, confidence
Tier 1–2Autonomous (with logging); a human reviews after, if at all
Tier 3Conditional gate — pause only when a threshold is crossed
Tier 4Always block for explicit approval — irreversible, every time
Mechanisminterrupt() to pause, Command(resume=…) to continue
PrerequisiteA durable checkpointer — no checkpointer, no pause/resume
PlacementPre-execution (block), post-execution (review), or escalation (signal-triggered)
Human movesApprove, reject-and-steer, or edit — and validate any edited argument
FatigueGate proportionally — over-gating trains rubber-stamping, a clickthrough hole
HITL vs HOTLBlock for irreversible; watch-and-intervene only for reversible
TimeoutTTL on frozen threads; fail closed (reject + alert), never auto-approve
AuditLog who/what/when/why for every decision — compliance + tier calibration
LatencyDesign for unbounded waits: queues, backups, escalation, aging alerts

Conclusion: calibrated autonomy, not a safety net

The agent loop gave you something that acts without asking. That's the capability; it's also the danger, because acting without asking is fine until the action can't be undone. Human-in-the-loop restores the one property the loop removed — that no irreversible thing completes without a person in the causal chain — and the craft is in restoring it surgically, not everywhere.

So gate by risk class, because reviewer attention is scarce and over-gating trains the rubber-stamp that an injection sails through. Build the pause on the checkpointer you already have, because pause/resume is just durable state doing security work. Block for the irreversible and merely watch the reversible, because that one bit — does the gate stop the action — is the whole safety story. And design the wait, because a frozen thread that auto-approves on timeout is no gate at all.

Done well, this isn't a crutch for an agent you don't trust. It's calibrated autonomy: full speed where mistakes are cheap and reversible, a human exactly where judgment is the difference between a good outcome and a costly one. The model brings the capability; security bounds the damage; human-in-the-loop decides which decisions a machine is allowed to make alone.

Next in series · Decision 20
Multi-Agent Orchestration
arrow_forward