Skip to main content
Status: Developer preview.
When a run reaches an action your policy gates — a destructive tool call, a spend over a threshold, a sensitive category — it parks and emits an approval.required event instead of executing. Your job is to show a person exactly what’s about to happen and send their decision back. This guide covers the full loop for both execution shapes: mode:"turn"’s synchronous SSE stream and mode:"task"’s durable run.

The golden rule

Render the canonical payload exactly. The summary field on approval.required is the action, described in full — not a hint for you to write your own copy from. What the person approves must be exactly what executes, byte for byte. Never:
  • Truncate summary to fit a card layout — use a scrollable or expandable container instead.
  • Paraphrase, translate, “clean up,” or reformat the wording.
  • Reorder or drop parts of it to make it more presentable.
  • Reuse a previous action’s rendering for a new approvalId — always re-render from the event you just received.
action_digest binds a decision to one exact action. If your UI shows something other than what the digest was minted for, the thing the person approved and the thing that runs are no longer the same — and the entire point of the gate is defeated. This is the same render == record == gate-match invariant every approval surface in Praxa holds to; the public API is not an exception to it.

1. Listen for approval.required

Events arrive on whichever stream carries your run’s progress: the response body of POST /v1/execute for mode:"turn", or GET /v1/runs/:id/events (resumable across reconnects via Last-Event-ID) for mode:"task". Both surface the same event shape:

2. Render the payload and the expiry

  • Show summary verbatim in a scrollable or expandable container — never clip it silently, and never run it through your own summarizer.
  • If expiresAt is present, show a live countdown and disable the decision controls once it passes. The server is the real enforcer (see step 5), but don’t invite a person to click into a stale approval.
  • If expiresAt is absent, still show a clearly pending state; don’t imply there’s no time pressure just because you can’t render a countdown.

3. Capture a decision

Two explicit controls — Approve and Deny — with no default selection and no “approve all.” Never auto-approve based on tool name, amount, or history: a policy that wants that already has auto available as a decision mode on the server, upstream of this UI ever rendering at all. If you’re seeing approval.required, a human decision is exactly what the policy requires.

4. POST the decision back

decision is "approved" or "denied" — nothing else. action_digest must be the exact string from the event; a digest that doesn’t match what the run is currently waiting on comes back as a conflict problem rather than a silent no-op.

5. Handle expired and denied outcomes

  • Expired. If nobody decides before expiresAt, the run fails that step with a request.failed event whose problem.code is "approval_timed_out". Stop showing the decision controls and surface the timeout — don’t let a person approve into a run that has already moved on.
  • Denied. A "denied" decision guarantees the gated action never executes. It does not mean the interaction is over: keep listening to the run’s events (or webhooks) for what happens next — completion, failure, or in some cases a new approval.required for an adjusted action — rather than treating “denied” as itself a terminal UI state.
  • Late decisions. A decision posted after the run has already resolved that approval (expired, already answered, or the run was cancelled) is rejected, not silently dropped. Always check the response before assuming your click landed.

6. Put it together: a compact React example

Plain React — no UI kit, no framework assumptions. It opens the run’s event stream, renders the pending approval exactly as received, and posts the decision.

Next steps

  • If you’re forwarding progress to a server instead of holding a stream open, see receive-webhooks.mdapproval.required deliveries follow the same rule: render summary exactly.
  • Migrating from a direct OpenAI/Anthropic integration? migrate-from-provider-apis.md covers what replaces your hand-rolled approval hooks.