Get started
Getting started
Install from npm or the shadcn registry, add the styles, render a first run in Next.js, and theme it. 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 @dgesteves/ agent-ui-kit aiai is a peer dependency, for the message part types. The package ships ES modules with 'use client' kept per file, a precompiled stylesheet, and @dgesteves/ for server code.
With the shadcn CLI
npx shadcn@latest add @agent-ui-kit/ agent-message@agent-ui-kit is in the shadcn registry directory, so the CLI finds it and adds it to your components.json on first use. 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/.
The items are agent-message, tool-call-timeline, approval-card, diff-review, run-meter, agent-status, sources, markdown, reasoning and ag-ui. Installing a second item skips the shared files it already added. They also install by URL (https:/) or straight from the repository (dgesteves/).
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 | @dgesteves/ | @/ |
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 '@dgesteves/agent-ui-kit/tailwind.css';Tailwind v3, or no Tailwind. Import the precompiled stylesheet: only the utilities the kit uses, and no global reset.
import '@dgesteves/agent-ui-kit/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. 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 @dgesteves/; 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 quickstart, running is this section as an app against a scripted model, so it needs no API key.
npm i @dgesteves/ agent-ui-kit ai @ai-sdk/ react @ai-sdk/ openai zodThe client renders the last assistant message, its status and its cost, with a minimal composer. The wrapper paints the kit's own background and text colors (bg-aui-bg text-aui-fg), so the run reads well whatever your page's colors are. Without Tailwind, give it background: var(--aui-bg); color: var(--aui-fg) instead.
'use client';
import { useChat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithApprovalResponses, type LanguageModelUsage, type UIMessage } from 'ai';
import { useState } from 'react';
import { AgentMessage, AgentStatus, RunMeter, deriveAgentState, useRunTiming } from '@dgesteves/agent-ui-kit';
type Message = UIMessage<{ usage?: LanguageModelUsage }>;
export function AgentRun() {
const { messages, status, sendMessage, addToolApprovalResponse } = useChat<Message>({
// Continue the run as soon as every pending approval has an answer.
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const [input, setInput] = useState('');
const last = messages.findLast((m) => m.role === 'assistant');
const { state, detail } = deriveAgentState({ status, message: last });
const timing = useRunTiming(status);
return (
<div className="bg-aui-bg text-aui-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 !== 'error'}
onToolApproval={addToolApprovalResponse}
/>
)}
<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-aui-border bg-aui-surface flex-1 rounded-lg border px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={status !== 'ready' && status !== 'error'}
className="bg-aui-accent text-aui-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() {
return (
<Suspense>
<AgentRun />
</Suspense>
);
}On the server, give the model tools, ask for approval before the risky one, and send usage as message metadata:
import { openai } from '@ai-sdk/openai';
import { addUsage } from '@dgesteves/agent-ui-kit/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 };
},
}),
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),
});
// Once approved, the run continues the same message in a new request whose totalUsage starts
// from zero. Add the usage the message already has (it comes back from the client, so it's 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:
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 approval.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 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.
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.react@^3 @ai-sdk/ openai@^3 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 (except Sources, which has no state and renders on the server too), 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 @dgesteves/, which has no React:
import { applyHunks, parseFileChange } from '@dgesteves/agent-ui-kit/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 {
--aui-accent: #7c3aed; /* fills: buttons, running state, focus */
--aui-accent-fg: #6d28d9; /* accent text */
--aui-hot: #c2410c; /* attention: approvals, errors, deletions */
--aui-radius: 6px;
--aui-font-sans: var(--font-inter);
}| Tokens | Purpose |
|---|---|
--aui-bg, --aui-surface, --aui-surface-2, --aui-border(-strong) | Surfaces and lines |
--aui-fg, --aui-fg-muted, --aui-fg-subtle | Text, all AA on every surface |
--aui-accent, --aui-accent-fg, --aui-on-accent, --aui-ring | Activity, primary actions, focus |
--aui-hot, --aui-hot-fg, --aui-on-hot, --aui-warn(-fg) | Attention, risk, errors |
--aui-add-bg, --aui-add-strong, --aui-del-bg, --aui-del-strong | Diff lines and word highlights |
--aui-chart-input, --aui-chart-output | The token bar |
--aui-code-* | Syntax tinting |
--aui-radius, --aui-font-sans, --aui-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-aui-bg text-aui-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 {
--aui-bg: var(--background);
--aui-surface: var(--card);
--aui-surface-2: var(--muted);
--aui-border: var(--border);
--aui-border-strong: var(--input);
--aui-fg: var(--foreground);
--aui-fg-muted: var(--muted-foreground);
--aui-fg-subtle: var(--muted-foreground);
--aui-accent: var(--primary);
--aui-accent-fg: var(--primary);
--aui-on-accent: var(--primary-foreground);
--aui-ring: var(--ring);
--aui-hot: var(--destructive);
--aui-hot-fg: var(--destructive);
--aui-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-slot (such as data-slot="approval-card") and state attributes (data-phase, data-state, data-decision, data-risk) to target from your CSS:
[data-slot='approval-card'][data-risk='critical'] {
outline: 2px solid var(--aui-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.