Approvals (UI surface)
The Approvals page is where a human reviews and decides every
require_approval decision the gate returns. It lives at
/control-center/approvals (sidebar badge counts pending requests)
and is gated by the approvals plan feature — Growth and above.
This page covers the UI surface — terminal-feed rows, the friction-level approve flow, the click-to-Dialog detail panel, and the history tab. The wire contract (action_digest, typed predicates, plan-tier gating) lives in Human approval. Programmatic decision-making (REST endpoints, idempotency, retry semantics) is at the bottom of this page; the API reference is in HTTP API → approvals.
Page layout — terminal feed
The pending queue renders as a terminal feed: hairline-divided rows in the spirit of the audit log + terminal-window vocabulary, not bordered cards. Each row reads as a continuous log line; the operator's eye locks onto the icon-prefix marker before parsing the rest of the row.
Status prefix markers
The first character of every row is a marker that encodes status and tone:
| Marker | Tone | Status |
|---|---|---|
● |
state-block | pending |
✓ |
state-allow | approved (history tab) |
✗ |
state-flag | denied (history tab) |
⌧ |
fg-muted | expired / consumed (history tab) |
Row anatomy
From left to right:
- Prefix marker + workflow name + actor label ("requested by X").
- Hero amount — for money-kind approvals, the spend line is back on the row (reverted to inline from the dialog-only placement) with the ▲ N× above $X limit relationship encoder so the operator sees both the value and why it's over the limit in one glance. Tabular-nums at 28px semibold.
- Why this needs approval — the rule label, deep-linkable to the rule's config page.
- Inline live countdown — a colour-shifting bar + pipe +
tabular
mm:sslabel that shrinks as the review window runs out. Colour flips green → amber → coral at 40% / 15% of the remaining window. - Action button(s) — see below.
For tool-call approvals (money kind = tool_call), the hero
amount is replaced by the operator-approved tool name + the raw
parameter bag, so the operator sees exactly what the SDK is about
to run. The action_digest is the tamper-evident binding, not a
display artefact — the dashboard shows the bag verbatim, never
reconstructed from the digest.
When the SDK forwarded tool_class="mcp" annotations, the row
also renders a class badge (MCP tool / builtin / custom /
unknown) plus a chip row for destructive, read-only,
open-world (each chip shows yes / no / unknown).
Friction-level approve flow
The action button label encodes the friction level — operators never fire an action without seeing the value they are approving:
- Low risk → single-click
[ approve ]. - Medium risk →
[ approve ]→[ type 499.00 to confirm ]. - High risk →
[ approve ]→[ type 1,000.00 ]→[ type refund_customer to confirm ].
The amount being approved is surfaced inside the button label itself, not only in the confirmation step. The deny path is a single click on every risk level — see the human-approval page for why deny is unconditional.
Click-to-Dialog
Clicking anywhere on a row (outside the action button) opens a Dialog with the full detail panel:
- Hero summary (amount / tool name + parameter bag).
- Why this needs approval — the matched rule's human-readable
predicate (
amount ≥ $50 USD,ANY(amount ≥ 5000, region IN [EU,US])). - Technical details accordion — open by default after
2026-08-31, because the closed chevron alone failed to signal
that the rule_id / digest / execution_id rows lived behind the
disclosure. Rows: Action fingerprint, Execution ID, Rule +
rule label, Tool patterns, Per-call threshold, Rule priority
(lower = higher), Review window, Trust level chip
(
typed impact/LLM-cost only), Rule created, and the rendered Action predicate.
The Dialog intentionally has no Approve / Deny controls — the friction-level flow lives on the row, and the Dialog is for review, not decision.
History tab
The history view is the same page at ?tab=history — a tab strip
in the page header switches between Pending (default) and
History. Old /approvals/history URLs redirect to
?tab=history so existing links keep working.
History rows are filtered to the last 30 days by default and
support the same search / status filters as the pending feed.
Resolved rows are grouped by outcome (approved, denied,
expired, consumed) with the same prefix-marker vocabulary
(✓ / ✗ / ⌧) so an operator can scan a week of decisions in one
glance.
Bulk toolbar
A hairline-divided toolbar above the feed exposes Approve all and Deny all when more than one row is selected. Both bulk actions require the same friction-level confirmations as the single-row flow.
Page chrome
- Plan gate — the page itself renders a
TierGateupgrade prompt for plans without theapprovalsfeature. The sidebar link is also hidden for those plans. - SSE live update — every new approval request lands in the feed within a few seconds without refresh; the badge count in the sidebar updates in lockstep.
- Audit trail — every approve / deny decision is recorded in
the audit log (
Audit logunder Governance) with the decided_by UUID, decided_at timestamp, and the operator label (orSystemfor server-side expiry).
Programmatic approval
For CI bots and on-call rotations, the same endpoints are exposed via REST and the page chrome has no opinion:
curl -X POST "https://api.nullrun.io/api/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer ***"
# Or deny explicitly
curl -X POST "https://api.nullrun.io/api/v1/orgs/$ORG_ID/approvals/$APPROVAL_ID/deny" \
-H "Authorization: Bearer ***"
The full endpoint catalog — idempotency rules (409
approval_already_decided), the post-approval /execute
binding, and the digest-mismatch drift cases (NR-A013 /
NR-A014) — is in
HTTP API → approvals.
Where to read next
- Human approval — wire contract, action digest, typed predicates, plan-tier gating.
- HTTP API → approvals — REST endpoints for programmatic decision-making.
- Audit log — every decision
lands in the hash-chained audit log; the operator +
decided_byUUID +decided_atare searchable.