Human-in-the-Loop Approvals
Since v2026.4.17, any tool configured with approval: required pauses the run whenever the model wants to call it. The pending call surfaces as a structured "paused" state; a human approves or denies out of band, and the run resumes from exactly where it stopped — no re-prompting, no lost message history.
Under the hood this is PydanticAI's native DeferredToolRequests / DeferredToolResults contract — the same surface AG-UI and the Vercel AI SDK speak. Every runner mode (single-shot, REPL, daemon, API, dashboard) handles it.
When to use it
Reach for approvals when the argument pattern can't be decided in advance:
- Shell commands whose safety depends on the target path.
- Writes to a production store where the diff matters.
- Money-moving API calls.
- Anything you'd want a human to glance at before it goes through.
If the answer is always the same regardless of arguments, tool permissions are a better fit — they evaluate before approval and short-circuit denials without bothering a reviewer.
Configuration
Add approval: required to any tool entry:
spec:
tools:
- type: shell
working_dir: .
approval: requiredapproval accepts auto (default, no gating) and required. It composes with permissions: — deny rules short-circuit first, so a reviewer is never asked to approve a call that would have been blocked anyway.
The wrapper order is builder → PolicyToolset (Cedar/InitGuard) → PermissionToolset (fnmatch) → ApprovalToolset. A call rejected earlier in that chain never reaches a human.
How a paused run looks
REPL
Approvals prompt inline and the run resumes in place:
> delete /tmp/scratch
Run abc123 paused — 1 tool call(s) need approval.
shell call_01HW9Q
{'command': 'rm -rf /tmp/scratch'}
Approve? [y/N]: y
Agent: Deleted /tmp/scratch.Single-shot
Prints pending calls, exits with code 2, and persists state to the audit SQLite:
$ initrunner run demo.yaml -p "delete /tmp/scratch"
Run abc123 paused — 1 tool call awaiting approval.
call_01HW9Q shell {'command': 'rm -rf /tmp/scratch'}
Resume with: initrunner approve abc123 --all
$ initrunner approve abc123 --all
Resumed.
Deleted /tmp/scratch.Daemon and conversational triggers
When a cron or webhook-fired run pauses, the daemon persists state and keeps serving other triggers. Slack, Discord, and Telegram triggers send a one-liner reply:
Awaiting approval for 1 tool call(s). Resume: initrunner approve abc123 --allThe --no-audit flag disables persistence; in that mode a paused daemon run reports that it cannot be resumed rather than silently losing state.
API
POST /v1/chat/completions returns HTTP 200 with an extended body when the model pauses:
{
"id": "chatcmpl-...",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": ""},
"finish_reason": "tool_calls_pending_approval"
}],
"run_id": "abc123",
"pending_approvals": [
{"tool_call_id": "call_01HW9Q", "tool_name": "shell",
"arguments": {"command": "rm -rf /tmp/scratch"}}
]
}Streaming requests get a final SSE event before [DONE]:
data: {"event":"approval_required","run_id":"abc123","pending_approvals":[...]}
data: {"id":"chatcmpl-...","choices":[{"delta":{},"finish_reason":"tool_calls_pending_approval"}]}
data: [DONE]Resume with a map of {tool_call_id: bool}:
curl -X POST http://localhost:8000/v1/approvals/abc123 \
-H 'content-type: application/json' \
-d '{"call_01HW9Q": true}'Every pending tool_call_id on that run must carry a decision — false denies. Optional X-Resolved-By header records the operator in the audit trail. The response mirrors a regular chat completion, or the paused shape again if the model re-pauses.
Dashboard
The dashboard has two approval surfaces, both driven by the same /api/approvals/* router:
- Inline in RunPanel — when a run started from the agent detail page pauses, an Approve/Deny card group replaces the "thinking" state. Each card shows a tool-templated argument preview (e.g.
rm -rf /tmp/cacherather than raw JSON) and a left state bar (muted = unset, lime = approved, red = denied). Submit fires once every card has a decision. - Queue view (
/approvals) — reviewers see every paused run across the daemon, API, and other sessions, grouped byrun_id. Single-call runs have inline controls; multi-call runs open a right-side drawer. A sidebar badge under Operate shows the pending count (tabular-nums, polled every 20s and bumped immediately by theapproval_requiredSSE event). Press?anywhere for the keyboard grammar (j/knavigate,A/Ddecide,⇧ A/⇧ Dbulk,↵submit,Escclose).
See Dashboard: Approvals queue for screenshots and keyboard details.
CLI
initrunner pending
Lists unresolved tool-call approvals across all runs in the audit database.
$ initrunner pending
Pending approvals (1)
┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ run_id ┃ tool_call… ┃ tool ┃ agent ┃ created_at ┃ arguments ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ abc123 │ call_01HW… │ shell │ demo │ 2026-04-24T14:21:08.947991 │ {"command":"rm -rf… │
└────────────┴────────────┴───────┴───────┴────────────────────────────┴─────────────────────┘initrunner approve
Resumes a run by approving or denying its pending calls.
| Flag | Description |
|---|---|
RUN_ID | The paused run identifier (shown in the pending table and in the CLI resume hint). |
--all | Approve every pending tool call for the run. |
--tool-call-id ID | Decide only the named call; any other pending calls for the same run default to denied. |
--deny | Combine with --all or --tool-call-id to deny instead of approve. |
initrunner approve abc123 --all
initrunner approve abc123 --tool-call-id call_01HW9Q
initrunner approve abc123 --all --denyAudit trail
Resumed runs log with trigger_type="resume" and a synthetic prompt of the form (resume: call_id:approve, call_id:deny, ...) so the audit row is self-describing. The pending_approvals table retains resolved rows with resolved_at, resolved_by, and decision ∈ {approve, deny}, so the approval history survives pruning of the runs themselves.
Limitations
The following are not yet supported:
- Per-role or per-skill approval defaults — today, approval is declared per tool entry.
- Expiry sweeper for pending approvals older than N hours.
- Attribution in "already resolved" toasts on the dashboard race path. The resolver's id is in the audit trail but isn't surfaced inline on the losing client.
See also: Security, Tools: Permissions, Dashboard.