Skip to content

Docs

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 ai

ai is a peer dependency, for the message part types. @assistant-ui/react is an optional one, for signoff-ui/assistant-ui (assistant-ui). The package ships ES modules with 'use client' kept per file, a precompiled stylesheet, and signoff-ui/core for server code.

With the shadcn CLI

npx shadcn@latest add https://signoff-ui.vercel.app/r/agent-message.json

Each 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/signoff-ui/.

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://signoff-ui.vercel.app/r/<item>.json. Installing a second item skips the shared files it already added. They also install straight from the repository (npx shadcn@latest add dgesteves/signoff-ui/agent-message).

To add them by name instead (npx shadcn@latest add @signoff-ui/agent-message), add the namespace to your components.json once. The namespace needs this registries entry: the shadcn directory doesn't list it yet.

components.json
{
  "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 /llms.txt.

Which one

Differencenpmshadcn
Updatesnpm updateRun shadcn add again and merge your edits
StylesTailwind v4, Tailwind v3 or no TailwindTailwind v4, like any current shadcn/ui app
Changing a componentclassName, tokens and data-* hooksEdit the source
Importssignoff-ui@/components/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.

app/globals.css
@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.

app/layout.tsx
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/theme.auto.css; 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 zod

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

app/agent-run.tsx
'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.

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 (
    <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:

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

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:

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:

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/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:
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:

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 <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:

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);
}
TokensPurpose
--signoff-bg, --signoff-surface, --signoff-surface-2, --signoff-border(-strong)Surfaces and lines
--signoff-fg, --signoff-fg-muted, --signoff-fg-subtleText, all AA on every surface
--signoff-accent, --signoff-accent-fg, --signoff-on-accent, --signoff-ringActivity, 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-strongDiff lines and word highlights
--signoff-chart-input, --signoff-chart-outputThe token bar
--signoff-code-*Syntax tinting
--signoff-radius, --signoff-font-sans, --signoff-font-monoShape 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:

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:

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