Components
ApprovalCard
Human-in-the-loop approval: what will run, how risky it is, and deny with a reason.
Example
Approval required
High riskRun command
Installs a package from the npm registry and updates package.json and pnpm-lock.yaml.
Interactive, and the same on the components page. Try the keyboard below on it.
Installation
npm i signoff-ui aiimport { ApprovalCard, ToolApprovalCard, ToolApprovalBatch } from 'signoff-ui';
// Once per app (with Tailwind v4: @import 'signoff-ui/tailwind.css'; in your CSS)
import 'signoff-ui/styles.css';npx shadcn@latest add https://signoff-ui.vercel.app/r/approval-card.jsonimport { ApprovalCard, ToolApprovalCard, ToolApprovalBatch } from '@/components/signoff-ui/approval-card';The styles are once per app; Getting started has the Tailwind v4 and plain CSS options.
Usage
// Bound to an AI SDK tool part in the approval flow, with rules from useApprovalPolicy:
<ToolApprovalCard
part={part}
onRespond={addToolApprovalResponse}
policy={policy}
// Edit the arguments before approving (AI SDK 7: the call runs with the edited input).
onEditInput={(id, input) => setMessages((m) => setToolInput(m, id, input))}
risk="high"
/>
// Or on its own, with every choice:
<ApprovalCard
toolName="run_command"
input={{ command: 'pnpm add @upstash/ratelimit' }}
risk="high"
decisions={['allow-once', 'allow-session', 'allow-always', 'deny-once', 'deny-always']}
editable
onDecide={({ decision, reason, input, args }) => answer(decision, { reason, input, args })}
/>How it works
Human-in-the-loop approval with a command or argument preview, a risk level when you give one (without risk, no badge rather than a guess), and deny with feedback. Y and N work while focus is in the card; ⌘ or Ctrl + Enter approves, optionally page-wide. critical actions need a second, confirming press.
Each pending approval sends one decision: a double click, or Y then N, is ignored until status changes, the handler's promise settles or the handler throws. ToolApprovalCard binds the card to a tool part and to addToolApprovalResponse, including the denial reason, and renders nothing for calls that need no approval.
Decisions that a toolApproval rule makes on its own (approval.isAutomatic) never prompt or take focus, and read "Auto-approved" or "Blocked by policy" rather than as a person's decision. A custom preview is your content: with styles.css, the kit's rules don't reach into it.
Once, for the session or always. decisions sets the choices: allow-once, allow-session, allow-always, deny-once and deny-always. The default is Approve and Deny, once. A lasting choice says what it will cover: the tool, and argument patterns the person can edit. For a command, the pattern starts at its program and subcommand (npm test*); for other calls, it starts at each argument as it is. "Any arguments" covers every call of the tool. onDecide receives { decision, reason, input, args }; when you pass it, onApprove and onDeny are not called. With a policy from useApprovalPolicy, ToolApprovalCard offers every choice, records the rule a person makes, and answers a call a rule already covers without showing a card. Once answered, that call reads "Allowed by your rule" and shows the rule.
Edit the arguments, then approve. With editable (on ApprovalCard) or onEditInput (on ToolApprovalCard), "Edit arguments" opens the arguments for editing. An object of strings, numbers and booleans gets a form; any other input gets JSON, which must parse before anything can be approved. The edited input goes out with the approval. ToolApprovalCard offers editing only with onEditInput, the function that applies the edit, so an edit is never dropped on its way to the agent. With AI SDK 7, onEditInput={(id, input) => setMessages((m) => setToolInput(m, id, input))} puts the edited input in the messages; the server runs the call with it after checking it against the tool's schema and its toolApproval again. With useAgUiAgent, pass its editInput, and the approval's resume payload carries input.
Approve all. ToolApprovalBatch answers every approval a run is waiting on at once, such as parallel calls; with a critical one among them it asks for a second press. AgentMessage shows it above its cards when two or more approvals are waiting.
API reference
Generated from the types the package ships, so it matches the version you install.
ApprovalCard
Human-in-the-loop approval for a pending agent action. Shows what will run,
how risky it is, and lets the user approve or deny by mouse or keyboard, once,
for the session or always, with the arguments edited first when editable.
Critical actions require a second confirming press.
The card is a group named by its title, not a landmark, so a run with many approvals doesn't
fill landmark navigation; pass role="region" to make it one.
Props
toolNamerequiredstringName of the tool or action that needs approval.
titleReactNodeHeading. Defaults to the humanized tool name.
descriptionReactNodeWhy the agent wants to do this, e.g. the SDK's
approval.requestReason.inputunknownArguments the action will run with.
riskRiskLevelHow risky the action is, shown as a badge. Without it the card shows no risk level.
previewReactNodeCustom arguments preview. Defaults to a command line for
{ command }inputs, else JSON. Your content, likeAgentMessage'srenderTool.statusApprovalStatusDefault'pending''pending' | 'approved' | 'denied'Approve and deny fire once: further presses (a double click, Y then N) are ignored until
statuschanges, the promise the handler returned settles, or the handler throws.reasonstringReason recorded with the decision, shown once resolved.
automaticbooleanDefaultfalseA policy, not a person, made the decision (the SDK's
approval.isAutomatic): once resolved, the card reads "Auto-approved" or "Blocked by policy" instead of "Approved" or "Denied".decisionApprovalDecisionHow the resolved decision was made, to say so:
allow-sessionreads "Approved for this session",deny-always"Always denied", and so on.decidedByRulestringThe rule that decided, in words, shown once resolved: "Allowed by your rule: …".
decisionsreadonly ApprovalDecision[]DefaultDEFAULT_DECISIONSThe choices to offer, from
allow-once,allow-session,allow-always,deny-onceanddeny-always. Default: Approve and Deny, once. Session and always decisions come toonDecidewith the arguments their rule covers.onDecide(choice: ApprovalChoice) => void | PromiseLike<void>Every decision with what goes with it: its scope, the reason, edited arguments and the rule's argument patterns. When you pass it,
onApproveandonDenyare not called. May return a promise.onApprove() => void | PromiseLike<void>Fires once per decision: see
status. May return a promise. Not called withonDecide.onDeny(reason?: string) => void | PromiseLike<void>Fires once per decision: see
status. May return a promise. Not called withonDecide.editablebooleanDefaultfalseLet the person edit the arguments before approving: a form for an object of strings, numbers and booleans, JSON otherwise. The edited input comes to
onDecide.ruleArgsRecord<string, string>The argument patterns a session or always rule starts with. Default: from the input.
ruleScopebooleanDefaulttrueShow which calls a session or always decision covers, editable. Pass
falsewhen the agent keeps what "always" means, as with ACP'sallow_always: the card then shows no argument patterns, and the decision comes withoutargs.shortcutsbooleanDefaulttrueY / N shortcuts while focus is inside the card (and S, A, Shift+N when offered).
globalShortcutbooleanDefaultfalseAlso approve with ⌘/Ctrl+Enter from anywhere on the page while pending.
autoFocusbooleanDefaultfalseMove focus to the card when it mounts in the pending state.
allowReasonbooleanDefaulttrueOffer an optional free-text reason when denying.
reasonPlaceholderstringPlaceholder of the reason field. Default "What should the agent do instead?" (
labels.approvalCard.reasonPlaceholder).approveLabelstringThe approve button's text. Default "Approve" (
labels.approvalCard.approve).denyLabelstringThe deny button's text. Default "Deny" (
labels.approvalCard.deny).headingLevelHeadingLevelDefault3Heading level for the title, to fit your document outline.
labelsSignoffLabelsInputWords to use instead of the English defaults: see
SignoffLabelsProvider.
Other props go to the root <section>: className (merged with tailwind-merge), id, aria-*, data-* and event handlers.
ToolApprovalCard
ApprovalCard bound to an AI SDK tool part in the approval flow. Renders nothing for parts without
one, or while a policy's automatic decision is still arriving: nobody needs to act on it.
Props
partrequiredToolPartonRespondrequired(response: ToolApprovalResponse) => void | PromiseLike<void>Matches
useChat().addToolApprovalResponse, so you can pass it directly.metaToolMetapolicyApprovalPolicyApproval rules from
useApprovalPolicy. The card then offers once, this session and always (unlessdecisionssays otherwise), answers a request a rule already decides without asking, and records every decision.onEditInput(toolCallId: string, input: unknown) => voidApply edited arguments before the approval goes out; the card offers editing only with it. With AI SDK 7:
(id, input) => setMessages((m) => setToolInput(m, id, input)). WithuseAgUiAgent: itseditInput.
Also takes every ApprovalCardProps prop above, except toolName, input, status, onApprove, onDeny, onDecide, reason, automatic, decision and decidedByRule.
ToolApprovalBatch
"Approve all" and "Deny all" for the approvals a run is waiting on together, such as parallel calls. Each is answered once, as Approve or Deny on its own card would; with a critical one among them, Approve all asks for a second press.
Props
partsrequiredreadonly ToolPart[]Tool parts; those waiting on a person count.
onRespondrequired(response: ToolApprovalResponse) => void | PromiseLike<void>policyApprovalPolicytoolsRecord<string, ToolMeta>minnumberDefault2Shown from this many waiting approvals.
labelsSignoffLabelsInputWords to use instead of the English defaults: see
SignoffLabelsProvider.
Other props go to the root <div>: className (merged with tailwind-merge), id, aria-*, data-* and event handlers.
ApprovalChoice
A decision as the card makes it: what was chosen, and what goes with it.
decisionrequiredApprovalDecision'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always'reasonstringThe denial's reason, when the person gave one.
inputunknownThe arguments to run with, when the person edited them (
editable).argsRecord<string, string>For a session or always decision: the argument patterns its rule covers, or
undefinedfor every call of the tool ("Any arguments").
ApprovalDecision
What a person can decide about a tool call, and for how long it holds.
type ApprovalDecision = 'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always';ToolApprovalResponse
idrequiredstringapprovedrequiredbooleanreasonstring
RiskLevel
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';ApprovalStatus
type ApprovalStatus = 'pending' | 'approved' | 'denied';Accessibility
| Keys | Action |
|---|---|
| Y | Approve, once |
| S | Approve for this session, when offered |
| A | Always approve, when offered |
| N | Deny |
| ⇧N | Always deny, when offered |
| ⌘/Ctrl↵ | Approve (page-wide with globalShortcut) |
| Esc | Close the reason field and return to the card |
- A group named by its title and described by its description and a visually hidden line that spells out the shortcuts on offer. It is not a landmark, so a run with many approvals doesn’t fill landmark navigation; pass
role="region"to make it one. - Every choice is a button with its key in
aria-keyshortcuts. Y, S, A, N and Shift+N only work while focus is inside the card, with no other modifier and not while typing, which keeps them within WCAG 2.1.4. - A lasting choice says what it will cover, in a labelled group: the tool, and a text field per argument pattern ("command matches"), or "Any arguments".
- Editing arguments opens a form with a labelled field per argument, or a JSON text area; JSON that does not parse is marked
aria-invalid, its error is the field’s description, and approving says "The arguments are not valid JSON." instead of approving. criticalactions need a second press of the same choice within four seconds, and say so: "Critical action. Press approve again to confirm." Approve all asks for one when any of the calls is critical.- Deciding announces the outcome ("Approved for this session") and moves focus to the card, so it isn’t lost when the buttons go away; the announcement then clears, so the decided card reads its outcome once.
autoFocusputs focus on a card that arrives pending. - Each pending approval sends one decision: a double click, or Y then N, is ignored until
statuschanges or the handler’s promise settles. A call a rule decides is answered without showing a card, and reads "Allowed by your rule" with the rule once it is. - The risk level is spelled out, not shown by color alone, and only when you give one: without
riskthe card shows none rather than guessing.headingLevelfits the title into your outline.
Theming
Every color, radius and font is a CSS variable, so the overrides in Theming apply, on :root or scoped to any element. These hooks are read from the component's markup.
- Slots (data-slot)
- signoff-approval-cardsignoff-approval-batch
- State attributes
- data-decision
'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always'data-statusonsignoff-approval-card'pending' | 'approved' | 'denied'data-riskonsignoff-approval-card'low' | 'medium' | 'high' | 'critical' - Tokens it uses
- --signoff-accent--signoff-accent-fg--signoff-bg--signoff-border--signoff-border-strong--signoff-fg--signoff-fg-muted--signoff-fg-subtle--signoff-font-mono--signoff-font-sans--signoff-hot--signoff-hot-fg--signoff-on-accent--signoff-on-hot--signoff-radius--signoff-ring--signoff-surface--signoff-surface-2--signoff-warn--signoff-warn-fg
/* Only this component, and only inside .settings-panel */
.settings-panel [data-slot='signoff-approval-card'] {
--signoff-radius: 4px;
}
[data-slot='signoff-approval-card'][data-status='pending'] {
outline: 1px solid var(--signoff-accent);
}