Runnable (Tool, Agent, or Workflow step) can require human approval before it runs. When a gate fires, the run cancels with a structured ApprovalEvent; you resume by calling the runnable again with approval_decisions={...}. Use approvals for irreversible actions: refunds, deploys, account deletions, outbound emails, anything that costs money or moves data.
Quick Start
Mark a tool as approval-required, stream events, and resume with a decision keyed byapproval_id.
status.code == "cancelled" and status.reason == "approval_required". The ApprovalEvent carries:
approval_id— stable id used to resolve the gaterunnable_path,runnable_name,runnable_type— what was about to executeinput— validated handler input (after redaction, if configured)prompt,description— human-facing stringst0— Unix-ms timestamp of when approval was requested (useful for SLA timers)
Configuring Approval Gates
requires_approval accepts True, False, or a callable that receives the same kwargs as the handler and returns bool. approval_prompt and approval_description accept strings or callables and surface in the ApprovalEvent so the human reviewer has context.
requires_approval or approval_prompt raises, the runnable does not silently approve nor execute. It ends with status.code == "error" and status.reason == "approval_policy_error" — distinct from handler errors so dashboards can surface policy bugs separately.
Resolutions: Approve, Deny, Audit
Pass either a barebool (True/False) or an ApprovalResolution for richer audit fields:
span.metadata["approval"]["resolution"]:
approver_id— who decidedcomment— free-form reasoningdecided_at— Unix-ms timestamp; defaults to construction time. Pass an explicit value if you replay decisions and need idempotencymetadata— org-specific extras
Tool denial vs Agent denial
Behavior differs based on who initiated the call:- Direct tool call — denial returns
status.code == "cancelled"andstatus.reason == "approval_denied". The handler does not run. - Tool called by an Agent — denial is converted into a
ToolResultContentso the model can see “this tool was denied” and choose another path (apologize, escalate, try an alternative). The agent does not crash.
Time-Limited Decisions
Bound decisions withexpires_at (Unix-ms). Expired resolutions are ignored at gate time and the gate emits a fresh ApprovalEvent with metadata["approval"]["expired"] == True:
Redacting Approval Input
Approval input is shown to humans and written to traces. If a gated runnable receives secrets or PII, redact the public approval snapshot. The simple form lists keys to mask with"***":
approval_redactor. It receives a copy of the validated input dict and returns the public snapshot:
ApprovalEvent.input, span.input while the gate is pending, span.metadata["approval"]["input"], OutputEvent.metadata["pending_approvals"], and any exporter (OTel, Langfuse, etc.). The handler still receives the original unredacted input when the approval is resumed.
A redactor that raises or returns a non-dict falls back to a placeholder so secrets never leak through a buggy redactor.
approval_id Semantics
The approval_id is derived from (runnable_path, validated_input). The same path + input shares one decision, so a single resolution survives retries of the same call (stream resumes, transient failures, agent loops re-asking for the same tool). Treat the id as opaque — the derivation is an internal contract and may change across SDK versions, so don’t persist ids across deploys.
For irreversible operations (money movement, destructive deletes) where every call must require a fresh decision, include a unique value in the input so each call derives a distinct approval_id:
idempotency_key: str parameter on the tool with a default_params={"idempotency_key": lambda: str(uuid4())} — Timbal evaluates the callable per-invocation.
Approvals in Workflows
Workflow steps follow the same rules as tools —requires_approval is a Runnable config, so wrap the function in a Tool (or use any Runnable) before adding it as a step. When a gated step fires, the workflow run cancels with approval_required and emits one ApprovalEvent per pending gate. Independent gates fire in parallel — you don’t need to ping-pong one approval at a time.
approval_decisions={...}, only the steps you decided on advance. Other pending gates remain pending and re-emit on the next call. This means you can approve a subset, observe what runs, and decide on the rest later.
Durable Resume Across Processes
The defaultInMemoryTracingProvider only resumes within the same Python process. For “approve in a UI now, resume in a worker later” workflows, switch to a durable provider:
- JSONL (local / single host)
- SQLite (single host, higher throughput)
- Timbal Platform (multi-host)
JsonlTracingProvider writes one record per run and uses a sidecar lock file (traces.jsonl.approval_claims.json + .lock via fcntl) for cross-process approval claims. Good for local dev and single-host deployments. Not recommended for high-throughput production — _store() rewrites the file on each run.parent_id:
Duplicate Worker Protection
When multiple workers consume the same approval queue, two of them might race to resume the same(parent_id, approval_id). Timbal claims the pair before executing the resolution. The first claimer wins; later duplicates stop before handler execution with status.reason == "approval_already_claimed".
JsonlTracingProvider and SqliteTracingProvider out of the box. Custom providers must override claim_approval(parent_id, approval_id, run_id) to get the same durable-lock behavior — the base class default is a no-op.
Enumerating Pending Approvals
When a run cancels withapproval_required and the runnable has multiple concurrent tool calls, each gate emits its own ApprovalEvent. There are two ergonomic ways to enumerate them.
During the stream, capture every ApprovalEvent:
.collect(), read OutputEvent.metadata["pending_approvals"]:
result.status only references the first gate. Resume by passing all decisions you want to settle in one call:
RunContext.pending_approvals() walks RunContext._trace directly. It tolerates both live RunStatus and dict-after-reload shapes, so it works against in-memory, JSONL, SQLite, and platform traces. Returned entries use the redacted approval input snapshot — never the raw secrets.
Observability
Status reasons
When the run cancels,OutputEvent.status.reason carries one of:
approval_required— a gate emitted anApprovalEventand is waiting on a decisionapproval_denied— a denying resolution was consumed (direct call only; agents convert this to a tool result)approval_already_claimed— durable claim said another worker already resumed this gateapproval_policy_error— arequires_approval/approval_promptcallable raised
Usage counters
OutputEvent.usage records approval-lifecycle counters so you can plot them in dashboards:
approvals:required— a gate emitted anApprovalEventapprovals:approved— a valid approved resolution was consumedapprovals:denied— a valid denied resolution was consumedapprovals:expired— an expired resolution was ignored and the gate re-emitted
Common Patterns
Approve from a UI, resume from a worker
Approve from a UI, resume from a worker
- Configure a durable provider (
JsonlTracingProvider/SqliteTracingProvider/PlatformTracingProvider). - UI calls
agent(prompt=...), capturesApprovalEvent(or polls the trace forpending_approvals()), shows reviewer the prompt + redacted input. - Reviewer clicks Approve/Deny. UI persists
(approval_id, ApprovalResolution)to a queue. - Worker pulls the message and calls
agent(prompt=..., parent_id=run_id, approval_decisions={approval_id: resolution}). - If
result.status.reason == "approval_already_claimed", no-op. Otherwise, the handler executed exactly once.
Approve a batch of tools in one round-trip
Approve a batch of tools in one round-trip
The agent may call multiple gated tools in parallel. Each gate emits its own
ApprovalEvent and the run cancels once they all settle. Collect all approval_ids, present them as a checklist, and resume with the full dict in one call.Force a fresh decision for each call
Force a fresh decision for each call
The default
approval_id is stable across retries of the same (path, input). To require a fresh decision per invocation, include a per-call unique value in the input — typically an idempotency_key=str(uuid4()) — so each call derives a distinct id.Hide secrets from reviewers
Hide secrets from reviewers
Use
approval_redact_keys=["api_key", "password"] for the simple case. Use approval_redactor=lambda input: {...} for partial masking (e.g. mask the local-part of an email but keep the domain). The handler still receives the unredacted input.See Also
- Approval-Required Tools example — runnable end-to-end snippet
- Tracing — how runs are persisted and replayed
- Context & State Management —
RunContextinternals - Tools — tool configuration reference