AI chat
AIProA complete streaming chat on the Vercel AI SDK: useChat, reasoning, tool calls, approvals, sources, attachments and a model picker, with a Claude route handler that installs with it.
npx prodular-ui add ai-chatPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Streaming chat
Pick a suggestion. The answer streams reasoning, a ledger lookup, a table with citations and sources, then asks before it emails a customer. This page uses a scripted route so it runs without a key; the next section shows how to point it at Claude.
Copilot can make mistakes. Check figures before you share them.
"use client"
import * as React from "react"import { MailIcon, ReceiptTextIcon, SquarePenIcon } from "lucide-react"
import { AiChat } from "@/components/ui/ai-chat"import { AiMark } from "@/components/ui/ai-mark"import type { AiToolConfig } from "@/components/ui/ai-message-parts"import { Button } from "@/components/ui/button"import type { ModelOption } from "@/components/ui/model-picker"
const models: ModelOption[] = [ { id: "claude-opus-5-5", name: "Claude Opus 5.5", provider: "Anthropic", description: "Best for analysis and multi step work", capabilities: ["reasoning", "tools", "vision", "files", "web"], contextWindow: 1_000_000, tag: "Default", }, { id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5", provider: "Anthropic", description: "Fast and capable for everyday questions", capabilities: ["reasoning", "tools", "vision", "files", "web"], contextWindow: 1_000_000, }, { id: "claude-haiku-5-5", name: "Claude Haiku 5.5", provider: "Anthropic", description: "Quick answers at the lowest cost", capabilities: ["reasoning", "tools", "vision", "files"], contextWindow: 1_000_000, },]
type ReminderInput = { customer?: string; to?: string; invoices?: string[]; message?: string }
const tools: Record<string, AiToolConfig> = { lookupInvoices: { title: "Look up invoices", icon: <ReceiptTextIcon />, summary: (input) => { const { status, customer } = input as { status?: string; customer?: string } return [customer, status === "all" ? "all invoices" : status && `${status} invoices`].filter(Boolean).join(", ") }, }, sendReminder: { title: "Send payment reminder", icon: <MailIcon />, summary: (input) => { const { to, invoices } = input as ReminderInput return [to, invoices && `${invoices.length} invoices`].filter(Boolean).join(", ") }, approval: { title: (input) => `Email a payment reminder to ${(input as ReminderInput).customer ?? "the customer"}`, actionLabel: "Send reminder", risk: "medium", params: (input) => { const { to, invoices, message } = input as ReminderInput return [ { label: "To", value: to }, { label: "Invoices", value: invoices?.join(", "), mono: true }, { label: "Message", value: message }, ] }, }, },}
export default function AiChatCopilot() { const [thread, setThread] = React.useState(0)
return ( <div className="flex h-160 w-full min-w-0 flex-col overflow-hidden rounded-xl border bg-background"> <div className="flex h-12 shrink-0 items-center gap-2 border-b px-4"> <AiMark /> <span className="text-sm font-medium">Relay Copilot</span> <span className="truncate text-[13px] text-muted-foreground">Receivables</span> <Button variant="ghost" size="sm" className="ms-auto" onClick={() => setThread((t) => t + 1)}> <SquarePenIcon data-icon="inline-start" /> New chat </Button> </div> <AiChat key={thread} // The docs stream a scripted answer. Remove api to use app/api/chat/route.ts with Claude. api="/api/demo-chat" assistantName="Relay Copilot" models={models} tools={tools} emptyTitle="Ask about receivables" emptyDescription="Relay Copilot reads your invoices and customers, and asks before it emails anyone." suggestions={[ "Which overdue invoices should I chase first?", "Who owes us the most right now?", "Remind Kestrel Health about their invoices", ]} placeholder="Ask about invoices, customers or cash" disclaimer="Copilot can make mistakes. Check figures before you share them." className="min-h-0 flex-1" /> </div> )}Connect to Claude
The install adds a route handler for Claude Opus 5.5. Add your key and drop the demo api prop.
- 1
Install the component and the route
Adds AiChat and AiMessageParts, the route handler at app/api/chat/route.ts, example tools in lib/ai/tools.ts, and the ai, @ai-sdk/react, @ai-sdk/anthropic and zod packages.
Terminalnpx prodular-ui add ai-chat - 2
Add your Anthropic key
Create a key in the Claude Console. The route reads it on the server, it never reaches the browser. TOOL_APPROVAL_SECRET is optional and signs approvals in production.
.env.localANTHROPIC_API_KEY=sk-ant-... TOOL_APPROVAL_SECRET=a-long-random-string - 3
Render the chat
Without api, AiChat posts to /api/chat, which streams Claude Opus 5.5 with reasoning, tools, approvals and web sources. The demo above passes api="/api/demo-chat", a docs only route that replays a scripted answer with the AI SDK's simulated stream so it runs without a key. Drop that prop and the same UI talks to Claude.
app/assistant/page.tsximport { AiChat } from "@/components/ui/ai-chat" export default function AssistantPage() { return ( <div className="h-dvh"> <AiChat assistantName="Relay Copilot" suggestions={["Which overdue invoices should I chase first?"]} /> </div> ) } - 4
Make the tools yours
Swap the sample ledger in lib/ai/tools.ts for your database. Anything that writes, sends or pays goes in toolApproval as "user-approval" in the route, and the chat asks a person before it runs.
app/api/chat/route.tstoolApproval: { sendReminder: { type: "user-approval", reason: "Emails a customer on behalf of Halcyon Labs." }, },
import { CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, CodeBlockHeader, CodeBlockTitle,} from "@/components/ui/code-block"
const steps = [ { title: "Install the component and the route", body: "Adds AiChat and AiMessageParts, the route handler at app/api/chat/route.ts, example tools in lib/ai/tools.ts, and the ai, @ai-sdk/react, @ai-sdk/anthropic and zod packages.", file: "Terminal", code: "npx prodular-ui add ai-chat", }, { title: "Add your Anthropic key", body: "Create a key in the Claude Console. The route reads it on the server, it never reaches the browser. TOOL_APPROVAL_SECRET is optional and signs approvals in production.", file: ".env.local", code: "ANTHROPIC_API_KEY=sk-ant-...\nTOOL_APPROVAL_SECRET=a-long-random-string", }, { title: "Render the chat", body: "Without api, AiChat posts to /api/chat, which streams Claude Opus 5.5 with reasoning, tools, approvals and web sources. The demo above passes api=\"/api/demo-chat\", a docs only route that replays a scripted answer with the AI SDK's simulated stream so it runs without a key. Drop that prop and the same UI talks to Claude.", file: "app/assistant/page.tsx", code: `import { AiChat } from "@/components/ui/ai-chat"
export default function AssistantPage() { return ( <div className="h-dvh"> <AiChat assistantName="Relay Copilot" suggestions={["Which overdue invoices should I chase first?"]} /> </div> )}`, }, { title: "Make the tools yours", body: "Swap the sample ledger in lib/ai/tools.ts for your database. Anything that writes, sends or pays goes in toolApproval as \"user-approval\" in the route, and the chat asks a person before it runs.", file: "app/api/chat/route.ts", code: `toolApproval: { sendReminder: { type: "user-approval", reason: "Emails a customer on behalf of Halcyon Labs." },},`, },]
export default function AiChatSetup() { return ( <ol className="flex w-full max-w-2xl flex-col divide-y"> {steps.map((step, i) => ( <li key={step.title} className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 py-5 first:pt-0 last:pb-0"> <span className="row-span-3 flex size-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground tabular-nums"> {i + 1} </span> <h4 className="text-sm font-medium text-foreground">{step.title}</h4> <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{step.body}</p> <CodeBlock size="sm" className="mt-1"> <CodeBlockHeader> <CodeBlockTitle className="font-normal text-muted-foreground">{step.file}</CodeBlockTitle> <CodeBlockActions> <CodeBlockCopyButton value={step.code} /> </CodeBlockActions> </CodeBlockHeader> <CodeBlockContent>{step.code}</CodeBlockContent> </CodeBlock> </li> ))} </ol> )}Anatomy
// app/assistant/page.tsx<div className="h-dvh"> <AiChat assistantName="Relay Copilot" models={models} tools={{ sendReminder: { title: "Send payment reminder", icon: <MailIcon />, approval: { actionLabel: "Send reminder", risk: "medium" }, }, }} suggestions={["Which overdue invoices should I chase first?"]} /></div>Guidance
Use it when
- As the whole assistant surface of a page or panel: it owns the thread, the composer, streaming state, stop, retry and approvals.
- With the route it installs. app/api/chat/route.ts streams Claude with adaptive thinking, tools, approvals and web sources in the format AiChat reads.
- When tools have side effects. Mark them user-approval on the server and AiChat shows an approval card before they run.
Avoid
- Wrapping AiChat in a card inside another card. Give it a full height parent and let the page provide the frame.
- Building your own message list around it. Use AiMessageParts with useChat when you need a different layout.
- Calling Anthropic from the browser or passing an API key as a prop. Keys stay in the route handler.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Install with npx prodular-ui add ai-chat. It writes app/api/chat/route.ts and lib/ai/tools.ts. Tell the user to set ANTHROPIC_API_KEY in .env.local; never hard code a key and never read it in a client component.
- Give AiChat a parent with a definite height (h-dvh, flex-1 with min-h-0, or a fixed height). It fills the parent and scrolls its own thread.
- Leave api unset to use /api/chat. Only the docs use /api/demo-chat; never ship that route or point production code at it.
- Keep the default model claude-opus-5-5. If you pass models, every id must also be listed in MODELS in the route, or the route falls back to the default.
- Define tools on the server in lib/ai/tools.ts. Any tool that writes, sends, pays or deletes must be listed in toolApproval with type user-approval in the route. Never rely on the client to ask.
- Pass tools on AiChat for every server tool: a verb first title (Send payment reminder), an icon, and approval copy with an actionLabel that names the action. Never show raw tool names.
- Set TOOL_APPROVAL_SECRET in production so approvals cannot be forged by a modified client.
- Persist threads by passing id and initialMessages, and save messages in the route with toUIMessageStream onEnd. Do not store chat history in localStorage.
- Keep sendReasoning and sendSources on in the route and thinking display summarized. Without them the reasoning and sources parts never reach the UI.
API
AiChat
| Prop | Type | Default | Description |
|---|---|---|---|
| api | string | "/api/chat" | Route that returns a UI message stream. |
| id / initialMessages | string / UIMessage[] | Load a saved thread. The id is sent with every request. | |
| models | ModelOption[] | Shows a model picker. The chosen id is sent as body.model. | |
| model / defaultModel / onModelChange | string | Control the selected model. Defaults to the first option. | |
| modelPicker | ReactNode | Replaces the built in picker in the composer toolbar. | |
| tools | Record<string, AiToolConfig> | Title, icon, summary and approval copy per tool name. See AiMessageParts. | |
| suggestions | string[] | Prompts shown while the thread is empty. Clicking one sends it. | |
| assistantName | string | "Assistant" | Shown in message headers and on approval cards. |
| emptyTitle / emptyDescription | ReactNode | Copy for the empty thread. | |
| attachments / accept | boolean / string | true | Lets people attach images, PDFs and text files. They are sent as file parts. |
| body / headers | object | Extra fields and headers sent with every request, read fresh each time. | |
| onFinish / onError | function | Passed to useChat. onFinish receives isAbort, isError and the final message. | |
| disclaimer | ReactNode | One line under the composer. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add ai-chat# or, with the registry in components.jsonnpx shadcn@latest add @prodular/ai-chat