- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Typography
@shadcn/helpers/ai-sdk lets you write an AI conversation in code and stream it
through useChat, with no model, API route, network request, or API key.
Because the conversation streams through the real useChat lifecycle, your
components behave exactly as they would in production.
It supports every part type the AI SDK does: reasoning, tools, data, files, sources, and custom parts.
import { createChat } from "@shadcn/helpers/ai-sdk"
const chat = createChat()
.user("What changed in this release?")
.assistant("The release adds keyboard shortcuts and faster search.")
.user("Can you show me the shortcuts?")
.assistant("Press ⌘K to search and ⌘Enter to submit.")Pass the chat to useChat with its initial messages and local transport.
useChat receives the same typed UIMessage[] it would get from a streamed
response.
import { useChat } from "@ai-sdk/react"
function Chat() {
const { messages, sendMessage } = useChat({
messages: chat.get(0), // starts with no messages.
transport: chat.transport(),
})
const nextMessage = chat.next(messages)
return (
<button
disabled={!nextMessage}
onClick={() => {
if (nextMessage) {
void sendMessage(nextMessage)
}
}}
>
Send next message
</button>
)
}get(0) starts with no messages. sendMessage(nextMessage) sends the next
predefined user message; the transport then streams its assistant response.
What It's For#
The helper decouples your chat UI from the model and the backend, so you can work on the frontend on its own. It runs offline, instantly, and the same way every time.
- Build components. Develop message bubbles, tool cards, and reasoning panels against realistic streaming output, without wiring up a model first.
- Preview and demo. Ship reproducible previews, screenshots, and videos that never depend on a live model.
- Write docs. Power documentation examples with conversations that render the same way on every load.
- Test. Assert against a deterministic stream in CI, with no network calls, token spend, or flaky model output.
Installation#
pnpm add @shadcn/helpers
This helper works alongside your existing AI SDK setup (ai and
@ai-sdk/react). Import the helpers from @shadcn/helpers/ai-sdk.
Usage#
Create a conversation, pass its messages and transport to useChat, then send
each predefined user message with next().
"use client"
import { useChat } from "@ai-sdk/react"
import { createChat } from "@shadcn/helpers/ai-sdk"
const chat = createChat()
.user("What changed in this release?")
.assistant("The release adds keyboard shortcuts and faster search.")
.user("Can you show me the shortcuts?")
.assistant("Press ⌘K to search and ⌘Enter to submit.")
const initialMessages = chat.get(0)
const transport = chat.transport()
export function Chat() {
const { messages, sendMessage, status } = useChat({
messages: initialMessages,
transport,
})
const nextMessage = chat.next(messages)
const isBusy = status === "submitted" || status === "streaming"
return (
<div>
{messages.map((message) => (
<div key={message.id}>{/* Render the message */}</div>
))}
<button
disabled={!nextMessage || isBusy}
onClick={() => {
if (nextMessage && !isBusy) {
void sendMessage(nextMessage)
}
}}
>
Send
</button>
</div>
)
}"use client"
import { useChat } from "@ai-sdk/react"User Messages#
Messages added with this helper use the user and assistant roles. Use
user() to add a user message. Existing AI SDK messages are preserved when
you start with createChat({ messages }).
const chat = createChat().user("What changed in this release?")
const [message] = chat.get()
message.role // "user"
message.parts // [{ type: "text", text: "What changed in this release?" }]Pass an id or metadata as the second argument when you need them.
chat.user("What changed in this release?", {
id: "user-release-question",
metadata: {
source: "docs",
},
})User messages can also include files. See Files.
Assistant Messages#
Use assistant() to add an assistant message.
const chat = createChat().assistant(
"The release adds keyboard shortcuts and faster search."
)
const [message] = chat.get()
message.role // "assistant"
message.parts // [{ type: "text", text: "The release adds...", state: "done" }]A string creates one text part. You can also pass an array of AI SDK message parts.
chat.assistant([
{ type: "text", text: "The release adds keyboard shortcuts." },
{ type: "text", text: "Search is faster too." },
])Use the writer callback when the message has more than plain text.
chat.assistant(({ writer }) => {
writer.reasoning("I should summarize the release.")
writer.text("The release adds keyboard shortcuts and faster search.")
})The writer adds parts in the order you call them.
Message Parts#
An assistant message can contain many part types. Add them with the writer
passed to assistant(), in the order you call them:
- Text and Reasoning
- Tool Calls
- Data
- Files and Sources
- Step Starts and Custom Parts
chat.assistant(({ writer }) => {
writer.reasoning("Let me check the weather.")
writer
.tool("getWeather", { input: { city: "San Francisco" } })
.output({ city: "San Francisco", temperature: 18, condition: "Breezy" })
writer.text("It is 18°C and breezy in San Francisco.")
})Text#
Use text() to add a text part.
chat.assistant(({ writer }) => {
writer.text("The release adds keyboard shortcuts.")
writer.text(" Search is faster too.")
})Each call creates a separate text part. Text streams word by word when the chat
is used with chat.transport().
Use mode: "instant" to send the whole part at once, or delayMs to change the
delay between its text deltas.
writer.text("Done.", { mode: "instant" })
writer.text("This part streams more slowly.", { delayMs: 100 })Pass an id when the text part needs a stable identifier.
writer.text("The final answer.", { id: "answer" })Reasoning#
Use reasoning() to add a reasoning part.
chat.assistant(({ writer }) => {
writer.reasoning("I should check the latest conditions first.")
writer.text("Let me check the weather.")
})Reasoning uses the same id, delayMs, and mode options as text.
writer.reasoning("Checking the forecast.", { mode: "instant" })Tool Calls#
Use tool() to add a tool call. It returns a handle that follows the tool from
input to output.
chat.assistant(({ writer }) => {
writer
.tool("getWeather", {
title: "Checking weather",
input: { city: "San Francisco" },
})
.sleep(900)
.output({
city: "San Francisco",
temperature: 18,
condition: "Breezy",
})
writer.text("It is 18°C and breezy in San Francisco.")
})The tool can finish with output(), error(), or denied().
writer.tool("getWeather", { input: { city: "San Francisco" } }).error()
writer.tool("getWeather", { input: { city: "San Francisco" } }).denied()Pass dynamic: true to create an AI SDK dynamic-tool part instead of a typed
tool-<name> part.
writer.tool("getWeather", {
dynamic: true,
input: { city: "San Francisco" },
output: { city: "San Francisco", temperature: 18, condition: "Breezy" },
})Type the tool name, input, and output by passing your tool definitions to
createChat.
type Tools = {
getWeather: {
input: { city: string }
output: { city: string; temperature: number; condition: string }
}
}
type DataParts = Record<string, never>
const chat = createChat<unknown, DataParts, Tools>()Data#
Use data() to add a typed data-* part.
type DataParts = {
weather: {
city: string
status: "loading" | "success"
temperature?: number
condition?: string
}
}
const chat = createChat<unknown, DataParts>().assistant(({ writer }) => {
writer.data({
type: "data-weather",
id: "weather-sf",
data: { city: "San Francisco", status: "loading" },
})
})Send the same type and id again to update the part in place. This is useful
for states such as loading to success.
writer.data({
type: "data-weather",
id: "weather-sf",
data: {
city: "San Francisco",
status: "success",
temperature: 27,
condition: "Breezy",
},
})Set transient: true for an update that should stream to the client but should
not remain in the final message.
writer.data({
type: "data-weather",
data: { city: "San Francisco", status: "loading" },
transient: true,
})Files#
Add files to a user message with the files option.
chat.user("Summarize this report.", {
files: [
{
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})Use file() to add a file to an assistant message.
chat.assistant(({ writer }) => {
writer.file({
filename: "summary.md",
mediaType: "text/markdown",
url: "https://example.com/summary.md",
})
})Use reasoningFile() for a file attached to a reasoning part.
writer.reasoningFile({
filename: "notes.txt",
mediaType: "text/plain",
url: "https://example.com/notes.txt",
})Sources#
Use sourceUrl() to add a URL source.
writer.sourceUrl({
sourceId: "source-1",
title: "Release notes",
url: "https://example.com/releases",
})Use sourceDocument() to add a document source.
writer.sourceDocument({
sourceId: "source-2",
title: "Product brief",
mediaType: "application/pdf",
filename: "brief.pdf",
})Step Starts#
Use stepStart() to add an AI SDK step boundary.
chat.assistant(({ writer }) => {
writer.stepStart()
writer.reasoning("I should search the release notes.")
writer.stepStart()
writer.text("Here is what changed.")
})Custom Parts#
Use custom() to add a custom part.
chat.assistant(({ writer }) => {
writer.custom("app.approval")
})The message receives a custom part with the given kind. The AI SDK expects
kinds in the {provider}.{provider-type} format. Calling custom() without a
kind uses "test.output".
Reading Messages#
Use get() to return messages from the start of the conversation.
chat.get() // Every message.
chat.get(2) // The first two messages.
chat.get(0) // An empty initial conversation.get() returns cloned messages and does not change the chat.
Use next() to find the next predefined user message after the messages already
shown.
const initialMessages = chat.get(2)
const nextMessage = chat.next(initialMessages)next() always accepts a message transcript, not an index. It returns the next
user message or null when none remain.
Start from Existing Messages#
Pass messages to continue from a saved conversation or fixture.
import { createChat } from "@shadcn/helpers/ai-sdk"
import type { UIMessage } from "ai"
declare const savedMessages: UIMessage[]
const chat = createChat({ messages: savedMessages })
.user("What should we do next?")
.assistant("Turn the open questions into a checklist.")Existing IDs, metadata, and parts are preserved.
Transport#
transport() creates an AI SDK ChatTransport that you can pass directly to
useChat.
const transport = chat.transport()
const { messages, sendMessage } = useChat({
messages: chat.get(0),
transport,
})When sendMessage() runs, the transport finds the assistant message that
follows the current transcript and streams it through the normal AI SDK chat
lifecycle. It uses message IDs first and falls back to matching the role and
text of the latest message.
Options#
| Option | Type | Default | Description |
|---|---|---|---|
delayMs | number | 50 | Delay between text and reasoning deltas. Use 0 or undefined to remove it. |
fallback | string, UIMessagePart[], or a callback | None | Response to stream when no predefined assistant response remains. |
The transport-level delayMs is the default for every streamed text and
reasoning part. Override it for one part with writer.text(..., { delayMs })
or writer.reasoning(..., { delayMs }).
const transport = chat.transport({
delayMs: 25,
})Fallback#
Use fallback when the conversation has no predefined assistant response
left. This keeps a demo usable after its predefined replies are exhausted.
const transport = chat.transport({
fallback: "This demo has no more predefined replies.",
})A fallback can also be an array of AI SDK message parts or a writer callback. The callback receives the incoming transcript, so it can create a response from the current state.
const transport = chat.transport({
fallback: ({ writer, messages }) => {
writer.text(`This example already has ${messages.length} messages.`, {
mode: "instant",
})
},
})Fallback responses stream like assistant responses but are not added to the
predefined conversation. Without one, the transport throws
"No assistant response found for this transcript." when the conversation is
exhausted.
Calling stop() from useChat aborts the active transport stream.
Reconnecting is not supported; reconnectToStream() returns null.
Timing#
Use delays to reproduce the pace of a real response.
const chat = createChat()
.user("Give me a project update.")
.sleep(800)
.assistant(({ writer }) => {
writer.text("The first milestone is complete.", { mode: "instant" })
writer.sleep(500)
writer.text(" The next one is ready.")
})
const transport = chat.transport({ delayMs: 50 })chat.sleep(ms)waits before the next assistant response starts.writer.sleep(ms)waits between parts of an assistant response.tool.sleep(ms)waits between a tool's input and its result.transport({ delayMs })sets the default delay between text and reasoning deltas.writer.text(text, { delayMs })andwriter.reasoning(text, { delayMs })override that delay for one part.mode: "instant"sends a whole text or reasoning part in one delta.
For fast tests, use chat.transport({ delayMs: 0 }) and instant text.
Errors#
Use error() on the chat when the whole assistant response should fail.
const chat = createChat()
.user("Load the report.")
.error("The report could not be loaded.")Use writer.error() to fail after other parts have streamed.
chat.assistant(({ writer }) => {
writer.text("I found the report.")
writer.error("The connection closed before it could be read.")
})Metadata and IDs#
Pass metadata on individual messages. Its type is preserved through get(),
next(), and the transport.
type Metadata = {
model: string
}
const chat = createChat<Metadata>()
.user("Hello", { metadata: { model: "demo" } })
.assistant("Hi.", {
id: "assistant-welcome",
metadata: { model: "demo" },
})Use chat options when a fixture needs custom prefixes or a fixed clock.
const chat = createChat({
messageIdPrefix: "demo-message",
toolCallIdPrefix: "demo-tool",
sourceIdPrefix: "demo-source",
now: "2026-01-01T00:00:00.000Z",
})API Reference#
The sections above cover the common flows. This reference lists every public export and option.
createChat()#
Creates a typed conversation and returns the fluent chat interface.
function createChat<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(
options?: CreateChatOptions<METADATA, DATA_PARTS, TOOLS>
): AiSdkChat<METADATA, DATA_PARTS, TOOLS>Type Parameters#
| Parameter | Description |
|---|---|
METADATA | The metadata shape stored on each UIMessage. |
DATA_PARTS | A map of names to payloads for typed data-* parts. |
TOOLS | A map of tool names to their input and output shapes. |
Options#
| Option | Type | Default | Description |
|---|---|---|---|
messages | UIMessage<METADATA, DATA_PARTS, TOOLS>[] | [] | Start from an existing transcript. Messages are cloned. |
messageIdPrefix | string | "msg" | Prefix for generated message IDs. |
toolCallIdPrefix | string | "call" | Prefix for generated tool call IDs. |
sourceIdPrefix | string | "source" | Prefix for generated source IDs. |
now | Date | string | "2026-01-01T00:00:00.000Z" | Fixed time used for generated createdAt metadata. |
IDs found in messages are reserved, so newly generated IDs continue after
the existing transcript.
AiSdkChat#
Every method that adds content returns the same chat, so calls can be chained.
| Method | Returns | Description |
|---|---|---|
user(text?, options?) | AiSdkChat | Add a user message with text and optional files. |
assistant(input?, options?) | AiSdkChat | Add an assistant message from text, parts, or a writer callback. |
sleep(delayMs) | AiSdkChat | Wait before the next assistant response starts. |
error(errorText?) | AiSdkChat | Add an assistant response that emits an error. |
get(count?) | UIMessage[] | Return cloned messages from the start of the conversation. |
next(messages) | UIMessage | null | Return the next predefined user message after a transcript. |
transport(options?) | ChatTransport | Create the transport used by useChat. |
Calling user() or assistant() without content uses
"Summarize the uploaded receipt.". Calling error() without a message uses
"An error occurred.". get(count) throws when count is negative or not an
integer.
user() Options#
| Option | Type | Description |
|---|---|---|
id | string | Use a specific message ID. |
metadata | METADATA | Set metadata for this message. |
files | FilePayload[] | Append file parts after the user text part. |
A user file has this shape:
type FilePayload = {
type?: "file"
mediaType: string
url: string
filename?: string
providerMetadata?: Record<string, unknown>
}assistant() Input#
| Input | Result |
|---|---|
string | One text part that streams word by word. |
UIMessagePart[] | Static AI SDK parts that stream in their existing order. |
({ writer }) => void | A synchronous callback for scripting parts, tools, errors, and timing. |
assistant() Options#
| Option | Type | Description |
|---|---|---|
id | string | Use a specific message ID. |
metadata | METADATA | Set metadata for this message. |
transport() Options#
| Option | Type | Default | Description |
|---|---|---|---|
delayMs | number | 50 | Delay between text and reasoning deltas. Use 0 or undefined to remove it. |
fallback | string | UIMessagePart[] | ({ writer, messages }) => void | None | Response used when no predefined assistant response remains. |
See Transport for matching, fallback, abort, and streaming behavior.
Writer#
The writer is available inside assistant(({ writer }) => {}) and fallback
callbacks.
| Method | Description |
|---|---|
text(text?, options?) | Add a text part. |
reasoning(text?, options?) | Add a reasoning part. |
tool(name, options?) | Add a typed or dynamic tool call and return its lifecycle handle. |
data(part) | Add or update a typed data part. |
file(options?) | Add a file part. |
reasoningFile(options?) | Add a reasoning file part. |
sourceUrl(options?) | Add a URL source part. |
sourceDocument(options?) | Add a document source part. |
stepStart() | Add an AI SDK step boundary. |
custom(kind?) | Add a custom part with the given kind. |
sleep(delayMs) | Pause before the next writer event. |
error(errorText?) | Emit an error and end the response without a finish chunk. |
Calling text() without content uses "Summarize the uploaded receipt.".
Calling reasoning() without content uses
"I need to inspect the available context before answering.". Calling
error() without a message uses "An error occurred.".
Text and Reasoning Options#
| Option | Type | Default | Description |
|---|---|---|---|
id | string | Generated | Use a stable part ID. |
delayMs | number | Transport | Override the transport delay for this part. |
mode | "stream" | "instant" | "stream" | Stream word deltas or emit the whole part in one delta. |
Tool Options#
| Option | Type | Description |
|---|---|---|
toolCallId | string | Use a specific tool call ID. |
title | string | Add a display title to the tool part. |
toolMetadata | Record<string, unknown> | Add provider or application metadata. |
providerExecuted | boolean | Mark the call as executed by the provider. |
input | TOOLS[NAME]["input"] | Set the typed tool input. |
output | TOOLS[NAME]["output"] | Immediately finish with a typed output. |
errorText | string | Immediately finish with an error. |
dynamic | boolean | Emit a dynamic-tool part instead of tool-<name>. |
tool() returns a handle with sleep(delayMs), output(value),
error(errorText?), and denied(). Use the handle when the tool lifecycle
needs events between its input and result. Calling tool.error() without a
message uses "Tool call failed.".
Data Input#
writer.data({
type: "data-name",
id: "optional-id",
data: value,
transient: false,
})Repeating the same type and id replaces the earlier data part. A transient
part streams to the client but is not included in the final message returned by
get().
File and Source Options#
| Method | Fields |
|---|---|
file() | mediaType?, url?, filename?, providerMetadata? |
reasoningFile() | mediaType?, url?, filename?, providerMetadata? |
sourceUrl() | sourceId?, url?, title?, providerMetadata? |
sourceDocument() | sourceId?, mediaType?, title?, filename?, providerMetadata? |
These methods provide sample defaults for omitted fields. Pass explicit values when the payload matters to the component or test.
Types#
| Export | Description |
|---|---|
AiSdkChat | The typed fluent chat returned by createChat(). |
CreateChatOptions | Options accepted by createChat(). |
On This Page
What It's ForInstallationUsageUser MessagesAssistant MessagesMessage PartsTextReasoningTool CallsDataFilesSourcesStep StartsCustom PartsReading MessagesStart from Existing MessagesTransportOptionsFallbackTimingErrorsMetadata and IDsAPI ReferencecreateChat()Type ParametersOptionsAiSdkChatuser() Optionsassistant() Inputassistant() Optionstransport() OptionsWriterText and Reasoning OptionsTool OptionsData InputFile and Source OptionsTypes