dendrux
v0.4.0Get started →

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.completed

Five things to notice:

  1. The approval.requested event (seq=2) and the tool.completed event (seq=5) share the same correlation_id. That id is the tool_call.id, so a reader can join the approval decision to the specific call it authorized.
  2. 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_index still runs straight through.
  3. The tool function ran only once, between seq=4 and seq=5, after the approval was in hand. No speculative execution.
  4. approval.decided (seq=6) carries the decision in its data payload: {"decision": "approved", "run_id": "...", "decisions": [...]}, with one entry per pending call (see What approval.decided records). This row is the audit record of the human's choice.
  5. 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:

  1. The tool function was not called. REFUND_CALLED is empty. The rejection does not invoke the function and then fail it; the function is simply skipped.
  2. A tool_calls row was still written. success=0, error_message is 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 scanning tool_calls with WHERE success = 0 AND error_message IS NOT NULL gets every rejected attempt grouped with actual tool failures, which is the right shape for "things that did not produce side effects."
  3. The model gets a normal failed tool result. tool.completed with success=False and 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=.

FormWhat happens
approved=TrueEvery approval-gated server call executes, and so does every held non-gated server call.
approved=FalseEvery pending call receives a synthetic failed ToolResult whose payload is rejection_reason as a JSON string (default "User declined to run this tool."). Same bytes as before 0.3.
decisions={call_id: ApprovalDecision.approve() | ApprovalDecision.deny(reason)}Each gated call is decided on its own. A denied call gets a failed result whose payload is {"denied": reason}.

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:

  1. The runtime walks pending_tool_calls in model order. It appends one TOOL message for each resolved server or denied call; client-target calls remain pending, and their messages are appended later by submit_tool_results, after the server results.
  2. Approved gated calls execute server-side through the normal executor path.
  3. Denied calls are skipped. The model sees a failed tool result {"denied": "<reason>"}, and a tool_calls row is written with success=0 and the reason as error_message, like the batch reject path.
  4. Held non-gated calls, server calls that sat in the batch but never needed approval, execute automatically. They are not part of decisions.
  5. 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=True included. They stay pending and the run moves to waiting_client_tool so the client can run them and call submit_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:

  1. CAS-guarded. Batch approve uses claim_paused_run; per-call decisions and batch reject use submit_and_claim, so the submitted data and the waiting_approval to running claim commit atomically. Two concurrent Approve clicks resolve deterministically: one wins; the other raises PauseStatusMismatchError or RunAlreadyClaimedError and can be shown as "this request has already been decided."
  2. Terminal-safe. If the run has already been finalized (someone else cancelled it, for example), submit_approval raises RunAlreadyTerminalError. 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:

  1. 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.
  2. 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.
  3. 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.runner and dendrux.loops.react.ReActLoop (the pause and re-dispatch logic).
  • Resolved with agent.submit_approval(run_id, approved=...) or agent.submit_approval(run_id, decisions={...}).
  • Gates can also come from a before_tool policy hook.
  • Emits approval.requested (one per gated call, on pause) and approval.decided (once, on resume) on run_events.
  • Persists the pending batch in agent_runs.pause_data until resume. See Pause and resume for the state-machine side.