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 |