Skip to content

Human in the loop

Pause a risky call until a person reviews it, then resume it: approved, edited or denied. Each decision runs at most one call, once.

sequenceDiagram
  participant App as your app
  participant Ex as Executor
  participant P as DeferredApproval
  participant S as ApprovalStore
  participant Rv as reviewer
  App->>Ex: run_call(call, ctx)
  Ex->>P: request(run, call, tool, args)
  P->>S: create → pending
  Ex-->>App: ToolResult status="pending" (tool not run)
  Rv->>S: list(status="pending") · decide(id, decision)
  App->>Ex: run_call(same call, same ctx)
  Ex->>P: request (same run and call id)
  P->>S: find → decided · consume (once)
  Ex-->>App: ok (edited args) or approval-denied

1. Mark the risky tool

def big_refund(call: ToolCall, ctx: ToolContext) -> bool:
    return float(call.arguments["amount"]) > 100  # small refunds run without review


@registry.tool(confirm=big_refund, effects="destructive")
def refund(order_id: str, amount: float) -> str:
    """Refund an order."""
    paid.append(amount)
    return f"refunded {amount} on {order_id}"

confirm=True reviews every call. A rule (call, ctx) -> bool (sync or async) reviews only some: here refunds over 100.

2. Pick a port

Port Use when
AutoDeny(reason=...) no reviewer exists (read-only mode, batch jobs)
CallbackApproval(fn) the decision is made in-process, now (a CLI prompt, a policy check)
DeferredApproval(store) a person decides later, elsewhere (a web UI, a queue, a ticket)
none every confirm call is denied: "no approval port configured"
store = MemoryApprovalStore()  # use a database-backed ApprovalStore across processes
executor = Executor(registry, approval=DeferredApproval(store))
ctx = executor.context(run_id="run_support_42")  # keep the run id to resume later

Keep the run_id: the resume must use the same run and the same call id.

3. Pause

call = ToolCall(name="refund", arguments={"order_id": "o-7", "amount": 500}, id="call_1")
first = await executor.run_call(call, ctx)
print(first.status, first.error.kind if first.error else "")  # pending, nothing ran

run_call never raises for a pending call: it returns status="pending" with error.details["approval_id"]. run raises ApprovalPending (with .approval_id) instead. Nothing ran, and no ledger entry is written.

4. Review

[waiting] = await store.list(run_id=ctx.run_id, status="pending")
print(waiting.request.tool, waiting.request.arguments)  # what the reviewer sees
edit = Decision(approved=True, edited_args={"order_id": "o-7", "amount": 450}, decided_by="ana")
await store.decide(waiting.approval_id, edit)
try:
    await store.decide(waiting.approval_id, Decision(approved=False, decided_by="bob"))
except ApprovalAlreadyDecided:
    print("first decision wins")

A reviewer sees the tool and its arguments. edited_args replaces the arguments; they are validated again before the call. The first decide wins; a second raises ApprovalAlreadyDecided.

5. Resume

resumed = await executor.run_call(call, ctx)  # same run, same call id
print(resumed.status, resumed.value)  # runs once, with the edited amount

6. Deny

call = ToolCall(name="refund", arguments={"order_id": "o-9", "amount": 900}, id="call_2")
pending = await executor.run_call(call, ctx)
approval_id = pending.error.details["approval_id"] if pending.error else ""
no = Decision(approved=False, reason="refund policy: max 500", decided_by="ana")
await store.decide(approval_id, no)
denied = await executor.run_call(call, ctx)
print(denied.status, denied.error.message if denied.error else "")  # the tool never ran

The reason reaches the caller in ApprovalDenied (kind approval-denied); the model sees it too.

The one-use rule

print((await store.get(waiting.approval_id)).consumed)  # the decision is spent
again = await executor.run_call(call, ctx)  # a replay of the same call ...
new_id = again.error.details["approval_id"] if again.error else ""
print(again.status, new_id != waiting.approval_id)  # ... is a new review, not a rerun
Rule Effect
A decision is consumed by the call it answers replaying the same call opens a new review; it never runs twice on one approval
A record matches only its own call same run, same call id, same tool and a digest of the original arguments; anything else fails closed with ToolUsageError
Two resumes race for one decision one gets it; the other is denied ("approval already used by a concurrent request")
A framework says "approved" never counts alone: a bridge passes approval only as a one-use token the adapter issued after its own port approved, bound to the run, native call id, tool and argument digest. A bridge cannot issue a token for a call with no native call id; such a call is denied by the bridge or decided by the executor's port
An MCP server with client_approval=True opt-in: the MCP client's user approves instead of the port (MCP approval); leave it off where a server-side reviewer is the control
You set ctx.approval yourself it must be your own trusted decision, or an adapter-issued one-use token; it covers one call of run / run_spec / run_call, never nested or loop calls

Output

refunded 20.0 on o-1
pending approval-pending
refund {'order_id': 'o-7', 'amount': 500.0}
first decision wins
ok refunded 450.0 on o-7
True
pending True
error approval denied for tool 'refund': refund policy: max 500
[20.0, 450.0]

In production

Need Do
Several processes a database-backed ApprovalStore (atomic create and consume; SQL recipe on Approval stores)
Reviews over HTTP the HTTP server: GET /approvals, POST /approvals/{id}/decide (with edited_arguments). By default only the run's owner or a principal with the approvals:decide scope may decide; pass your own authorize_decision rule for four-eyes review
An agent loop run_tool_loop returns finish="pending"; decide, then call it again with the returned messages and the same ctx (Tool loop)
A flow step pause gates in a flow (Flow pause and resume)
Reviews in Slack or email Approval over Slack or email
A framework's own pause its bridge: resolve_interruptions, resolve_deferred, resolve_approvals, resolve_confirmations (Approval batches)

Files

File Does
examples/howto_hitl.py the rule, the store, pause, review, resume, deny, one use
tests/docs/test_howto_examples.py runs it offline and checks the output