Skip to content

Docs

Components

ApprovalCard

Human-in-the-loop approval: what will run, how risky it is, and deny with a reason.

Example

Approval required

High risk

Run command

Installs a package from the npm registry and updates package.json and pnpm-lock.yaml.

~/acme/chat-app
pnpm add @upstash/ratelimit
Calls a lasting decision covers

Remembered for run_command calls where:

* matches any text but shell operators (; & | > < and backticks); ** matches anything.

Interactive, and the same on the components page. Try the keyboard below on it.

Installation

npm i signoff-ui ai
import { 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';

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

toolNamerequired
string

Name of the tool or action that needs approval.

title
ReactNode

Heading. Defaults to the humanized tool name.

description
ReactNode

Why the agent wants to do this, e.g. the SDK's approval.requestReason.

input
unknown

Arguments the action will run with.

risk
RiskLevel

How risky the action is, shown as a badge. Without it the card shows no risk level.

preview
ReactNode

Custom arguments preview. Defaults to a command line for { command } inputs, else JSON. Your content, like AgentMessage's renderTool.

status
ApprovalStatusDefault 'pending'
'pending' | 'approved' | 'denied'

Approve and deny fire once: further presses (a double click, Y then N) are ignored until status changes, the promise the handler returned settles, or the handler throws.

reason
string

Reason recorded with the decision, shown once resolved.

automatic
booleanDefault false

A 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".

decision
ApprovalDecision

How the resolved decision was made, to say so: allow-session reads "Approved for this session", deny-always "Always denied", and so on.

decidedByRule
string

The rule that decided, in words, shown once resolved: "Allowed by your rule: …".

decisions
readonly ApprovalDecision[]Default DEFAULT_DECISIONS

The choices to offer, from allow-once, allow-session, allow-always, deny-once and deny-always. Default: Approve and Deny, once. Session and always decisions come to onDecide with 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, onApprove and onDeny are not called. May return a promise.

onApprove
() => void | PromiseLike<void>

Fires once per decision: see status. May return a promise. Not called with onDecide.

onDeny
(reason?: string) => void | PromiseLike<void>

Fires once per decision: see status. May return a promise. Not called with onDecide.

editable
booleanDefault false

Let 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.

ruleArgs
Record<string, string>

The argument patterns a session or always rule starts with. Default: from the input.

ruleScope
booleanDefault true

Show which calls a session or always decision covers, editable. Pass false when the agent keeps what "always" means, as with ACP's allow_always: the card then shows no argument patterns, and the decision comes without args.

shortcuts
booleanDefault true

Y / N shortcuts while focus is inside the card (and S, A, Shift+N when offered).

globalShortcut
booleanDefault false

Also approve with ⌘/Ctrl+Enter from anywhere on the page while pending.

autoFocus
booleanDefault false

Move focus to the card when it mounts in the pending state.

allowReason
booleanDefault true

Offer an optional free-text reason when denying.

reasonPlaceholder
string

Placeholder of the reason field. Default "What should the agent do instead?" (labels.approvalCard.reasonPlaceholder).

approveLabel
string

The approve button's text. Default "Approve" (labels.approvalCard.approve).

denyLabel
string

The deny button's text. Default "Deny" (labels.approvalCard.deny).

headingLevel
HeadingLevelDefault 3

Heading level for the title, to fit your document outline.

labels
SignoffLabelsInput

Words 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

partrequired
ToolPart
onRespondrequired
(response: ToolApprovalResponse) => void | PromiseLike<void>

Matches useChat().addToolApprovalResponse, so you can pass it directly.

meta
ToolMeta
policy
ApprovalPolicy

Approval rules from useApprovalPolicy. The card then offers once, this session and always (unless decisions says otherwise), answers a request a rule already decides without asking, and records every decision.

onEditInput
(toolCallId: string, input: unknown) => void

Apply 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)). With useAgUiAgent: its editInput.

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

partsrequired
readonly ToolPart[]

Tool parts; those waiting on a person count.

onRespondrequired
(response: ToolApprovalResponse) => void | PromiseLike<void>
policy
ApprovalPolicy
tools
Record<string, ToolMeta>
min
numberDefault 2

Shown from this many waiting approvals.

labels
SignoffLabelsInput

Words 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.

decisionrequired
ApprovalDecision
'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always'
reason
string

The denial's reason, when the person gave one.

input
unknown

The arguments to run with, when the person edited them (editable).

args
Record<string, string>

For a session or always decision: the argument patterns its rule covers, or undefined for 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

idrequired
string
approvedrequired
boolean
reason
string

RiskLevel

type RiskLevel = 'low' | 'medium' | 'high' | 'critical';

ApprovalStatus

type ApprovalStatus = 'pending' | 'approved' | 'denied';

Accessibility

Keyboard
KeysAction
YApprove, once
SApprove for this session, when offered
AAlways approve, when offered
NDeny
⇧NAlways deny, when offered
⌘/Ctrl↵Approve (page-wide with globalShortcut)
EscClose 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.
  • critical actions 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. autoFocus puts focus on a card that arrives pending.
  • Each pending approval sends one decision: a double click, or Y then N, is ignored until status changes 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 risk the card shows none rather than guessing. headingLevel fits 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-statuson signoff-approval-card'pending' | 'approved' | 'denied'data-riskon signoff-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
app/globals.css
/* 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);
}