Hi! Ask me anything about Material UI, MUI X Data Grid, Date Pickers, Charts, Tree View, Scheduler, or Chat.
Chat - Vercel AI SDK adapter
Connect the Vercel AI SDK to the chat runtime by piping streamText output, or a useChat instance, into the chat UI.
The chunk types createAiSdkAdapter accepts match the AI SDK's UI Message Stream protocol structurally, so no runtime dependency on the ai package is needed.
Install ai to get full type info on the values you pass in, or skip it and rely on the local mirror.
Use this adapter when your backend speaks the AI SDK's UI Message Stream; for prototyping without a backend see the Echo adapter, and for other protocols see Building an adapter.
Demo
The demo below talks to a live MUI documentation assistant served using AI SDK. Ask it anything about Material UI or MUI X—it streams text and reasoning back through the adapter.
'use client';
import { ChatBox } from '@mui/x-chat';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
import type { ChatMessage } from '@mui/x-chat/headless';
const CONVERSATION_ID = 'mui-docs';
const initialConversations = [{ id: CONVERSATION_ID, title: 'MUI docs assistant' }];
const initialMessages = [
{
id: 'welcome',
conversationId: CONVERSATION_ID,
role: 'assistant' as const,
status: 'sent' as const,
parts: [
{
type: 'text' as const,
text: 'Hi! Ask me anything about Material UI, MUI X Data Grid, Date Pickers, Charts, Tree View, Scheduler, or Chat.',
},
],
},
];
function toPlainMessages(messages: ChatMessage[]) {
return messages.map((message) => ({
role: message.role,
content: message.parts
.map((part) => (part.type === 'text' ? part.text : ''))
.join(''),
}));
}
const adapter = createAiSdkAdapter({
stream: async ({ messages, signal }) => {
const response = await fetch(
'https://backend.mui.com/public/docs-assistant/chat',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: toPlainMessages(messages) }),
signal,
},
);
if (!response.ok || !response.body) {
throw new Error(`Docs assistant unavailable (HTTP ${response.status})`);
}
return response.body;
},
});
export default function MuiDocsAssistantDemo() {
return (
<ChatBox
adapter={adapter}
initialActiveConversationId={CONVERSATION_ID}
initialConversations={initialConversations}
initialMessages={initialMessages}
variant="compact"
sx={{
height: 600,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
/>
);
}
Pattern A: Streaming from a server route
The most common setup.
A server route uses streamText(...).toUIMessageStreamResponse(), and the client adapter pipes the response body straight into the chat:
// app/api/chat/route.ts
import { streamText, convertToModelMessages } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
'use client';
import { ChatBox } from '@mui/x-chat';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
const adapter = createAiSdkAdapter({
stream: async ({ messages, signal }) => {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.map(({ id, role, parts }) => ({ id, role, parts })),
}),
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Chat API failed: ${response.status}`);
}
return response.body;
},
});
export default function ChatPage() {
return <ChatBox adapter={adapter} sx={{ height: 600 }} />;
}
Both NDJSON ({json}\n) and SSE (data: {json}\n\n with [DONE]) wire framings are decoded automatically—whichever shape your server emits, it works.
The request body shape is defined by your backend, not the adapter—the adapter only consumes the response stream, so send whatever message format your route expects.
Pattern B: Streaming in-process
Skip the server route when the model can be called in the same process (worker with proxied API keys, edge runtime, server actions returning a stream).
.toUIMessageStream() yields the same chunk shapes as a fetch response, just as JavaScript objects rather than bytes:
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
const adapter = createAiSdkAdapter({
stream: ({ message }) =>
streamText({
model: openai('gpt-4o-mini'),
prompt: message.parts
.map((part) => (part.type === 'text' ? part.text : ''))
.join(''),
}).toUIMessageStream(),
});
The demo below simulates toUIMessageStream() with a hand-built object stream, so you can watch the token-by-token flow without any backend:
'use client';
import { ChatBox } from '@mui/x-chat';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
import type { AiSdkUIMessageChunk, ChatMessage } from '@mui/x-chat/headless';
const CONVERSATION_ID = 'ai-sdk-mock';
const initialConversations = [{ id: CONVERSATION_ID, title: 'Mock stream' }];
const initialMessages = [
{
id: 'welcome',
conversationId: CONVERSATION_ID,
role: 'assistant' as const,
status: 'sent' as const,
parts: [
{
type: 'text' as const,
text: 'Type anything — I stream the reply back word by word, like the AI SDK UI Message Stream.',
},
],
},
];
function getMessageText(message: ChatMessage) {
return message.parts
.map((part) => (part.type === 'text' ? part.text : ''))
.join('');
}
function replyFor(message: ChatMessage) {
const text = getMessageText(message).trim() || 'nothing';
return `You said: «${text}» — this reply was streamed as AI SDK UI-message chunks, no \`ai\` package involved.`;
}
// Builds a ReadableStream of AI SDK UI Message Stream chunks, enqueuing one
// `text-delta` per word on a timer so the reply streams in visibly. The
// `start`/`finish` chunks deliberately omit `messageId`, exercising the
// adapter's synthetic-ID path.
function buildMockUiMessageStream(
text: string,
): ReadableStream<AiSdkUIMessageChunk> {
const words = text.split(' ');
let index = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
return new ReadableStream<AiSdkUIMessageChunk>({
start(controller) {
controller.enqueue({ type: 'start' });
controller.enqueue({ type: 'text-start', id: 'reply-text' });
const pushWord = () => {
if (index >= words.length) {
controller.enqueue({ type: 'text-end', id: 'reply-text' });
controller.enqueue({ type: 'finish' });
controller.close();
return;
}
const delta = index === 0 ? words[index] : ` ${words[index]}`;
controller.enqueue({ type: 'text-delta', id: 'reply-text', delta });
index += 1;
timer = setTimeout(pushWord, 40);
};
timer = setTimeout(pushWord, 40);
},
cancel() {
if (timer) {
clearTimeout(timer);
}
},
});
}
const adapter = createAiSdkAdapter({
stream: ({ message }) => buildMockUiMessageStream(replyFor(message)),
});
export default function AiSdkMockStreamDemo() {
return (
<ChatBox
adapter={adapter}
initialActiveConversationId={CONVERSATION_ID}
initialConversations={initialConversations}
initialMessages={initialMessages}
variant="compact"
sx={{
height: 500,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
/>
);
}
Pattern C: Sharing state with useChat
If you already drive state through useChat and want the chat to render alongside other UI bound to the same chat object (a streaming status badge, a sidebar with message counts), hand the chat instance to the adapter directly:
'use client';
import * as React from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { ChatBox } from '@mui/x-chat';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
export default function ChatPage() {
const chat = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const adapter = React.useMemo(() => createAiSdkAdapter({ chat }), [chat]);
return (
<React.Fragment>
<StreamingBadge status={chat.status} />
<ChatBox adapter={adapter} sx={{ height: 600 }} />
</React.Fragment>
);
}
Keep the memo keyed on the whole chat object, matching the adapter's own guidance—recreating the adapter when chat changes is fine, because the runtime reads it through a ref.
This integration trades token-by-token streaming for a unified state object.
useChat.sendMessage only resolves after the full reply has streamed in, so the chat shows the assistant message whole.
If you need both real-time tokens and shared state, prefer Pattern A and call chat.setMessages from a useChat onFinish callback when you need a copy.
Pattern D: Reusing a transport
When you've already configured a transport (custom headers, body transforms, auth interceptors), forward its sendMessages method to { stream }:
import { DefaultChatTransport } from 'ai';
import { createAiSdkAdapter } from '@mui/x-chat/headless';
const transport = new DefaultChatTransport({
api: '/api/chat',
headers: { Authorization: `Bearer ${token}` },
});
const adapter = createAiSdkAdapter({
stream: ({ messages, signal }) =>
transport.sendMessages({ chatId: 'main', messages, abortSignal: signal }),
});
transport.sendMessages returns a ReadableStream of UI message chunks that structurally satisfies the stream callback—no extra wiring needed.
Options
All option and request types—CreateAiSdkAdapterOptions, CreateAiSdkAdapterStreamOptions, CreateAiSdkAdapterChatOptions, CreateAiSdkAdapterRequest, AiSdkUIMessageChunk, and AiSdkChatInstance—are exported from @mui/x-chat and @mui/x-chat/headless.
| Option | Type | Notes |
|---|---|---|
stream |
(req) => ReadableStream<AiSdkUIMessageChunk | Uint8Array> |
Token streaming. Bytes are decoded for both NDJSON and SSE framings. |
chat |
AiSdkChatInstance (matches useChat()'s shape) |
Whole-reply integration with @ai-sdk/react—see Pattern C above for the streaming tradeoff. |
The stream callback receives a CreateAiSdkAdapterRequest:
| Field | Type | Notes |
|---|---|---|
message |
ChatMessage |
The message just sent. |
messages |
ChatMessage[] |
Full conversation history, including message. |
attachments |
ChatSendMessageInput['attachments'] (optional) |
Files attached in the composer—forward them in your request body if your backend accepts uploads. |
metadata |
ChatSendMessageInput['metadata'] (optional) |
Arbitrary send metadata passed by the caller. |
signal |
AbortSignal |
Aborts when the user presses stop—pass it to fetch. |
AI SDK error chunks ({ type: 'error', errorText }) are converted to ChatStreamError and surfaced through the chat's built-in error UI.
Unknown chunk types pass through unchanged so newer protocol additions don't require an adapter update.
If start, finish, or abort chunks arrive without a messageId, the adapter synthesizes one so deltas still bind to the right assistant message.
Malformed JSON mid-stream raises a ChatStreamError surfaced through the same error UI.
See also
- See Echo adapter for an in-memory adapter for prototyping.
- See Adapters for the full adapter interface reference.
- See Building an adapter for writing your own from scratch.
- See Streaming for the chunk protocol reference.
- See Vercel AI SDK: Stream protocol for the UI Message Stream spec.