AI message parts
AIRenders one Vercel AI SDK UIMessage with Prodular UI: text through Response, reasoning, every tool state, approvals wired to addToolApprovalResponse, sources and inline citations.
npx prodular-ui add ai-message-partsWith useChat
Your own layout around useChat. Map messages to AiMessageParts and pass addToolApprovalResponse. This demo streams a scripted answer from a docs route, no key needed.
"use client"
import { useChat } from "@ai-sdk/react"import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai"import { MailIcon, ReceiptTextIcon } from "lucide-react"
import { AiMark } from "@/components/ui/ai-mark"import { AiMessageParts, type AiToolConfig } from "@/components/ui/ai-message-parts"import { Message, MessageContent, MessageHeader } from "@/components/ui/message"import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller"import { PromptInput, PromptInputActions, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar,} from "@/components/ui/prompt-input"import { Suggestion, Suggestions } from "@/components/ui/suggestions"
const tools: Record<string, AiToolConfig> = { lookupInvoices: { title: "Look up invoices", icon: <ReceiptTextIcon /> }, sendReminder: { title: "Send payment reminder", icon: <MailIcon />, approval: { actionLabel: "Send reminder" } },}
const statusMap = { submitted: "submitting", streaming: "streaming", error: "error", ready: "idle" } as const
export default function AiMessagePartsUseChat() { const { messages, sendMessage, status, stop, addToolApprovalResponse } = useChat({ // The docs route replays a scripted answer. Point this at /api/chat for Claude. transport: new DefaultChatTransport({ api: "/api/demo-chat" }), sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, }) const last = messages[messages.length - 1]
return ( <div className="flex h-140 w-full min-w-0 flex-col overflow-hidden rounded-xl border bg-background"> <MessageScrollerProvider autoScroll> <MessageScroller className="min-h-0 flex-1"> <MessageScrollerViewport> <MessageScrollerContent className="mx-auto w-full max-w-2xl gap-8 px-4 pt-6 pb-8"> {messages.length === 0 && ( <Suggestions className="my-auto justify-center"> <Suggestion suggestion="Which overdue invoices should I chase first?" onSelect={(text) => sendMessage({ text })} /> </Suggestions> )} {messages.map((message) => ( <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={message.role === "user"}> <Message from={message.role}> <MessageContent className="gap-4"> {message.role === "assistant" && ( <MessageHeader> <AiMark animated={status === "streaming" && message.id === last?.id} /> Relay Copilot </MessageHeader> )} <AiMessageParts message={message} streaming={status === "streaming" && message.id === last?.id} tools={tools} agentName="Relay Copilot" onApprovalResponse={addToolApprovalResponse} /> </MessageContent> </Message> </MessageScrollerItem> ))} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> <div className="mx-auto w-full max-w-2xl shrink-0 px-4 pb-4"> <PromptInput status={statusMap[status]} onSubmit={(text) => sendMessage({ text })} onStop={() => stop()}> <PromptInputTextarea placeholder="Ask about invoices" /> <PromptInputToolbar> <PromptInputActions> <PromptInputSubmit /> </PromptInputActions> </PromptInputToolbar> </PromptInput> </div> </div> )}Every tool state
Preparing, running, done, failed, denied and waiting for approval, straight from the part state. Approve or deny the last call.
"use client"
import * as React from "react"import type { UIMessage } from "ai"import { BanknoteIcon, MailIcon, ReceiptTextIcon, UsersIcon } from "lucide-react"
import { AiMark } from "@/components/ui/ai-mark"import { AiMessageParts, type AiToolConfig } from "@/components/ui/ai-message-parts"import { Message, MessageContent, MessageHeader } from "@/components/ui/message"
const tools: Record<string, AiToolConfig> = { lookupInvoices: { title: "Look up invoices", icon: <ReceiptTextIcon /> }, lookupCustomer: { title: "Look up customer", icon: <UsersIcon /> }, syncPayments: { title: "Sync bank payments", icon: <BanknoteIcon /> }, sendReminder: { title: "Send payment reminder", icon: <MailIcon />, approval: { actionLabel: "Send reminder" } },}
const reminder = { customer: "Tidewater Energy", to: "ap@tidewater.energy", invoices: ["INV-2090"],}
// One assistant message with a tool part in every state the AI SDK reports.const initial: UIMessage = { id: "msg_states", role: "assistant", parts: [ { type: "reasoning", state: "done", text: "Collections review for Tidewater Energy and Kestrel Health. Pull their invoices, refresh payments from the bank, then remind whoever is still overdue.", }, { type: "tool-lookupCustomer", toolCallId: "call_customer", state: "input-streaming", input: { name: "Kestrel" }, }, { type: "tool-lookupInvoices", toolCallId: "call_invoices", state: "input-available", input: { status: "overdue", customer: "Tidewater Energy" }, }, { type: "tool-lookupInvoices", toolCallId: "call_paid", state: "output-available", input: { status: "paid", customer: "Northwind Logistics" }, output: { count: 1, total: 18400, invoices: [{ id: "INV-2104", amount: 18400, paidOn: "2026-10-21" }] }, }, { type: "tool-syncPayments", toolCallId: "call_sync", state: "output-error", input: { account: "Meridian Bank ****4417", since: "2026-10-14" }, errorText: "Bank connection expired on Oct 20. Reconnect it in Settings.", }, { type: "tool-sendReminder", toolCallId: "call_denied", state: "output-denied", input: { customer: "Ostara Foods", to: "accounts@ostarafoods.com", invoices: ["INV-2058"] }, approval: { id: "approval_ostara", approved: false, reason: "Ostara is on a payment plan until November." }, }, { type: "tool-sendReminder", toolCallId: "call_remind", state: "approval-requested", input: reminder, approval: { id: "approval_tidewater", requestReason: "Emails a customer on behalf of Halcyon Labs." }, }, ],}
export default function AiMessagePartsToolStates() { const [message, setMessage] = React.useState(initial)
// In an app this is addToolApprovalResponse from useChat. Here it moves the part on locally. const respond = ({ id, approved }: { id: string; approved: boolean }) => { setMessage((current) => ({ ...current, parts: current.parts.map((part) => part.type === "tool-sendReminder" && part.state === "approval-requested" && part.approval.id === id ? approved ? { ...part, state: "output-available" as const, output: { sent: true, to: reminder.to, messageId: "msg_inv_2090" }, approval: { id, approved: true as const }, } : { ...part, state: "output-denied" as const, approval: { id, approved: false as const } } : part ), })) }
return ( <div className="w-full max-w-2xl"> <Message from="assistant"> <MessageContent className="gap-4"> <MessageHeader> <AiMark /> Relay Copilot </MessageHeader> <AiMessageParts message={message} tools={tools} agentName="Relay Copilot" onApprovalResponse={respond} /> </MessageContent> </Message> </div> )}Anatomy
const { messages, status, addToolApprovalResponse } = useChat({ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,})
{messages.map((message) => ( <Message key={message.id} from={message.role}> <MessageContent> <AiMessageParts message={message} streaming={status === "streaming" && message.id === messages.at(-1)?.id} tools={{ sendReminder: { title: "Send payment reminder", approval: { actionLabel: "Send reminder" } } }} onApprovalResponse={addToolApprovalResponse} /> </MessageContent> </Message>))}Guidance
Use it when
- Inside your own chat layout built on useChat, when AI chat does not fit the page.
- To render saved threads: pass a stored UIMessage and it renders the same way it streamed.
- Inside a side panel, an inbox or an agent run, wherever an AI SDK message needs to show.
Avoid
- Mapping message.parts by hand. Text grouping, citation numbering and tool states are already handled here.
- Wrapping it in a card or a bubble. Assistant answers sit on the page; only user text gets a bubble, which it renders itself.
- Setting streaming on every message. Only the last assistant message while status is streaming.
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.
- Render one AiMessageParts per UIMessage inside MessageContent. Pass streaming={status === "streaming" && message.id === lastMessage.id} so only the live message shows the caret and the reasoning timer.
- Always pass onApprovalResponse={addToolApprovalResponse} from useChat, and set sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses on useChat. Without both, approvals never reach the server.
- Pass tools keyed by the server tool name with a verb first title and an icon. For approval tools add approval.actionLabel that names the action, for example Send reminder.
- In the route, keep sendReasoning and sendSources on in toUIMessageStream, and use thinking display summarized for Claude. Otherwise reasoning shows empty and sources never arrive.
- Data parts render nothing by default. Render them with renderPart, which runs before the defaults for every part; return undefined to keep the default.
- This item is free. Do not import Pro components into it. For the Pro Approval card, pass renderApproval, as AI chat does.
API
AiMessageParts
| Prop | Type | Default | Description |
|---|---|---|---|
| message | UIMessage | A message from useChat or your database. | |
| streaming | boolean | false | True only for the message that is streaming now. |
| tools | Record<string, AiToolConfig> | title, icon, summary(input), approval { title, description, actionLabel, risk, params } and render per tool name. | |
| onApprovalResponse | ({ id, approved, reason? }) => void | Pass addToolApprovalResponse. Approval buttons are disabled without it. | |
| renderApproval | (props: AiApprovalRenderProps) => ReactNode | Replaces the approval UI. Receives the part, its config, pending and respond(approved). | |
| renderPart | (part, index) => ReactNode | undefined | Runs first for every part. Return undefined to use the default. | |
| agentName | string | "Assistant" | Who asks on approval requests. |
getToolCallStatus
| Prop | Type | Default | Description |
|---|---|---|---|
| part | ToolUIPart | DynamicToolUIPart | Maps an AI SDK tool state to a ToolCall status and label, for custom tool UIs. |
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-message-parts# or, with the registry in components.jsonnpx shadcn@latest add @prodular/ai-message-parts