Human-in-the-Loop and Approvals
Interview answer (say this first). Human-in-the-loop means the agent pauses at a defined point, persists its state, and waits for a person to approve, reject, or edit a proposed action before continuing. Approval is mandatory when an action is irreversible, expensive, or sensitive. The decision, the approver’s identity, the exact arguments, and the timestamp are recorded in state and an audit log, and the run resumes from its checkpoint.
Why this exists
An agent is given write tools: send email, issue a refund, delete a record, update a CRM. One day the model proposes an action that is wrong but perfectly well-formed.
tool = {"name": "refund", "args": {"order_id": "A-1002", "amount": 4999.00}}
Nothing in that call is malformed. The JSON is valid. The tool exists. The only problem is that the model chose the wrong order. If the agent executes it, a real customer receives $4,999, and getting it back requires a human to reverse it.
The same shape appears everywhere:
- The agent summarises its plan as “delete the inactive accounts” and a bug in the query matches 40,000 active accounts.
- The agent drafts a reply that leaks an internal price list to a customer.
- The agent decides to spend $200 of API budget on a research task nobody asked for.
- The agent emails every user in the database instead of the test account.
None of these need a smarter model. They need a pause before the irreversible step, where a person can see exactly what is about to happen and say yes, no, or “do this instead.”
Human-in-the-loop exists because model judgment is probabilistic and some actions cannot be undone. The human supplies the final, accountable decision.
Note:
The one-sentence purpose. Stop before consequential actions, let a person approve or change them, record the decision, and resume the run from where it paused.
Start from zero
| Word | Plain meaning |
|---|---|
| Human-in-the-loop (HITL) | A person participates in the run at defined points, usually approving a proposed action. |
| Approval | A person authorises one specific proposed action before it runs. |
| Approver | The identity allowed to make that decision. |
| Interrupt | A planned pause where the run stops and returns control to the caller, with state saved. |
| Resume | Continuing the run from its checkpoint after a decision arrives. |
| Approve | Allow the action to proceed, usually unchanged. |
| Reject | Block the action and return control to the agent, ideally with a reason. |
| Edit | Approve with modified arguments: same intent, different payload. |
| Escalation | Sending a pending decision to a higher authority, for example after a timeout or a high-value flag. |
| Timeout | A deadline after which no human answer will arrive. |
| Default | The decision used on timeout, normally the safe one (reject or deny). |
| Irreversible action | Something that cannot be cleanly undone: sent email, payment, deletion, external API call. |
| Blast radius | How much a wrong action can affect: one record, one customer, or the whole database. |
| Audit trail | A durable record of who decided what, when, with which arguments and result. |
| Four-eyes principle | Two people must agree before a critical action; one to propose, another to approve. |
| Separation of duties | The person who benefits from an action is not the person who approves it. |
| Approval fatigue | Rubber-stamping caused by too many low-risk prompts, which weakens every real check. |
Two distinctions carry the topic.
Approval is not permission. A permission says a tool may ever be used by this agent or role. An approval says this particular call, with these arguments, right now is allowed. You need both, and they answer different questions.
An interrupt is not an error. An error means something failed. An interrupt is a designed pause: the run is healthy, it simply needs a decision before it can continue. Treating it as a failure loses state and breaks resume.
The core idea
Think of a bank vault with a two-person rule. One employee can prepare the withdrawal, but the vault only opens when a second authorised person turns their key. The first key alone does nothing; the pause is deliberate and built into the door.
An approval gate is that second key, applied to one action at a time.
flowchart TD
A["Agent proposes action"] --> B{"Policy: risk level?"}
B -->|"read-only / cheap"| C["Execute automatically"]
B -->|"irreversible / expensive / sensitive"| D["Persist state<br/>raise interrupt"]
D --> E["Show a human:<br/>action, arguments, reason, diff"]
E --> F{"Decision"}
F -->|"approve"| G["Record decision<br/>+ approver + hash"]
F -->|"edit"| H["Record edited args<br/>+ approver"]
F -->|"reject"| I["Record rejection<br/>+ reason"]
G --> J["Resume from checkpoint"]
H --> J
I --> K["Agent revises or aborts"]
D -.->|"no answer by deadline"| L["Timeout: apply safe default<br/>or escalate"]
L --> F
The cheapest, safest default is: auto-approve reversible, cheap, read-only actions; gate everything irreversible. Each gate should show the approver the exact payload, because approving a summary rather than the payload is how mistakes get through.
| Action class | Examples | Default treatment |
|---|---|---|
| Read-only | search, fetch, calculate | Auto-approve |
| Reversible write | draft, tag, update a staging field | Auto-approve with logging |
| External communication | send email, post message, call a webhook | Approve |
| Money | charge, refund, transfer, purchase | Approve, often two people above a threshold |
| Destructive | delete, drop, revoke access, cancel | Approve, sometimes with a typed confirmation |
| Sensitive data | export PII, change permissions | Approve plus a data-handling check |
How it works
- Classify every tool and action by risk. Record for each: reversible? costs money? leaves the machine? touches sensitive data? The classification drives the gate.
- Before executing a gated action, build an approval request. Include the exact arguments, the reason the agent wants it, and the expected effect. A request the approver cannot understand is not an approval request.
- Persist state and raise an interrupt. The run stores where it is and returns control. It does not spin, sleep, or hold a thread for hours.
- Surface the request to a human with enough context. Show the proposed action, its arguments, a diff or preview, the agent’s reasoning, and the risk class. Keep sensitive data out of notification channels when possible.
- The human decides: approve, reject, or edit. An edit is an approval of a corrected payload. A rejection should carry a reason so the agent can revise rather than retry blindly.
- Record the decision durably. Store the approver identity, timestamp, decision, the exact approved arguments, and a hash of the payload. This is the audit record and the proof of what was authorised.
- Apply any edit to the arguments. The executed call must use the approved payload, not the model’s original one, or the audit record lies.
- Resume from the checkpoint. The run continues at the next step. Completed steps are skipped; the approved action executes once, guarded by an idempotency key.
- Handle silence with a timeout and a safe default. If no decision arrives by the deadline, apply the default — normally reject — or escalate. Never let a default be “execute the irreversible action.”
- Escalate when policy requires. Route high-value or timed-out requests to a second approver or a manager. Four-eyes means the proposer cannot be the sole approver.
- Close the loop with the agent. On rejection, feed the reason back so the next proposal differs. Repeating the same blocked call is a loop, not a plan.
Warning:
Approve the payload, not the summary. If the approval UI shows “the agent wants to send an email” but executes the model’s raw arguments, an injection or a bug can send something completely different. Always render and hash the exact arguments that will be executed.
The syntax you will use
An interrupt signal that carries the proposed action. Do not name the attribute args: Exception.args coerces a dict into a tuple of its keys.
class Interrupt(Exception):
def __init__(self, action: str, arguments: dict) -> None:
super().__init__(action)
self.action = action
self.arguments = arguments # NOT self.args
Durable state holding decisions. The dicts and lists are serialisable and survive the pause. Use lists, not sets, for the decision fields: JSON has no set type, and json.dumps raises TypeError on a set (chapter 09).
from dataclasses import dataclass, field
@dataclass
class RunState:
run_id: str
pending: dict[str, dict] = field(default_factory=dict) # action -> proposed arguments
decisions: list[dict] = field(default_factory=list) # append-only audit records
log: list[dict] = field(default_factory=list)
A risk policy: which actions need approval. Unknown actions default to requiring approval — deny by default.
from enum import Enum
class Decision(Enum):
AUTO = "auto"
APPROVE = "approve"
POLICY: dict[str, Decision] = {
"search_docs": Decision.AUTO, # read-only, cheap, reversible
"send_email": Decision.APPROVE, # leaves the machine
"delete_records": Decision.APPROVE, # irreversible
}
def needs_approval(action: str) -> bool:
return POLICY.get(action, Decision.APPROVE) is Decision.APPROVE
Execute-or-interrupt around the gated action. This is the gate in one function. It persists the proposed payload at interrupt time, and on resume it executes the stored approved payload, never the caller’s fresh arguments.
import hashlib, json
def args_hash(arguments: dict) -> str:
canonical = json.dumps(arguments, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode()).hexdigest()
def latest_decision(state: RunState, action: str) -> dict | None:
for record in reversed(state.decisions):
if record["action"] == action:
return record
return None
def execute_action(state: RunState, action: str, arguments: dict) -> str:
if needs_approval(action):
record = latest_decision(state, action)
if record is None or record["decision"] != "approve":
state.pending[action] = arguments # persist the exact proposed payload
state.log.append({"action": action, "event": "interrupt",
"arguments": arguments, "sha256": args_hash(arguments)})
raise Interrupt(action, arguments)
arguments = record["approved_arguments"] # execute the stored payload, not the caller's
state.log.append({"action": action, "event": "executed", "arguments": arguments})
return f"did {action} with {arguments}"
Record an approval, or a rejection. Writing the decision into state — approver, timestamp, decision, approved payload, and its hash — is what lets resume work and what proves what was authorised.
from datetime import datetime, timezone
def approve(state: RunState, action: str, approver: str,
approved_arguments: dict | None = None,
timestamp: str | None = None) -> None:
payload = approved_arguments if approved_arguments is not None else state.pending.get(action)
if payload is None:
raise ValueError(f"no pending action: {action}")
state.decisions.append({
"action": action,
"decision": "approve",
"approver": approver,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
"approved_arguments": dict(payload),
"sha256": args_hash(payload),
})
state.log.append({"action": action, "event": "approved", "approver": approver})
def reject(state: RunState, action: str, approver: str, reason: str,
timestamp: str | None = None) -> None:
state.decisions.append({
"action": action,
"decision": "reject",
"approver": approver,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
"reason": reason,
})
state.log.append({"action": action, "event": "rejected",
"approver": approver, "reason": reason})
A timeout with a safe default. Real systems park the run durably and wake on a decision or a timer; the rule is the same.
def wait_for_decision(prompt: str, timeout_s: int, default: str) -> str:
return default # 'reject' unless policy says otherwise
An audit record as JSON. Emit the full decision records alongside the event log; the log alone does not carry the approver, timestamp, or payload hash.
import json
def audit(state: RunState) -> str:
return json.dumps({"run_id": state.run_id,
"decisions": state.decisions,
"log": state.log}, sort_keys=True)
LangGraph: interrupt inside a node, resume with Command. The graph checkpoints at the interrupt, so the pause can outlive the process (verified with LangGraph 1.x).
from langgraph.types import interrupt, Command
def gate(state):
answer = interrupt({"question": "Approve this refund?",
"amount": 4999.00, "order": "A-1002"})
return {"log": state["log"] + [f"refund approved={answer}"]}
# First invocation pauses and returns; state is checkpointed.
graph.invoke({"log": []}, {"configurable": {"thread_id": "refund-1"}})
# Later, a human decision resumes the same thread:
graph.invoke(Command(resume=True), {"configurable": {"thread_id": "refund-1"}})
An idempotency key around the approved action. Resuming must not execute the action twice.
# Pseudocode: `ledger` is the run's idempotency ledger; `payments` is the payment client.
approved = latest_decision(state, "refund")["approved_arguments"]
ledger.run_once(f"refund:{state.run_id}", lambda: payments.refund(**approved))
Examples: simple to real
Example 1 — classify, then gate. Read-only runs; anything else waits.
print("search_docs needs approval:", needs_approval("search_docs"))
print("send_email needs approval:", needs_approval("send_email"))
print("wire_transfer (unknown) needs approval:", needs_approval("wire_transfer"))
Illustrative output:
search_docs needs approval: False
send_email needs approval: True
wire_transfer (unknown) needs approval: True
An unlisted action is treated as dangerous. Deny-by-default means a new tool cannot accidentally ship unguarded.
Example 2 — the run pauses on a gated action. State is recorded, then the interrupt stops the loop. The proposed payload is persisted so the approver sees exactly what would run.
state = RunState(run_id="run-1")
try:
execute_action(state, "search_docs", {"q": "report"})
execute_action(state, "send_email", {"to": "team@example.com", "body": "hi"})
except Interrupt as exc:
print("paused:", exc.action, exc.arguments)
print("pending:", state.pending)
Illustrative output:
paused: send_email {'to': 'team@example.com', 'body': 'hi'}
pending: {'send_email': {'to': 'team@example.com', 'body': 'hi'}}
The search ran and is recorded. The email is not sent, and its arguments are now durable. That state is what gets persisted and what a later resume reads.
Example 3 — approve with a correction, then resume. The human fixes the body; the executed call uses the stored approved payload even though the resume supplies different arguments.
approve(state, "send_email", approver="dana",
approved_arguments={"to": "team@example.com", "body": "hi (redacted)"},
timestamp="2026-01-01T00:00:00+00:00")
result = execute_action(state, "send_email",
{"to": "attacker@evil.com", "body": "wire the money"})
print(result)
print("record:", state.decisions[-1])
Illustrative output:
did send_email with {'to': 'team@example.com', 'body': 'hi (redacted)'}
record: {'action': 'send_email', 'decision': 'approve', 'approver': 'dana', 'timestamp': '2026-01-01T00:00:00+00:00', 'approved_arguments': {'to': 'team@example.com', 'body': 'hi (redacted)'}, 'sha256': '770f0124c396a4087b160a0aff4a8b443e344ad496d3e94719e29e76364b9d89'}
The model’s original body was "hi", and the resume supplies a different payload. Execution uses the approved arguments — so does the record — because the decision, not the caller, is the source of truth. The hash lets a verifier detect any later change.
Example 4 — reject with a reason, so the agent can revise.
s2 = RunState(run_id="run-2")
try:
execute_action(s2, "delete_records", {"ids": [1, 2, 3]})
except Interrupt:
pass
reject(s2, "delete_records", approver="carol", reason="keep audit data",
timestamp="2026-01-01T00:00:00+00:00")
print("record:", s2.decisions[-1])
Illustrative output:
record: {'action': 'delete_records', 'decision': 'reject', 'approver': 'carol', 'timestamp': '2026-01-01T00:00:00+00:00', 'reason': 'keep audit data'}
The rejection carries an approver, a timestamp, and a reason, so the agent can propose a narrower deletion instead of retrying the same call forever.
Example 5 — timeout applies the safe default. Silence must not mean “yes.”
print("timeout decision:", wait_for_decision("Approve email?", 300, "reject"))
Illustrative output:
timeout decision: reject
For low-risk, reversible actions the default might be approve. For money, deletion, or external communication, the default is deny. Make this a policy decision, not an accident.
Example 6 — the audit record ties it together.
print(audit(state))
Illustrative output (wrapped for readability):
{"decisions": [{"action": "send_email",
"approved_arguments": {"body": "hi (redacted)", "to": "team@example.com"},
"approver": "dana", "decision": "approve",
"sha256": "770f0124c396a4087b160a0aff4a8b443e344ad496d3e94719e29e76364b9d89",
"timestamp": "2026-01-01T00:00:00+00:00"}],
"log": [{"action": "search_docs", "arguments": {"q": "report"}, "event": "executed"},
{"action": "send_email", "arguments": {"body": "hi", "to": "team@example.com"},
"event": "interrupt",
"sha256": "8a0449e54689ba8723db4ab006cd6d53abad9e4680d5de42d8595134e3a5e01d"},
{"action": "send_email", "approver": "dana", "event": "approved"},
{"action": "send_email", "arguments": {"body": "hi (redacted)", "to": "team@example.com"},
"event": "executed"}],
"run_id": "run-1"}
Every transition is present: what ran, what paused, who approved, the exact approved payload with its hash, and what actually executed. In an incident review, this is the difference between a story and a fact.
In production
- Gate irreversible, expensive, and sensitive actions; auto-approve the rest. Gating everything causes approval fatigue, and tired approvers click yes. The gate earns its keep only if it is rare and meaningful.
- Show the exact payload, not a paraphrase. Render the arguments that will be executed, with a diff where possible. Approving a summary while executing raw arguments is a known bypass.
- Hash and store the approved payload. Record a hash of the arguments alongside the decision. If the payload changes after approval, the hash mismatch blocks execution.
- Default to deny on timeout. Silence is not consent. Choose short timeouts for high-risk actions and route to a human owner before executing.
- Make the decision durable before resuming. Write the approval to state and audit storage first. If the resume runs before the record lands, a crash can lose the proof of authorisation.
- Execute the approved action exactly once. Guard it with an idempotency key. Resuming after a crash must not refund twice.
- Escalate on risk, not only on time. High-value, unusual, or first-time actions should go to a second approver even if an answer arrives, and four-eyes should mean the proposer is not the approver.
- Return rejection reasons to the agent. A reason turns a dead end into a revision. A bare rejection tends to produce the same proposal again.
- Never let the agent approve itself. The model can draft the request and explain it, but the approval identity must come from an authenticated human channel, not a field the model can write.
- Keep PII out of notification channels. Notification tools (email, chat, tickets) are often broader than the approval itself. Send an id and a link, not the sensitive payload.
- Design the pause to be durable and cheap. Park the run in a checkpoint and wake on a signal or timer. Holding a thread or a serverless instance open for hours wastes money and will be killed anyway.
- Measure the queue. Track pending approvals, time-to-decision, approval and rejection rates, and how often edits happen. A rising edit rate means the agent’s proposals are drifting.
Interview questions
1. When should an agent require human approval?
Answer. When an action is irreversible, expensive, or sensitive: sending external communication, moving money, deleting or modifying access, exporting personal data, or anything with a large blast radius. Reversible, cheap, read-only actions should run automatically, or the approval queue becomes noise and people stop reading it.
Follow-up: “How do you decide the threshold?” Classify by reversibility, cost, audience, and data sensitivity. Put the policy in a table reviewed by the business, not in the model’s prompt.
Trap. Proposing to gate everything “to be safe.” That guarantees approval fatigue and makes the gate useless.
2. Approve, reject, edit — how do you model the three outcomes?
Answer. Each is a recorded decision with an approver and timestamp. Approve allows the action unchanged; edit allows it with a corrected payload; reject blocks it and returns a reason. The executed call must use the recorded approved payload, and its hash should match what the approver saw.
Follow-up: “Why is edit important?” It lets a human fix a near-miss without abandoning the whole run: the intent was right, the arguments were wrong. It also turns a near-miss into a training signal.
Trap. Treating edit as “approve and let the agent re-plan freely.” An edit is a specific corrected payload, not blanket permission for whatever the agent does next.
3. How does an interrupt-and-resume mechanism work?
Answer. A node checks policy before a gated action, persists state, and raises an interrupt that stops the run and returns control to the caller. The run sits in a store keyed by thread id. Later, a decision arrives and the runtime resumes the graph from the checkpoint; the interrupted node re-executes with the decision available, and completed steps are skipped.
Follow-up: “What has to be true for this to work after a restart?” The state must be serialisable and stored durably, the interrupt must be reproducible from the checkpoint, and the pending action must be recoverable.
Trap. Implementing an interrupt as a busy-wait or a long-held connection. It will not survive a deploy and it burns resources.
4. What should happen if a human never responds?
Answer. A timeout fires and the safe default applies: normally reject or park the action. High-risk work escalates to another approver or a queue owner. Expired approvals should be visible and auditable, not silently dropped.
Follow-up: “Why not default to approve?” Because defaulting to approve converts an unanswered prompt into an authorised irreversible action. The absence of a human is not evidence that the action is safe.
Trap. Forgetting to clean up expired requests, so the run leaks and the same approval resurfaces later.
5. What is escalation, and when do you use it?
Answer. Escalation routes a pending decision to someone with more authority or a different role. Use it when the action exceeds a value or risk threshold, when the first approver does not respond in time, or when policy requires four-eyes. Escalation raises the seniority or urgency of the decision, it does not remove the gate.
Follow-up: “How is escalation different from a retry?” A retry asks the same person again. Escalation changes who decides because the decision is above the first approver’s authority.
Trap. Escalating to the agent’s owner by default, so every pause becomes one person’s bottleneck. Route by policy and keep a rollover list.
6. How do you capture an approval for audit?
Answer. Record the run and step, the proposed action and exact arguments, a hash of those arguments, the approver identity, the decision, the final executed arguments, and the timestamps. Store it append-only. In a review you should be able to prove what was proposed, who approved it, what changed, and what actually ran.
Follow-up: “Why hash the arguments?” So you can detect any change between what was approved and what executes, and so the record is compact and tamper-evident.
Trap. Logging only “user approved.” Without the payload and identity, the record proves nothing.
7. How do you avoid approval fatigue?
Answer. Gate only the genuinely consequential classes, batch related decisions, show the minimum context needed to decide, and learn from edits and rejections to improve the agent’s proposals. Track how often approvers change a payload: a high edit rate means the agent is proposing the wrong things.
Follow-up: “What if volume is still high?” Raise the auto-approve threshold for proven-safe action types, add automated pre-checks, or route by exception. Never respond by making the approval UI faster to click through.
Trap. Adding a blanket “approve all” button. It converts a safety control into a formality.
8. What state must survive the pause, and why?
Answer. Everything needed to resume and to justify the decision: messages and plan, the step number, the pending action and its exact arguments, any prior decisions, the run and thread ids, and the schema version. Plus the audit record of the decision itself. If any of that is missing, resume cannot proceed or cannot be trusted.
Follow-up: “What must not be in the paused state?” Secrets and unnecessary PII. The state is copied into logs and debug tools, and the pause may last for hours across several systems.
Trap. Storing a live connection or an in-memory callback in the paused state. Neither survives the pause, so resume must rebuild them from ids and configuration.
Remember this
- Gate irreversible, expensive, and sensitive actions; auto-approve the reversible and cheap, or fatigue defeats the control.
- Approve the exact payload, hash it, and execute the approved arguments — not the model’s originals.
- Pause durably, resume from the checkpoint, and execute the approved action exactly once with an idempotency key.
- Silence is not consent: time out to the safe default and escalate when policy demands.
- Record the decision in state and an append-only audit log with approver identity, payload hash, and timestamp.