# ApprovalCard

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

Source: https://signoff-ui.vercel.app/docs/components/approval-card

## Installation

From npm:

```bash
npm i signoff-ui ai
```

```tsx
import { ApprovalCard, ToolApprovalCard, ToolApprovalBatch } from 'signoff-ui';
import 'signoff-ui/styles.css'; // once per app, or @import 'signoff-ui/tailwind.css' with Tailwind v4
```

Or as source, with the shadcn CLI:

```bash
npx shadcn@latest add https://signoff-ui.vercel.app/r/approval-card.json
```

```tsx
import { ApprovalCard, ToolApprovalCard, ToolApprovalBatch } from '@/components/signoff-ui/approval-card';
```

## Usage

```tsx
// 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

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `toolName` (required) | `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` | `ApprovalStatus` ('pending' \| 'approved' \| 'denied') | `'pending'` | 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` | `boolean` | `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_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` | `boolean` | `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` | `boolean` | `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` | `boolean` | `true` | Y / N shortcuts while focus is inside the card (and S, A, Shift+N when offered). |
| `globalShortcut` | `boolean` | `false` | Also approve with ⌘/Ctrl+Enter from anywhere on the page while pending. |
| `autoFocus` | `boolean` | `false` | Move focus to the card when it mounts in the pending state. |
| `allowReason` | `boolean` | `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` | `HeadingLevel` | `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`, `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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `part` (required) | `ToolPart` |  |  |
| `onRespond` (required) | `(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, except `toolName`, `input`, `status`, `onApprove`, `onDeny`, `onDecide`, `reason`, `automatic`, `decision`, `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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `parts` (required) | `readonly ToolPart[]` |  | Tool parts; those waiting on a person count. |
| `onRespond` (required) | `(response: ToolApprovalResponse) => void \| PromiseLike<void>` |  |  |
| `policy` | `ApprovalPolicy` |  |  |
| `tools` | `Record<string, ToolMeta>` |  |  |
| `min` | `number` | `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`, `id`, `aria-*`, `data-*` and event handlers.

### ApprovalChoice

A decision as the card makes it: what was chosen, and what goes with it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `decision` (required) | `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.

```ts
type ApprovalDecision = 'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always';
```

### ToolApprovalResponse

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  |  |
| `approved` (required) | `boolean` |  |  |
| `reason` | `string` |  |  |

### RiskLevel

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

### ApprovalStatus

```ts
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.
- `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

Slots (`data-slot`): `signoff-approval-card`, `signoff-approval-batch`.

- `data-decision`: 'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always'
- `data-status` on `signoff-approval-card`: 'pending' | 'approved' | 'denied'
- `data-risk` on `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`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
