Understanding the Agentic Chat Model
How the Vercel AI SDK chat model actually fits together, who owns the conversation between server and client, what the server-sent event stream looks like on the wire, how useChat folds those events into typed message parts, and why you render tool calls straight from the parts instead of lifting them into component state.
The first time you wire a streaming LLM into a real UI, the instinct is to lift the
interesting bits into component state. A useState for the search results, an
effect that fills it when the tool call resolves, a grid that reads from it. It
works. It also leaves you owning a copy of data the SDK already handed you, plus an
effect whose only job is to keep the copy in sync with its source.
Our Chat component is headless on purpose: it knows about viewport, scroll, and
input, and nothing about where messages come from. Wiring it to the Vercel AI SDK,
the lesson that paid off was the opposite of that instinct. You don't lift
anything. The message is already structured state, and you render straight from it.
'use client'
import * as React from 'react'
import { ArrowUpIcon, Square } from 'lucide-react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { Streamdown } from 'streamdown'
import {
Chat,
ChatInputArea,
ChatInputField,
ChatInputSubmit,
ChatViewport,
ChatMessages,
ChatMessageRow,
ChatMessageBubble,
ChatMessageAvatar,
} from '@/components/chat'
import type { RegistryChatUIMessage } from './types'
import { SearchTool } from './search-tool'
import { ThinkingRow } from './thinking-row'
const AGENT_AVATAR = '/static/c/scrap.webp'
export function AiSdkChatDemo() {
const [input, setInput] = React.useState('')
const { messages, sendMessage, status, stop, error } =
useChat<RegistryChatUIMessage>({
transport: new DefaultChatTransport({ api: '/api/chat' }),
})
const isStreaming = status === 'submitted' || status === 'streaming'
const lastMessage = messages[messages.length - 1]
const assistantStarted =
lastMessage?.role === 'assistant' &&
lastMessage.parts.some(
(part) =>
(part.type === 'text' && part.text.trim() !== '') ||
part.type === 'tool-searchComponents'
)
const showThinking = isStreaming && !assistantStarted
const handleSubmit = () => {
const text = input.trim()
if (!text || isStreaming) return
sendMessage({ text })
setInput('')
}
return (
<Chat onSubmit={handleSubmit}>
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-6">
<ChatViewport className="h-96">
<ChatMessages className="w-full py-3">
<ChatMessageRow variant="peer">
<ChatMessageAvatar
src={AGENT_AVATAR}
alt="JOYCO assistant"
fallback="J"
/>
<ChatMessageBubble>
Hi! I'm the registry assistant. Tell me what you're
building and I'll find components for you.
</ChatMessageBubble>
</ChatMessageRow>
{messages.map((message) => (
<React.Fragment key={message.id}>
{message.parts.map((part, index) => {
const key = `${message.id}-${index}`
if (part.type === 'text') {
return part.text.trim() ? (
<ChatMessageRow
key={key}
variant={message.role === 'user' ? 'self' : 'peer'}
>
{message.role !== 'user' && (
<ChatMessageAvatar
src={AGENT_AVATAR}
alt="JOYCO assistant"
fallback="J"
/>
)}
<ChatMessageBubble>
{message.role === 'user' ? (
part.text
) : (
<Streamdown
animated={{
animation: 'blurIn', // "fadeIn" | "blurIn" | "slideUp" | custom string
duration: 200, // milliseconds (default: 150)
easing: 'ease-out', // CSS timing function (default: "ease")
sep: 'word', // "word" | "char" (default: "word")
}}
isAnimating={isStreaming}
>
{part.text}
</Streamdown>
)}
</ChatMessageBubble>
</ChatMessageRow>
) : null
}
if (part.type === 'tool-searchComponents') {
return (
<SearchTool
key={key}
part={part}
avatarSrc={AGENT_AVATAR}
/>
)
}
return null
})}
</React.Fragment>
))}
{showThinking && <ThinkingRow avatarSrc={AGENT_AVATAR} />}
{status === 'error' && error && (
<div aria-live="polite" className="px-3 py-2">
<p className="text-destructive text-sm">
Something went wrong. Try sending that again.
</p>
</div>
)}
</ChatMessages>
</ChatViewport>
<ChatInputArea>
<ChatInputField
multiline
placeholder="Ask for a component…"
value={input}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setInput(e.target.value)
}
/>
<ChatInputSubmit
onClick={(e) => {
if (isStreaming) {
e.preventDefault()
stop()
}
}}
disabled={!input.trim() && !isStreaming}
>
{isStreaming ? (
<Square className="size-[1em] fill-current" />
) : (
<ArrowUpIcon className="size-[1.2em]" />
)}
<span className="sr-only">{isStreaming ? 'Stop' : 'Send'}</span>
</ChatInputSubmit>
</ChatInputArea>
</div>
</Chat>
)
}
export default AiSdkChatDemo
Who owns the conversation
The server owns the durable copy; the client owns a live one. Getting that split right is the whole game, so it is worth walking end to end.
On load, the client asks the server for the thread and gets back messages[], the
persisted snapshot. From there the conversation belongs to the client: useChat
holds it and renders from it. When you send, you do not ship the array back. You
send only the new message plus the chat id (prepareSendMessagesRequest is the
transport hook for that). The server loads the prior messages from its own store,
appends yours, hands them to convertToModelMessages, runs the model, and streams
back events, never a fresh list. The client reduces those events into its copy
with the same logic the server used to assemble them, so both sides land on the
identical parts without the client ever re-fetching anything.
The durable copy is written once, cleanly.
toUIMessageStreamResponse({ originalMessages, onFinish }) hands the onFinish
callback the complete messages array with the new assistant reply already folded
into parts, so persisting is a straight write, no reconstruction:
onFinish: ({ messages }) => saveChat({ chatId, messages })What lands in the database is exactly the assembled UIMessage[] the client renders
from (the same parts shape shown a couple of sections down). One structure is both
the live source on the client and the durable source on the server.
This demo is the stateless slice of that picture: no store, so it skips the load,
ships the whole array each turn ({ id, messages, trigger, messageId }), and forgets
the thread on refresh. The machinery in between is identical; production just adds the
snapshot on the way in and the onFinish write on the way out.
What comes over the wire
streamText does not return a string. It returns a stream, and
result.toUIMessageStreamResponse() serializes it as Server-Sent Events: one
data: line per chunk. Ask the assistant for "a plane game" and the raw response
reads like this (trimmed):
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"tool-input-start","toolCallId":"call_a1","toolName":"searchComponents"}
data: {"type":"tool-input-delta","toolCallId":"call_a1","inputTextDelta":"plane game"}
data: {"type":"tool-input-available","toolCallId":"call_a1","toolName":"searchComponents","input":{"query":"plane game"}}
data: {"type":"tool-output-available","toolCallId":"call_a1","output":{"results":[{"title":"Plane","href":"/components/plane"}]}}
data: {"type":"finish-step"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"txt-0"}
data: {"type":"text-delta","id":"txt-0","delta":"Found "}
data: {"type":"text-delta","id":"txt-0","delta":"one "}
data: {"type":"text-delta","id":"txt-0","delta":"for you."}
data: {"type":"text-end","id":"txt-0"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]Two things stand out. Every piece is its own event with a stable id: the tool call
streams its input as it is assembled, arrives complete as tool-input-available,
and its result lands separately as tool-output-available. And the text comes one
word per event, which looks absurdly chatty for a single sentence. That is not the
model. It is smoothStream({ chunking: 'word' }) in the route, re-chunking the
stream so the UI can animate word by word. Drop it and you get the model's raw,
lumpier token bursts; the protocol is identical, the deltas are just bigger. (The
model streams its private reasoning-* events through here too; this demo simply
doesn't render that part type.)
From events to parts
You never parse any of that yourself. useChat consumes the events and folds them,
in order, into one structure: an assistant message whose parts array mirrors what
just streamed in.
{
id: 'msg_1',
role: 'assistant',
parts: [
{
type: 'tool-searchComponents', // tool name, prefixed with `tool-`
toolCallId: 'call_a1',
state: 'output-available', // input-streaming → input-available → output-available
input: { query: 'plane game' },
output: { results: [{ title: 'Plane', href: '/components/plane' }] },
},
{ type: 'text', text: 'Found one for you.' },
],
}Dozens of data: lines collapse into two parts. The typing is not inferred from
the stream, you declare it. types.ts spells out each tool's input and output
shape and plugs that map into the tools slot of the UIMessage generic:
export type RegistryChatTools = {
searchComponents: {
input: { query: string }
output: { results: RegistrySearchResult[] }
}
}
export type RegistryChatUIMessage = UIMessage<never, NoDataParts, RegistryChatTools>Pass that to the hook, useChat<RegistryChatUIMessage>(), and every message it
returns has parts typed as a discriminated union: the searchComponents entry
becomes the tool-searchComponents part type, carrying exactly those input and
output shapes. A text part is a bubble, a tool call is its own part, and each
carries its own input and output. That object is what you render, not the stream.
Parts are the render anchors
This is where the opening instinct dies for good. The card grid renders from
part.output, not from a useState you filled in an effect. The part is the data.
There is no copy to drift, no effect to keep in sync, no question of "is my state
stale", because the render is a plain read of the message. Add a second tool to the
route tomorrow and the rule does not change: it arrives as its own part, with its
own output, and you render it the same way.
Mapping parts onto the headless Chat
useChat gives you the messages; you decide how each part renders. The whole render
is one walk over parts: switch on part.type, and for the tool part switch again
on part.state.
{
message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <ChatMessageBubble key={i}>{part.text}</ChatMessageBubble>
case 'tool-searchComponents':
switch (part.state) {
case 'output-available':
return <ResultCards key={i} results={part.output.results} />
case 'output-error':
return <RetryRow key={i} />
default:
return (
<ShimmerRow
key={i}
label={`Searching for “${part.input?.query ?? ''}”`}
/>
)
}
}
})
}A text part becomes a bubble. The tool part shows a shimmer while its input is still
streaming, the result cards once the output resolves, and a retry line if it errors.
Notice the shimmer label is not a constant: the query streams in as part.input,
so even the loading state reads from the part rather than from some isSearching
flag you set on the side. And because the part is typed from types.ts,
part.input.query is a string and part.output.results is known to be search
results inside their branches and nothing else. The Chat component never had to
learn a single thing about tools, or Groq, or search. It renders parts, and the
parts know what they are.
The backend is one route
app/api/chat/route.ts runs streamText with a single searchComponents tool
that queries the registry's own pages, then returns
result.toUIMessageStreamResponse(). Which provider and model sit behind it is
your call and a one-line swap; this example happens to run on Groq's free tier, but
the pattern is identical whether the tokens come from Groq, OpenAI, or Anthropic.
The route stays a thin, stateless seam, and all the structure lives in the parts.