Human approval loop
A real defer → approve → permit run: a policy-deferred refund waits for a human, an operator approves it, and the agent resumes to permit — with the signed audit chain verified.
refund_charge matches an effect: review rule, so the engine returns defer instead of
running it. A separate operator client (holding a Bearer token the agent never has) approves
the pending request; the agent re-issues the same call with the approval token and the engine
upgrades it to permit. Every decision lands in a hash-chained, Ed25519-signed Decision
Provenance log before its verdict returns.
Run it yourself
From a clone of the repo — this builds a local engine, spawns raxit serve, runs the demo, and
tears everything down:
make -C examples demo-approval-loop-py # Python (the transcript below)
make -C examples demo-approval-loop-ts # TypeScript, identical shapeIn your own project, the same flow is available from the published SDK
(pip install raxit / npm install @raxitlabs/raxit) — the whole integration is three calls:
from raxit import RaxitClient
agent = RaxitClient(agent_id="approval-bot")
operator = RaxitClient(agent_id="approval-bot", operator_token="<operator bearer token>")
decision = agent.govern.decide("refund_charge", {"amount_cents": 5000}) # -> defer.policy
record = operator.approvals.approve(decision.denial_token, approver="ops-oncall", reason="ok")
resumed = agent.govern.decide("refund_charge", {"amount_cents": 5000},
approval_token=decision.denial_token) # -> permit.approvedThe run, step by step
Every terminal line below is from a real captured run of
examples/capabilities/approval-loop — nothing is mocked, and the approval token
(dt_f99b7cbf2418437d) is the actual one-time token that run minted:
Two rules, default block. read_ticket is a plain allow; refund_charge is policy-deferred (`effect: review`) — it needs a human before it can run.
apiVersion: raxit.security/v1
agent: approval-bot
default: block
rules:
- effect: allow
resource: read_ticket
- effect: review # review == defer (needs human approval)
resource: refund_chargeHow it works
The whole policy is two rules plus a default:
apiVersion: raxit.security/v1
agent: approval-bot
default: block
rules:
- effect: allow
resource: read_ticket
- effect: review # review == defer (needs human approval)
resource: refund_chargedefault: block— anything not listed is denied. The agent gets exactly what the policy names, nothing ambient.effect: review— the defer verb. Whenrefund_chargematches,decide()returnseffect='defer'with reasondefer.policyand a one-time denial token (dt_…). The tool has not run; the token is the handle a human uses to resolve the suspension.- The operator is a separate principal. Approving requires the daemon's operator Bearer
token (
RAXIT_APPROVALS_TOKENhere;raxit approvalsreads a local 0600 token file automatically). The agent client is constructed without it — an agent cannot list or approve its own pending requests. - Resumption is explicit and bound. The agent re-issues the same call with
approval_token=<the denial token>. The engine matches the now-approved record (which pins the resource and anargs_hashof the original arguments) and resolvespermitwith reasonpermit.approved. An approval for one call does not become a standing grant. - Audit before execute. The defer, the approval, and the resumed permit are each written to
the signed Decision Provenance log before returning. The finale,
operator.audit.verify(signatures=True, pubkeys=[...]), checks the hash chain and every Ed25519 signature:ok=True signatures_verified=True signed_count=3 count=3.
Blocking inline vs. explicit resume
The SDK can also block inline on a defer: decide() polls the approval once a second,
printing [raxit] waiting for human approval…, until a human resolves it or
RAXIT_DEFER_TIMEOUT elapses — that's the flow shown on
Approvals & warrants. This demo sets RAXIT_DEFER_TIMEOUT=0 and
drives the two-step resolution explicitly (decide → approve → re-decide) so the transcript is
race-free and CI-deterministic. The engine-side effect — a defer resolved by a human approval —
is identical either way.
What got deferred — and why
Nothing in this run was hard-denied; the point of the demo is the suspend-and-resume path. The two reason codes the run asserts on:
| Call | Decision | Reason code | Why |
|---|---|---|---|
refund_charge (first) | defer | defer.policy | The effect: review rule matched — the call needs a human before it may run. |
refund_charge (resumed) | permit | permit.approved | The echoed approval token resolved to an approved record for the same resource and args. |
The paths not taken here still fail closed: if the operator had run
raxit approvals deny dt_f99b7cbf2418437d, the resumed call would resolve to
deny.approval_denied; if nobody had acted before the deadline, an inline-blocking agent would
see deny.approval_expired and a [raxit BLOCKED] message instead of a result. See
Approvals & warrants for both terminal shapes.
Related
- Approvals & warrants — the inline blocking flow, the
raxit approvalsCLI, timeouts, and how warrants skip the per-call wait. - Permit / defer / deny / modify / step-up — the full decision vocabulary and the Cedar resolution chain behind it.
- Audit — the hash-chained, signed Decision Provenance log this demo verifies at the end.
Demo: lethal trifecta
A permitted PII read taints the session; a later egress-allowlisted send is denied with deny.taint_trifecta — a real, executed demo.
Demo: automatic PII redaction
A send_email carrying a raw card number is rewritten in-flight: the engine returns MODIFY with redacted args, the tool runs on those, and the PII never leaves — a real, executed demo.

