Skip to content

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-reported estimated_tokens.
  • Phase 1 (action_predicate) — a typed condition over a structured BusinessImpact extracted 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:

  1. money_amount — per-call monetary threshold.

    {
      "kind": "money_amount",
      "direction": "outflow",
      "operator": "gt",
      "threshold_minor": 5000,
      "currency": "USD"
    }
    

  2. 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):

  • dfc96387ca539b7130caebe705e042f2e34e52ab44352ae5e527bcef64f0df27 for Money
  • 9975a8b75a436fb78b9d141b9e0c0a90838c1243d78119b304ae6ed0526966a6 for ToolCall

Approval resume flow (Разрыв 1c)

The complete flow, end-to-end:

  1. SDK sends /api/v1/gate with the live BusinessImpact. Gate evaluates rules. Match fires.
  2. Gate inserts approvals row with business_impact, action_digest, expires_at = NOW() + expires_in_seconds (server-side, clamped [1, 3600] s), and emits approval_required alert to configured channels.
  3. Gate returns decision = "require_approval" plus approval_id, approval_timeout_seconds, approval_expires_at.
  4. SDK parks on threading.Event.wait(timeout=approval_timeout_seconds).
  5. Operator clicks Approve / Deny in the dashboard (or auto-deny timer fires).
  6. Backend publishes ApprovalResolved event on the WS push channel.
  7. 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 limit
  • ToolCall: tool:stripe.charge + raw params key/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:

  1. The agent's goal (what it was trying to accomplish)
  2. The tool it wants to call (e.g. send_email)
  3. The typed impact summary (Phase 1) or the projected cost (Phase 0)
  4. The action digest (the SHA-256 binding)
  5. 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 the action_digest against the live payload and refuses on mismatch (returns DigestMismatch).
  • Deny — the gate rejects, the agent sees WorkflowKilledInterrupt (a BaseException). 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:

approve_via_api.sh
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.drop in 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