Approvals
Hold a risky call for a human decision, then approve or deny from the dashboard, a signed email link, or Slack.
A policy whose action is require_approval doesn't block the
call and doesn't let it through: it parks it as a pending approval and asks a
human. The SDK and framework adapters wait the hold out
transparently (they long-poll the hold's status), so to your agent the call just
takes longer and then resolves to the final allow or deny. The remote MCP
proxy waits a short window server-side; if the hold is still open it returns a
denial that tells the agent to retry once a human approves. A card preflight
that comes back review opens the same kind of hold (kind: "card" instead of
"tool_call").
An approved hold resolves to allow; a denied or expired hold resolves to
deny.
Hold timeout
Every hold gets a deadline from the workspace's Approval hold timeout
(Settings → Workspace, 1 to 1440 minutes, default 5 minutes). A hold
still undecided at the deadline auto-denies: it resolves as expired, the
held call is patched to deny, and approval.resolved fires with decision
deny and resolvedBy: "system". Approvals fail closed.
The Expiry reminder (on by default) re-sends the approve/deny prompt shortly before the deadline, with a lead of 60 seconds or 20% of the timeout, whichever is larger, so an auto-deny never lands unseen.
Ways to decide
Every channel resolves through the same vote-casting path, so dual control and deny-overrides behave identically wherever the click happens, and resolution is idempotent: the first verdict wins and later attempts see "already resolved".
Dashboard
The approvals inbox at Dashboard → Approvals lists open holds with their risk score and reason; approve or deny in one click.
Signed email links
Approvers get an email with a decide link (/approve/{token}). The token is an
HMAC-SHA256 over the approval id, workspace id, and an expiry, so it can't
be forged or pointed at a different approval, and it stops working after it
expires (1 hour, comfortably past the default hold timeout). The approve/deny choice is
made on the page, never encoded in the token or the URL, so an email
client prefetching the link can't decide anything.
No dashboard login is needed: the token is the authorization. The page posts to
POST /api/approvals/act; an invalid or expired token gets a 401, and a hold
that was already resolved gets a 409.
Slack
Connect Slack and each hold posts a message with Approve and Deny
buttons. Point your Slack app's Interactivity Request URL at
/api/slack/interactivity and set SLACK_SIGNING_SECRET (the endpoint answers
503 until it's configured). Slack's request signature is verified over the
raw body before anything is parsed; the button itself carries the same signed
approval token naming exactly one approval. After the click, the message is
replaced with a status line (the verdict, or the vote count while a second
approver is still needed), and the vote is recorded under the clicking Slack
user's identity.
Dual control
Set Dual control (two-person rule) to a minimum risk score (0 to 100, blank to disable). A hold whose risk is at or above the threshold when it opens requires two distinct approvers instead of one.
- Approve votes are deduplicated per approver, so one person clicking twice still counts once.
- Deny overrides: a single deny from any approver resolves the hold denied immediately, regardless of approve votes already cast.
- Email-link votes are cast under one shared
email-linkidentity, so the email channel contributes at most one approve vote to a two-person hold. Slack votes are per Slack user and dashboard votes are per member, so either can supply the second approver.
Escalation
Set Approval escalation (minutes, blank to disable) and a hold still pending
past the window notifies the second tier: every notification channel subscribed
to approval.escalated, plus an email to the workspace owners (a built-in
fallback that works with no channel configured; the original notice goes to
owners and admins). The window must be shorter than the hold timeout; an
escalation that would land at or after expiry never fires.
Escalation also emits the approval.escalated webhook event:
| Field | Meaning |
|---|---|
workspaceId, approvalId | Which hold escalated. |
kind | tool_call or card. |
subject | Tool name (tool call) or card subject. |
agentId | The agent whose call is held. |
risk | The hold's risk score. |
expiresAt | ISO deadline after which the hold auto-denies (null on a legacy hold). |
Webhook events
| Event | Fires when |
|---|---|
approval.pending | A hold opens and awaits an approver. |
approval.escalated | A hold passes the escalation window still undecided. |
approval.resolved | A hold is approved, denied, or expired (expiry delivers decision deny, resolvedBy: "system"). |
See Webhooks for signatures, retries, and replay.
Next steps
- Policies: author the
require_approvalrules that open holds. - Framework integrations: how adapters surface a hold to your agent.