Installation
npx shadcn@latest add @pixeldosa/ai-approval-gateRequires `cn` at `@/lib/utils`. Installing this pulls in AI Action Toolbar, AI Context Surface and Confidence Meter, which it composes. Phrase `action` as the consequence rather than the tool call — the whole component is worthless if the user cannot evaluate what they are approving.
Usage
"use client";
import { AIApprovalGate } from "@/components/ui/ai-approval-gate";
<AIApprovalGate
action="Send the Q3 reconciliation summary to 243 contacts"
risk="high"
reversible={false}
impact={[
{ label: "Recipients", value: "243" },
{ label: "Attachments", value: "2" },
]}
confidence="medium"
explanation="Three invoices share a billing contact, which accounts for the warning."
onApprove={() => resume("approve")}
onReject={() => resume("reject")}
onEdit={() => openDraft()}
/>
Phrase action as the consequence, not the tool call. "Send the Q3 summary to 243
contacts" is reviewable; "Execute send_email" is not, and the whole component is
worthless if the user can't evaluate what they're approving.
Features
reversibleis required, not optional. Whether something can be taken back is the single most decision-relevant fact on the surface, and almost nothing surfaces it. It renders as a plain sentence — an icon is the wrong weight for the most consequential thing present.- The impact list is the scope. An approval without one is a yes/no question with the information removed. "243 recipients" is what makes it reviewable, in tabular figures so numbers align.
- High risk changes the Approve button, not just a badge. A dangerous action must never look identical to a harmless confirmation.
- Composes the trust primitives rather than re-deriving them — Confidence Meter, AI Context Surface, and AI Action Toolbar. The decision strip inherits real ARIA toolbar semantics, roving tabindex, and busy handling for free.
- It holds perfectly still. Your turn. Pair with
AgentPresence state="awaitingApproval".
Variants
High risk, irreversible
Approve carries the destructive treatment. The border shifts. The consequence is stated without hedging.
Delete 1,284 archived records from the production ledger
- Records
- 1,284
- Table
- ledger_archive
This cannot be undone.
Needs review, reversible
The default weight. Undo exists, so the surface doesn't shout.
Apply the suggested category to 18 uncategorised transactions
- Transactions
- 18
This can be undone afterwards.
Low risk
Add a note to the reconciliation thread
This can be undone afterwards.
With reasoning
Composed AI Context Surface — collapsed by default, because provenance should be
available rather than forced into view.
Send the Q3 reconciliation summary to 243 contacts
- Recipients
- 243
- Attachments
- 2
This cannot be undone.
Three invoices share a billing contact, which accounts for the duplicate-payment warning.
- 1.invoices/2026-q3.csv5 flagged rows, 3 sharing a contact.
Examples
Wiring to a LangGraph interrupt — the graph pauses on a checkpoint, you resume with the decision:
{interrupt ? (
<AIApprovalGate
action={interrupt.value.description}
risk={interrupt.value.risk}
reversible={interrupt.value.reversible}
impact={interrupt.value.impact}
onApprove={() => resume({ type: "accept" })}
onReject={() => resume({ type: "reject" })}
onEdit={() => resume({ type: "edit" })}
/>
) : null}
Omit onEdit when the payload genuinely can't be changed before it runs — offering an
edit that does nothing is worse than not offering one:
<AIApprovalGate action="Rotate the API signing key" risk="high" reversible={false}
onApprove={rotate} onReject={cancel} />
Reflecting a request in flight — busy is caller-owned, as everywhere else here:
<AIApprovalGate {...props} busy={pending} onApprove={approve} onReject={reject} />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
action | string | — | Required. The consequence, not the tool call. |
reversible | boolean | — | Required. Rendered as a plain statement. |
risk | "low" | "medium" | "high" | "medium" | high makes Approve destructive. |
impact | { label, value }[] | — | The blast radius as countable facts. |
confidence | "low" | "medium" | "high" | — | Composed Confidence Meter. |
provenance | string | — | Source caption beside the confidence. |
explanation, sources | — | — | Composed AI Context Surface. |
onApprove / onReject | () => void | — | Required. |
onEdit | () => void | — | Only when the payload is genuinely editable. |
busy | "approve" | "reject" | null | — | Caller-owned in-flight state. |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level, so the component fits your page's outline. |
Accessibility
- A labelled
<section>, with the action as its accessible name. - The decision strip is a real ARIA toolbar — one tab stop, arrow-key navigation,
disabled actions skipped — inherited from
AI Action Toolbar. - Impact facts are a
<dl>, so the label/value relationship survives without visual layout. - Risk is carried by a text label as well as colour; irreversibility is a sentence, not an icon alone.
- "Waiting for your approval" is announced through an
aria-liveregion.
When not to use
Disambiguating between options the agent can't choose between is Agent Ask — that's
"which did you mean?", where this is "may I do this?". Reviewing a text rewrite hunk by
hunk is Diff Accept. And don't gate low-risk reversible actions; friction where it
isn't warranted trains people to approve without reading.
why it's built this way
Engineering Notes
This is the payoff component for the system's trust thesis, and it is deliberately a composition rather than a new implementation: Confidence Meter, AI Context Surface and AI Action Toolbar are all consumed as-is, which is the 'compose existing trust primitives' rule (DESIGN.md §3.6) applied at the scale it was written for. Composing the toolbar rather than hand-rolling three buttons also means the decision strip inherits the real ARIA toolbar pattern — roving tabindex, arrow navigation, disabled-action skipping, busy handling — none of which a bespoke approval widget would have got right. Building it surfaced a genuine gap in that toolbar: it had no `destructive` intent, because nothing had needed one until an approval could be dangerous. Adding it there rather than special-casing here is the correct direction, since the house rule that a destructive action must never look identical to a harmless primary one applies system-wide. Two content decisions carry most of the value. `reversible` is a required prop rather than an optional flag: whether something can be undone determines how carefully the rest of the surface deserves to be read, and it renders as a plain sentence ('This cannot be undone') rather than an icon, because an icon is exactly the wrong weight for the most consequential fact present. The `impact` list exists because an approval without a scope is a yes/no question with the information removed — '243 recipients' is what makes the decision reviewable, and it uses tabular figures so the numbers align down the column.
how it moves, and when it doesn't
Motion Notes
Deliberately still. This is a user-turn surface under the turn-taking grammar (DESIGN.md §3a): motion means the machine is busy, and animating an approval gate would put it in the same visual register as the agent's own working states — precisely backwards, since nothing here proceeds until the user acts. The only motion is inherited: the composed AI Action Toolbar's colour transitions on hover and focus at `--pd-duration-instant`, and its busy spinner while an approve or reject is in flight, which maps to a real request rather than decorating a wait. AI Context Surface brings its own grid-template-rows disclosure transition when expanded. Nothing animates on entrance — that belongs to whatever surface reveals this gate, the same rule Confidence Meter and AI Action Toolbar hold. Under prefers-reduced-motion every inherited transition drops to instant and the component loses nothing, because it never carried information in movement to begin with.