Human approval
Some operations need a human to click Approve before they run. Sending an email to a customer, moving money, deleting a record — operations where you want a paper trail and a conscious decision.
In the dashboard, pending approvals live under Approvals in the sidebar. When the agent hits an approval rule, the call pauses. The agent stays paused until a human clicks Approve or Deny, or the approval times out.
Approval rules — separate from ToolBlock
Approval rules are not ToolBlock policies with an action =
require_approval field. They are a separate entity on the backend
(approval_rules table) with these fields:
| Field | Type | Purpose |
|---|---|---|
tool_patterns |
TEXT[] |
Glob patterns matching the tool name |
per_call_threshold_cents |
BIGINT NULLABLE |
Phase 0 projected-cost threshold |
action_predicate |
JSONB NULLABLE |
Phase 1 typed BusinessImpact predicate |
priority, expires_in_seconds, action_label |
scalar | Operational metadata |
When an SDK calls a tool that matches an approval rule, the gate
returns decision = "require_approval" and parks the SDK on a
threading.Event until the operator clicks Approve / Deny.
Phase 0 vs Phase 1 rules
Two predicate kinds can fire the same approval rule:
- Phase 0 (
per_call_threshold_cents) — projected execution cost in cents, evaluated against the SDK-reportedestimated_tokens. - Phase 1 (
action_predicate) — a typed condition over a structuredBusinessImpactextracted from the live function call.
When both are set, the rule fires only when both pass. Either
may be None, in which case it does not contribute. A rule with
both None matches every call.
Phase 1 typed predicates (Разрыв 2, 2026-07-27)
Two predicate kinds are supported on action_predicate:
-
money_amount— per-call monetary threshold. -
tool_parameters(Разрыв 2) — DNF over up to 5 named parameters with Equals / OneOf / NumericRange / Regex / Exists matchers.
{
"kind": "tool_parameters",
"trigger_logic": "any",
"conditions": [
{"param_name": "refund_amount", "matcher": {"kind": "numeric_range", "min": 500, "max": null}},
{"param_name": "recipient", "matcher": {"kind": "regex", "pattern": "^(?!internal@).*"}}
]
}
The tool_parameters predicate rides on the SDK's
ToolParamsExtractor. With the default include_all=True mode
(nullrun-sdk-python/src/nullrun/extractor.py), every kwarg of
@sensitive-decorated functions flows into the predicate bag
(positional args are dropped; f64 / set / custom objects are
filtered; PII-masked sentinels like "***" for password / token /
api_key keys are stripped before wire).
action_digest — tamper-evident binding
When the gate fires an approval rule with a typed BusinessImpact,
it computes a SHA-256 digest of the canonical-JSON
{"kind":"money_amount", direction, operator, threshold_minor,
currency, extractor_id, extractor_version} (Money variant) or the
ToolCallParams envelope (ToolCall variant). The digest is stored
on the approvals row.
After the operator clicks Approve, the SDK's post-approval /execute
re-check sends the live business_impact and action_digest back
to the gate. The grant consume is atomic:
UPDATE approvals
SET consumed_at = NOW()
WHERE id = $1
AND execution_id = $2
AND status = 'APPROVED'
AND consumed_at IS NULL
AND expires_at > NOW()
AND ($3::text IS NULL OR action_digest = $3)
RETURNING id
Two concurrent re-checks with different impacts serialize through
the row lock. The discriminated ActionGrantOutcome enum surfaces
Allow / DigestMismatch / NotFound / Expired / ReplayRejected.
Cross-repo golden hexes (must hold against either implementation):
dfc96387ca539b7130caebe705e042f2e34e52ab44352ae5e527bcef64f0df27forMoney9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6forToolCall
Approval resume flow (Разрыв 1c)
The complete flow, end-to-end:
- SDK sends
/api/v1/gatewith the liveBusinessImpact. Gate evaluates rules. Match fires. - Gate inserts
approvalsrow withbusiness_impact,action_digest,expires_at = NOW() + expires_in_seconds(server-side, clamped[1, 3600]s), and emitsapproval_requiredalert to configured channels. - Gate returns
decision = "require_approval"plusapproval_id,approval_timeout_seconds,approval_expires_at. - SDK parks on
threading.Event.wait(timeout=approval_timeout_seconds). - Operator clicks Approve / Deny in the dashboard (or auto-deny timer fires).
- Backend publishes
ApprovalResolvedevent on the WS push channel. - SDK wakes the parked thread; agent resumes with the operator's outcome.
If the WS push is silent for approval_timeout_seconds, the SDK
fails CLOSED: WorkflowKilledInterrupt is raised and the agent
dies. A silent network must not silently approve a privileged
action. There is no /status HTTP-poll fallback for approvals —
deliberate, consistent with §34a "Hard always, no hidden autonomy".
What you see in the dashboard
The Approvals page lists every pending, approved, denied, and expired request. Each row shows:
- The tool the agent wanted to call (e.g.
send_email) - The workflow that requested it
- The action digest (first 16 hex chars — full digest in tooltip)
- For typed predicates: the rendered impact summary
Money:Spend $499.00 USD · 4.99× above the $100.00 limitToolCall:tool:stripe.charge+ rawparamskey/value block- How long ago it was created
- How long until
expires_at
Click an approval to see the full context — what the agent was trying to do, the tool's arguments, and any notes you attached.
How to approve or deny
In the Approvals page, click an open request. You see:
- The agent's goal (what it was trying to accomplish)
- The tool it wants to call (e.g.
send_email) - The typed impact summary (Phase 1) or the projected cost (Phase 0)
- The action digest (the SHA-256 binding)
- How long the approval has been pending
Two buttons:
- Approve — the gate releases the reservation, the agent's
call resumes. On
/execute, the gate re-checks theaction_digestagainst the live payload and refuses on mismatch (returnsDigestMismatch). - Deny — the gate rejects, the agent sees
WorkflowKilledInterrupt(aBaseException). The agent can catch it and clean up; most agents don't.
If you don't click either within the approval's expires_at window,
the request expires. The SDK raises WorkflowKilledInterrupt
after approval_timeout_seconds (server-clamped [1, 3600] s).
The agent can retry or give up.
Notification channels
When an approval is created, the gateway notifies every active channel configured on your org:
- Email — via the Brevo SMTP sub-processor.
- Slack — uses your org's installed Slack OAuth.
- Webhook — generic HTTPS POST with HMAC-SHA256 signature
(
X-NullRun-Signature, 5-minute clock-skew tolerance, 10-minute nonce replay defence).
Disable a channel per-user under Settings → Notifications, or per-channel under Settings → Notification channels.
Programmatic approval (for automations)
The dashboard is for humans. If you want a CI bot or on-call rotation to approve requests programmatically, the same endpoints are exposed via REST:
curl -X POST "https://api.nullrun.io/api/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer ***"
The endpoint is idempotent — calling approve on an already-approved
request returns 409 approval_already_decided. Use this in your
incident-response automation: an approval surfaces in Slack, your
bot detects the risk_level = high, and approves or denies based on
your runbook.
When to use approval instead of blocking
Approval makes sense when:
- The operation is sensitive but you want the agent to be able to do it under human review (sending customer emails, creating invoices, deploying builds).
- The blast radius is bounded (a single email vs. an entire database drop).
- You have someone on-call who can review within minutes.
Blocking (not approval) makes more sense when:
- The operation is never legitimate (
db.dropin a read-only workflow). - The blast radius is unbounded (admin operations, mass deletes).
- No one is on-call to review approvals in time.
Approval is a feature, not a default. Most teams should default to blocking and switch specific patterns to approval as the need arises.
What's logged
Every approval decision is in Governance → Audit log. You can filter by:
- Approver (which user clicked Approve/Deny)
- Workflow
- Tool name
- Time window
- Outcome (approved / denied / expired)
The audit log is the source of truth for "who approved this?" —
both for compliance and for incident review. The approvals.action_digest
column is the immutable anchor that proves the operator approved
the exact payload the SDK sent on /gate (not "any refund" —
the exact amount and arguments).
See also
- Tool policies —
ToolBlockrules (norequire_approvalaction; that's a separate entity) - Sensitive tools — when blocking is enough
- Workflows → operator controls — Pause / Kill work the same way as approval
- API keys — how to mint a key bound to a workflow