Skip to content

Approval (human in the loop)

A tool with confirm= set asks an approval port before it runs. The port approves, denies, edits the arguments, or says "pending" so the run can pause and resume later.

flowchart TD
  H["before_tool hooks"] --> P{"ctx.approval set?<br/>(your own pre-approval, or a<br/>bridge-checked adapter token)"}
  P -- yes --> E{"edited_args?"}
  P -- no --> R{"confirm rule says yes?"}
  R -- no --> RUN["run the tool"]
  R -- yes --> PORT["ApprovalPort.request(ApprovalRequest)"]
  PORT -->|approved| E
  E -- yes --> V["validate the edited args, then<br/>run before_tool hooks on them again"] --> RUN
  E -- no --> RUN
  PORT -->|denied| D["ApprovalDenied"]
  PORT -->|raises| PEND["ApprovalPending<br/>(result status 'pending')"]

The executor runs this step; this page covers the pieces it uses. Import them from agent_adapter; ApprovalPort, ApprovalStore, ApprovalRecord and evaluate_confirm come from agent_adapter.approval.

A framework's own "approved" never counts alone. A bridge sets ctx.approval only from a one-use token the adapter issued after its own port approved. The token is bound to the run, the native call id, the tool and an argument digest (one-use rule). One opt-in exception: an MCP server built with client_approval=True makes the MCP client's user the approver instead of the port (MCP approval). Leave it off where a server-side reviewer is the control.

Rule What it means
A pre-approval covers one call ctx.approval applies only to the one call of run / run_spec / run_call, and only when ctx.call_id is empty or that call's id. run_many, as_completed, run_plan and run_tool_loop never use it: every call asks the port
It is used up at step 5 the call keeps it to itself: the tool body's context has approval=None, so a nested call or a run_tool_loop inside the tool asks the port, even with the same call id
Edits are checked like originals edited arguments are validated again, then the before_tool hooks (guardrails, budget, your own checks) run again on them. A hook that blocks stops the call; a ShortCircuit replaces it. An edit equal to the original arguments is not checked twice

Confirm rules

confirm is a bool or a function (call, ctx) -> bool (sync or async).

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


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

evaluate_confirm(rule, call, ctx) returns the answer. A rule that raises is logged (tool name and ids, never the arguments) and the error propagates; it is not retried. A rule that returns a non-bool is a ToolUsageError. Pending approvals and control flow (including types added with register_control_flow) propagate without a log. The executor's step 5 calls this same function.

Ports

Port request() returns
AutoApprove(*, reason="auto") approved, decided_by="auto"
AutoDeny(*, reason="denied by policy") denied, decided_by="auto"
CallbackApproval(fn) fn(request), sync or async; a bool becomes a decision by "callback"
DeferredApproval(store) the stored decision, or raises ApprovalPending
under_limit = CallbackApproval(lambda req: req.arguments["amount"] <= 1000)
print((await under_limit.request(refund_request(500))).approved)
print((await AutoDeny(reason="read-only mode").request(refund_request(500))).reason)
ApprovalRequest field Meaning
approval_id apr_…; the store fills an empty one
run_id, call_id which call; the deferred resume key
tool, arguments tool name and LLM-facing arguments (node:<flow>.<node> for flow pauses)
kind, hint, metadata, created_at "tool" or "node", reviewer hint, free data, epoch seconds

Any object with async def request(self, request) -> ApprovalDecision is a port. Decision is the short name of ApprovalDecision (the same class).

ApprovalDecision field Meaning
approved the verdict
edited_args replacement arguments; validated and run through the before_tool hooks again
reason, decided_by, decided_at why (carried by ApprovalDenied); who; epoch seconds

Deferred approval

For reviews that happen elsewhere (the HTTP server, a queue, a ticket), the call pauses and resumes with the same (run_id, call_id).

sequenceDiagram
  participant Ex as executor
  participant P as DeferredApproval
  participant S as ApprovalStore
  participant Rv as reviewer
  Ex->>P: request(req)
  P->>S: find(run_id, call_id) → none
  P->>S: create(req) → pending
  P-->>Ex: raise ApprovalPending(approval_id)
  Rv->>S: list(status="pending"), decide(id, decision)
  Ex->>P: request(req) (resumed call)
  P->>S: find → decided
  P->>S: consume(id)
  P-->>Ex: decision
store = MemoryApprovalStore()
port = DeferredApproval(store)
try:
    await port.request(refund_request(500))  # 1. no record yet: create one, pause
except ApprovalPending as pending:
    approval_id = pending.approval_id
print((await store.get(approval_id)).status)  # 2. a reviewer lists pending records

decision = ApprovalDecision(approved=True, edited_args={"amount": 450}, decided_by="ana")
await store.decide(approval_id, decision)  # 3. ... and decides (HTTP server, queue)

resumed = await port.request(refund_request(500))  # 4. the resumed call gets it, once
print(resumed.approved, resumed.edited_args, (await store.get(approval_id)).consumed)

A pending record answers every repeat request with the same approval_id. Once a decision is consumed, the next request for that call creates a new record (a new review). If two resumes race for one decision, one gets it and the other fails closed with ApprovalDenied("approval already used by a concurrent request"). To decide a parked call and run it once by its id, call executor.resume(approval_id, True, decided_by="ana"); a pending record needs both (Resume a parked call).

A record answers only the call it was made for: the same (run_id, call_id), and an args_digest over the kind, tool and original arguments. Any other request fails closed with ToolUsageError("call id reused with different tool/arguments"), logged at WARNING without the arguments. A decision's edited_args are what runs; the match is checked against the original request.

request_digest(request) Rule
covers kind, tool and the original arguments
canonical form json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False), encoded UTF-8 with surrogatepass; SHA-256 hex
values JSON only: dict (string keys), list, str, int, float (finite), bool, None. Anything else (bytes, tuples, sets, objects, other keys), or nesting deeper than MAX_JSON_DEPTH (256), is a ToolUsageError before the store is touched
trust always recomputed from the stored request; a stored digest that disagrees makes the record invalid (ToolUsageError)

Store

ApprovalStore is a protocol; MemoryApprovalStore(*, max_records=10_000) keeps records in process. When several processes or hosts serve one run, use a durable store: SQLiteApprovalStore or RedisApprovalStore (Approval stores), or SQLAlchemyApprovalStore or DynamoDBApprovalStore (Database stores).

Method Does Raises
create(request) atomic per call: stores a pending record, or returns the call's unconsumed record unchanged ToolUsageError if another call's record has the id
get(approval_id) the record ApprovalNotFound
find(run_id, call_id) the call's unconsumed record, or None —
decide(approval_id, decision) sets approved / denied; first decide wins ApprovalNotFound, ApprovalAlreadyDecided
consume(approval_id) -> bool atomic test-and-set: True only for the caller that marks a decided record used; False if already consumed or still pending ApprovalNotFound
list(*, run_id=None, status=None, limit=100) records, newest first ConfigError on a bad status or limit

ApprovalRecord is a frozen model: request, status, decision, consumed, args_digest (always request_digest(request), recomputed by model_copy) and the property approval_id. Locking and eviction of the memory store: Approval stores.

Events

Ports and stores emit nothing. The executor alone emits approval.requested, approval.decided, and tool.pending when a port raises ApprovalPending.

This page's code is examples/approval.py. The test suite runs it.