Human-in-the-loop gating on gated tool calls, with a persist-and-resume flow that works across processes and an explicit reject path.
Approval
Approval is the governance layer that gates a specific tool call on an explicit human decision. The agent is declared with require_approval=["tool_name"]. When the LLM emits that tool call, the runtime does not execute it. Instead, the run pauses with status=waiting_approval, persists the pending call in pause_data, and returns. A later call to submit_approval either lets each call execute or synthesizes a denial for it, and the loop continues. You can decide the whole batch at once (approved=True or False) or call by call (decisions={call_id: ApprovalDecision...}).
The power of this design is that the two halves of the flow do not have to share a process, a server, or a memory space. The starter can return an HTTP response while the run sits paused in the DB; a reviewer can open a separate request hours later and approve or reject. Nothing about the handoff depends on in-memory continuity.
Declaring an approval gate
import asyncio
from dendrux import Agent, tool
from dendrux.llm.anthropic import AnthropicProvider
@tool(target="server")
async def refund(order_id: int) -> str:
"""Issue a refund."""
return f"Refunded order {order_id}"
async def main() -> None:
async with Agent(
name="Support",
provider=AnthropicProvider(model="claude-haiku-4-5"),
prompt="You are a support agent. Use the refund tool when a refund is requested.",
tools=[refund],
require_approval=["refund"], # refund cannot execute without submit_approval
database_url="sqlite+aiosqlite:///demo.db",
) as agent:
result = await agent.run("Refund order 42 using the refund tool.")
print(result.status.value) # waiting_approval
print(result.run_id) # handle for submit_approval later
asyncio.run(main())The gated tool is registered normally. The runtime just remembers its name. At dispatch time, a call to a name in require_approval is intercepted before the tool function would run.
Construction-time validation still applies: every name in require_approval must exist in tools, the overlap with deny is rejected, and missing tools raise ValueError. See Access control for the adjacent constructor checks.
The happy path (approved)
The Quickstart already walks through this, with real DB state pulled from the run. The shape at a glance:
seq=0 run.started
seq=1 llm.completed (model emits refund(order_id=42))
seq=2 approval.requested corr=01KPFXPAT8ZF7MNGJNW2Y6S8GZ
seq=3 run.paused status=waiting_approval
<-- starter process exits here -->
<-- a later process calls submit_approval(run_id, approved=True) -->
seq=4 run.resumed
seq=5 tool.completed refund executed, success=True
seq=6 approval.decided decision=approved
seq=7 llm.completed (model writes the final user-facing answer)
seq=8 run.completedFive things to notice:
- The
approval.requestedevent (seq=2) and thetool.completedevent (seq=5) share the samecorrelation_id. That id is thetool_call.id, so a reader can join the approval decision to the specific call it authorized. - The pause at seq=3 and the resume at seq=4 are adjacent in the log but written by two different processes. Event ordering explains why
sequence_indexstill runs straight through. - The tool function ran only once, between seq=4 and seq=5, after the approval was in hand. No speculative execution.
approval.decided(seq=6) carries the decision in itsdatapayload:{"decision": "approved", "run_id": "...", "decisions": [...]}, with one entry per pending call (see Whatapproval.decidedrecords). This row is the audit record of the human's choice.- The run finishes as
status=success. Approval did not need to be terminal.
The reject path
submit_approval(run_id, approved=False, rejection_reason="...") is the other branch. It resumes the run, skips the tool, and feeds a synthetic failed tool result (carrying the reason) back to the model:
paused = await agent.run("Refund order 42 using the refund tool.")
print(paused.status.value) # waiting_approval
done = await agent.submit_approval(
paused.run_id,
approved=False,
rejection_reason="Manager declined: amount exceeds automatic threshold.",
)
print(done.status.value) # success (the run finishes; the model handles the rejection)A live capture with the same refund agent, rejected with that reason:
first: status=waiting_approval
after reject: status=success
refund function actually called? False
final answer preview: "I attempted to process the refund for order 42, but it was declined because
the refund amount exceeds the automatic threshold. This means that..."
run_events:
seq=0 run.started
seq=1 llm.completed
seq=2 approval.requested
seq=3 run.paused
seq=4 run.resumed
seq=5 tool.completed success=False
seq=6 llm.completed
seq=7 run.completed
tool_calls:
refund success=0 error="Manager declined: amount exceeds automatic threshold."Three things worth calling out in the rejected trace:
- The tool function was not called.
REFUND_CALLEDis empty. The rejection does not invoke the function and then fail it; the function is simply skipped. - A
tool_callsrow was still written.success=0,error_messageis the rejection reason. This is deliberate: from an audit perspective, "we tried to refund this order and we declined" is part of the story. A reader scanningtool_callswithWHERE success = 0 AND error_message IS NOT NULLgets every rejected attempt grouped with actual tool failures, which is the right shape for "things that did not produce side effects." - The model gets a normal failed tool result.
tool.completedwithsuccess=Falseand the rejection reason as the error. The next LLM turn sees it in history the same way it would see a real tool crash, and produces a sensible user-facing answer. No special prompt handling is required.
The contrast with Access control matters: a deny entry blocks at dispatch phase 0, never pauses, and never writes a tool_calls row. A rejected approval passes through the full pause-and-resume pipeline and writes a row for the audit. Same outcome (no side effect), different bookkeeping, because the semantics are different: policy deny is an invariant, approval reject is a decision.
Submitting the decision
From dendrux/agent.py:
async def submit_approval(
self,
run_id: str,
*,
approved: bool | None = None,
decisions: Mapping[str, ApprovalDecision] | None = None,
rejection_reason: str | None = None,
notifier: LoopNotifier | None = None,
) -> RunResult:
"""Pass exactly one of approved= (whole batch) or decisions= (per call)."""Pass exactly one of approved= or decisions=; passing both or neither raises ValueError, as does rejection_reason together with decisions=.
Per-call decisions
from dendrux import ApprovalDecision
await agent.submit_approval(
run_id,
decisions={
call_a: ApprovalDecision.approve(),
call_b: ApprovalDecision.deny("Order 2 is outside the refund window"),
},
)The keys must be exactly the ids of the approval-gated calls: the calls that fired approval.requested. A missing id, an unknown id, or a duplicate raises ValueError before anything is claimed, so a wrong request leaves the run waiting. Call ids are in the approval.requested events (correlation_id and data.call_id) and in the run.paused event's pending_tool_calls.
How the batch resolves on resume:
- The runtime walks
pending_tool_callsin model order. It appends oneTOOLmessage for each resolved server or denied call; client-target calls remain pending, and their messages are appended later bysubmit_tool_results, after the server results. - Approved gated calls execute server-side through the normal executor path.
- Denied calls are skipped. The model sees a failed tool result
{"denied": "<reason>"}, and atool_callsrow is written withsuccess=0and the reason aserror_message, like the batch reject path. - Held non-gated calls, server calls that sat in the batch but never needed approval, execute automatically. They are not part of
decisions. - Client-target calls never execute on the server (see below).
The decisions are saved in pause_data["submitted_decisions"] in the same step that claims the run. No migration is involved.
Which calls are gated
When a batch pauses for approval, the runtime saves a snapshot of the gated calls in pause_data["approval_gates"], as {call_id: {"source": "static" | "hook", "reason": ...}}. source="static" means the tool is in require_approval=[...]; source="hook" means a before_tool hook returned require_approval.
submit_approval(decisions=...) validates only against that snapshot. Policy is never recomputed at decision time, so the process that submits the decision may be a different Agent instance, with a different (or no) before_tool hook, and still validates correctly. A pause saved by an earlier release has no snapshot; for those, every server call in the batch counts as gated, so nothing runs without an explicit decision.
One gated call pauses the whole remaining batch: the non-gated calls are held until the human decides, so the model's tool results go back together.
Mixed server and client batches
A batch can contain server calls and client-target calls (see Pause and resume). Each pending call is routed by its saved target on resume:
- Server calls (approved or non-gated) execute.
- Client-target calls are never executed on the server, whichever form you use,
approved=Trueincluded. They stay pending and the run moves towaiting_client_toolso the client can run them and callsubmit_tool_results.
Before 0.3.0, an approved batch executed every pending call server-side, including client-target ones. That was a bug and a security risk: a tool meant to run in the user's environment ran on the server. It is fixed; see the CHANGELOG.
approved=False rejects every pending call, client calls included, so the model gets a denial for each.
What approval.decided records
approval.decided keeps its scalar decision and run_id. The scalar is approved, rejected or mixed. A new decisions list has one entry per pending call, whatever the form:
{
"decision": "mixed",
"run_id": "01KPG17P8XNWVJC1EN0M3XKCK4",
"decisions": [
{"call_id": "01KPG...A", "tool_name": "refund", "decision": "approved", "reason": null},
{"call_id": "01KPG...B", "tool_name": "refund", "decision": "denied", "reason": "Order 2 is outside the refund window"},
{"call_id": "01KPG...C", "tool_name": "lookup", "decision": "not_gated_executed", "reason": null}
]
}Each entry's decision is one of approved, denied, not_gated_executed or client_pending. The payload carries tool names and call ids, never params.
Approve later
The decision belongs to the pause. Once a run has moved on, there is no way to approve a call after the fact. When "not now, but maybe later" is a real workflow (a review queue, a daily batch), deny the call with a reason that says so, and let your application run the action itself when someone signs it off:
# 1. Hold the call now. The model is told the action is queued.
await agent.submit_approval(
run_id,
decisions={call_id: ApprovalDecision.deny("Held for review: ticket 4412")},
)
# 2. Later, after a reviewer approves, run the action yourself.
# For an MCP tool, call it straight on the connection:
await connection.call_tool("create_issue", {"title": "Rotate the key"})call_tool bypasses all Agent governance, so authorize the call in your own code first. See Calling a tool directly. The run itself carries on with the model's view that the action is pending.
CAS-guarded and idempotent
Two things hold in every form of submit_approval:
- CAS-guarded. Batch approve uses
claim_paused_run; per-call decisions and batch reject usesubmit_and_claim, so the submitted data and thewaiting_approvaltorunningclaim commit atomically. Two concurrent Approve clicks resolve deterministically: one wins; the other raisesPauseStatusMismatchErrororRunAlreadyClaimedErrorand can be shown as "this request has already been decided." - Terminal-safe. If the run has already been finalized (someone else cancelled it, for example),
submit_approvalraisesRunAlreadyTerminalError. A run whose cancellation was requested but not yet observed is refused the same way.
After the claim, and before each approved call executes, the runtime checks for a cancel and then a pause request. A cancel finalizes the run before any further side effect; a requested pause parks the rest of the batch with the decisions already made, and resume continues it without asking again.
The notifier= kwarg lets the submit call attach its own notifier for the rest of the run. The notifier passed to the original run() call is not remembered across processes (the resumer is a different process), so the resumer supplies one explicitly if it wants live observability.
Why pause and resume instead of a callback
A callback-style alternative would have run() block on an awaitable that resolves when the human decides. In a single-process toy, that works. In real deployments, it breaks fast:
- HTTP request timeouts. The starter process has a request in flight. Waiting an hour for a human is not compatible with a 30-second timeout.
- Process restarts. A deploy or a crash loses the in-memory awaitable. Pause-and-resume survives both: the DB is the source of truth, and any process with the DB URL can resume.
- Multi-reviewer workflows. An approval queue where a different person reviews than the one who started the run requires the decision to be submittable from a different HTTP endpoint, a different service, or a different machine. That cannot work through a Python awaitable.
The pause-and-resume flow is a superset of the callback flow: anything a callback can do, a pause-and-resume flow can do (just run the submit from the same process, a millisecond later). The converse is not true.
Where this fits
- Declared on
Agent(require_approval=[...], tools=[...]). - Enforced in
dendrux.runtime.runneranddendrux.loops.react.ReActLoop(the pause and re-dispatch logic). - Resolved with
agent.submit_approval(run_id, approved=...)oragent.submit_approval(run_id, decisions={...}). - Gates can also come from a
before_toolpolicy hook. - Emits
approval.requested(one per gated call, on pause) andapproval.decided(once, on resume) onrun_events. - Persists the pending batch in
agent_runs.pause_datauntil resume. See Pause and resume for the state-machine side.