Get started
Getting started
Install from npm or the shadcn registry, add the styles, and build a Next.js run with a diff review and an approval. AI SDK 6 or 7, React 18 or 19.
Requirements
- React 18.2 or later, or 19 (
react@^18.2.0 || ^19.0.0). CI runs the test suite on both. - AI SDK 6 or 7 (
ai@^6.0.0 || ^7.0.102). The kit imports its types only, so it adds no SDK code to your bundle. - A React app. Nothing in the kit is tied to a framework. The walkthrough below is a Next.js 16 App Router app, which CI builds and runs in Chrome.
For an AG-UI agent (LangGraph, CrewAI, Mastra and the rest) instead of the AI SDK, read this page for the install and the styles, then go to AG-UI agents.
Install
The same components come two ways: as an npm package, or as source files the shadcn CLI copies into your app.
From npm
npm i signoff-ui aiai is a peer dependency, for the message part types. @assistant-ui/ is an optional one, for signoff-ui/ (assistant-ui). The package ships ES modules with 'use client' kept per file, a precompiled stylesheet, and signoff-ui/ for server code.
With the shadcn CLI
npx shadcn@latest add https:/ / signoff-ui.vercel.app/ r/ agent-message.jsonEach item brings the component, the helpers it imports, its npm dependencies and the theme tokens, which shadcn add writes into your CSS. Files land in components/.
The items are agent-message, tool-call-timeline, approval-card, use-approval-policy, diff-review, use-diff-review, run-meter, agent-status, sources, markdown, reasoning, ag-ui and assistant-ui, each at https:/. Installing a second item skips the shared files it already added. They also install straight from the repository (npx shadcn@latest add dgesteves/).
To add them by name instead (npx shadcn@latest add @signoff-ui/), add the namespace to your components.json once. The namespace needs this registries entry: the shadcn directory doesn't list it yet.
{
"registries": {
"@signoff-ui": "https://signoff-ui.vercel.app/r/{name}.json"
}
}Component and hook files start with 'use client', so they work when rendered from Server Components; the helpers in lib/ (diff.ts, usage.ts, ai.ts, format.ts) do not, so the server can call them. The files pass a new Next.js app's ESLint config with no warnings, which CI checks.
From a coding agent. The shadcn MCP server lets Claude Code, Cursor, VS Code or Codex browse and install registry items. Set it up with npx shadcn@latest mcp init --client claude (or cursor, vscode), make sure components.json lists the registry as above, and ask for it by name, for example "add the signoff-ui diff review to the chat page". Agents can also read these docs from /.
Which one
| Difference | npm | shadcn |
|---|---|---|
| Updates | npm update | Run shadcn add again and merge your edits |
| Styles | Tailwind v4, Tailwind v3 or no Tailwind | Tailwind v4, like any current shadcn/ui app |
| Changing a component | className, tokens and data-* hooks | Edit the source |
| Imports | signoff-ui | @/ |
Start with npm if you want updates and don't plan to fork the components. Use the registry if you'd rather own the code, or your app already follows the shadcn/ui conventions.
Add the styles
Once per app. Skip this if you installed with the shadcn CLI, which already wrote the tokens into your CSS.
Tailwind CSS v4. Import the kit's entry after Tailwind. It adds the tokens, maps them into your theme and points @source at the components, so your build generates only the utilities they use.
@import 'tailwindcss';
@import 'signoff-ui/tailwind.css';Tailwind v3, or no Tailwind. Import the precompiled stylesheet: only the utilities the kit uses, and no global reset.
import 'signoff-ui/styles.css';styles.css has no cascade layers, so Tailwind v3 builds accept it and a global reset such as * { padding: 0 } can't strip the components' spacing. Every rule is scoped to the components' own elements, so it doesn't restyle your app; import it after your global CSS. That holds inside the components too: what renderTool, renderData, renderOutput and a custom approval preview return is your content, in a data-signoff-slot element the kit's rules don't reach, so your CSS styles it (it still inherits the component's text color and font). Components you render there are styled as usual. The stylesheet also sets none of your theme's variables (--spacing, --text-*, --font-*): its utilities have the kit's values written in and read only the --signoff-* tokens. If your app orders its CSS with layers, use styles.layered.css, the same stylesheet in @layer theme, base, utilities.
Dark mode. Light is the default. Add class="dark" (or data-theme="dark") to <html> or any ancestor for the dark palette. To follow the OS setting instead, also import signoff-ui/; class="light" on <html> still forces light.
Render a run
A Next.js App Router app with AI SDK 7: a client component, a page and a route. The agent reads a file, proposes an edit across two files for you to review, and asks before it runs a command. The quickstart, running is this section as an app against a scripted model, so it needs no API key, and CI runs it in Chrome.
npm i signoff-ui ai @ai-sdk/ react @ai-sdk/ openai zodThe client renders the last assistant message, its status and its cost, with a minimal composer. renderTool puts a DiffReview where the agent proposes its edit, and the review's result goes back as the tool's output, shaped for the model by reviewToolOutput. The wrapper paints the kit's own background and text colors (bg-signoff-bg text-signoff-fg), so the run reads well whatever your page's colors are. Without Tailwind, give it background: var(--signoff-bg); color: var(--signoff-fg) instead.
'use client';
import { useChat } from '@ai-sdk/react';
import {
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
type LanguageModelUsage,
type UIMessage,
} from 'ai';
import { useState } from 'react';
import {
AgentMessage,
AgentStatus,
DiffReview,
RunMeter,
deriveAgentState,
getToolPartName,
reviewToolOutput,
useRunTiming,
type FileChange,
} from 'signoff-ui';
type Message = UIMessage<{ usage?: LanguageModelUsage }>;
export function AgentRun() {
const { messages, status, sendMessage, addToolOutput, addToolApprovalResponse } = useChat<Message>({
// 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'] });
// A run starts with each message you send and spans its review and approval round trips.
const timing = useRunTiming(status, messages);
return (
// The kit's own background and text colors, so it reads well on any page. Add `dark` for the dark theme.
<div className="bg-signoff-bg text-signoff-fg mx-auto flex max-w-2xl flex-col gap-4 p-6">
<AgentStatus state={state} detail={detail} elapsedMs={timing.activeMs} />
{last && (
<AgentMessage
message={last}
streaming={status === 'streaming'}
// Once the run has finished, been stopped or failed, calls that never settled read "Stopped".
active={state !== 'done' && state !== 'stopped' && state !== 'error'}
onToolApproval={addToolApprovalResponse}
// The proposed edit, reviewed hunk by hunk: the agent gets the files as applied, and your comments.
renderTool={(part) =>
getToolPartName(part) === 'review_changes' && part.state === 'input-available' ? (
<DiffReview
files={(part.input as { files: FileChange[] }).files}
onSubmit={(review) =>
addToolOutput({
tool: 'review_changes',
toolCallId: part.toolCallId,
output: reviewToolOutput(review),
})
}
/>
) : undefined
}
/>
)}
<RunMeter
usage={last?.metadata?.usage}
pricing={{ input: 2.5, cachedInput: 0.25, output: 10 }}
ttftMs={timing.ttftMs}
durationMs={timing.activeMs}
/>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
if (!input.trim()) return;
void sendMessage({ text: input });
setInput('');
}}
>
<input
aria-label="Message the agent"
placeholder="Ask the agent to change something"
value={input}
onChange={(event) => setInput(event.target.value)}
className="border-signoff-border bg-signoff-surface flex-1 rounded-lg border px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={status !== 'ready' && status !== 'error'}
className="bg-signoff-accent text-signoff-on-accent rounded-lg px-4 text-sm font-medium disabled:opacity-50"
>
Send
</button>
</form>
</div>
);
}Render it from a page. With cacheComponents, on by default in new Next.js 16 apps, useChat needs a <Suspense> boundary: it creates ids with Math.random(), which Next.js doesn't allow in prerendered output.
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 (
<Suspense>
<AgentRun />
</Suspense>
);
}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:
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<string, string> = {
'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_changeswithoutexecute. The AI SDK ends the step at the call and leaves it to the client.DiffReview'sonSubmitgets each file with only the accepted hunks applied (content) and its decision, the rejected hunks with their lines, and the comments with theirs.reviewToolOutputturns that into one summary sentence and those lists, andaddToolOutputsends 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.useChatisreadywhile the review waits on you; naming the tool makesderiveAgentStateread 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. Thereasonshows on the approval card, asapproval.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 anoutput-errorpart, and itserrorTextgoes throughonError. By default that hides every message behind "An error occurred.", so the timeline would show that instead ofENOENT. In production, pass through the errors you're happy for users to read and keep the rest generic.
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:
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/, withopenai OPENAI_API_KEY - Anthropic:
anthropic('claude-sonnet-5-5')from@ai-sdk/, withanthropic ANTHROPIC_API_KEY - Google:
google('gemini-3.5-flash')from@ai-sdk/, withgoogle GOOGLE_GENERATIVE_AI_API_KEY - xAI:
xai('grok-4.7')from@ai-sdk/, withxai XAI_API_KEY - Mistral:
mistral('mistral-medium-latest')from@ai-sdk/, withmistral MISTRAL_API_KEY - A local model (Ollama, LM Studio):
ollama('qwen3')from@ai-sdk/, with no keyopenai-compatible
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:/) and pick a model you've pulled that can call tools:
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, 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.
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 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/for AI SDK 6, and the AI SDK 6 versions of any other providers (react@^3 @ai-sdk/ openai@^3 @ai-sdk/,anthropic@^3 @ai-sdk/,google@^3 @ai-sdk/,xai@^3 @ai-sdk/and so on).openai-compatible@^2 toolApprovalis an AI SDK 7 option. On 6, drop it and mark the tool withneedsApproval: true:
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/, which has no React:
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 <Suspense>.
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:
: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:
: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:
[data-slot='signoff-approval-card'][data-risk='critical'] {
outline: 2px solid var(--signoff-hot);
}Next steps
- AG-UI agents: render LangGraph, CrewAI or Mastra agents with the same components.
- Components: each component in isolation, with its install command and usage.
- The playground: a full run with approvals and diff review, driven from the keyboard.