Integrate Vercel AI SDK v7 with assistant-ui for streaming chat.
Current-version integration. Requires ai@^7 and @ai-sdk/react@^4. For older versions see the overview.
Quickstart
Create a project
npx create-next-app@latest my-app
cd my-appnpx create-expo-app@latest my-app
cd my-appmkdir my-app
cd my-app
npm init -yInstall dependencies
npm install @assistant-ui/react @assistant-ui/react-ai-sdk ai@^7 @ai-sdk/react@^4 @ai-sdk/openai zodnpx expo install @assistant-ui/react-native @assistant-ui/react-ai-sdk ai@^7 @ai-sdk/react@^4 @ai-sdk/openai zodnpm install @assistant-ui/react-ink @assistant-ui/react-ai-sdk ai@^7 @ai-sdk/react@^4 @ai-sdk/openai zod ink reactSet up the backend route
For React Native and Ink, host this route in a separate backend project and point the client runtime at its absolute URL.
import { openai } from "@ai-sdk/openai";
import {
streamText,
convertToModelMessages,
tool,
zodSchema,
createUIMessageStreamResponse,
toUIMessageStream,
} from "ai";
import type { UIMessage } from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-5.6-luna"),
messages: await convertToModelMessages(messages), // async in v7
tools: {
get_current_weather: tool({
description: "Get the current weather",
inputSchema: zodSchema(z.object({ city: z.string() })),
execute: async ({ city }) => {
return `The weather in ${city} is sunny`;
},
}),
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}Set up the frontend
"use client";
import { Thread } from "@/components/assistant-ui/thread";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
export default function Home() {
const runtime = useChatRuntime();
return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="h-full">
<Thread />
</div>
</AssistantRuntimeProvider>
);
}import { AssistantRuntimeProvider } from "@assistant-ui/react-native";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import { View } from "react-native";
import { Thread } from "@/components/assistant-ui/thread";
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3000";
export default function Home() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({
api: `${API_URL}/api/chat`,
}),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<View style={{ flex: 1 }}>
<Thread />
</View>
</AssistantRuntimeProvider>
);
}import { AssistantRuntimeProvider } from "@assistant-ui/react-ink";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/react-ai-sdk";
import { Box } from "ink";
import { Thread } from "./components/thread.js";
export function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({
api: "http://localhost:3000/api/chat",
}),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<Box flexDirection="column">
<Thread />
</Box>
</AssistantRuntimeProvider>
);
}Set up UI components
Follow the UI Components guide to wire up the Thread, composer, and supporting primitives.
Follow the React Native setup to add a native Thread, composer, and supporting primitives.
Follow the Ink setup to add a terminal Thread, composer, and supporting primitives.
Frontend tools and system messages
AssistantChatTransport (used by default) forwards system messages and frontend tools to your backend on every request. Consume them with frontendTools:
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages, zodSchema, createUIMessageStreamResponse, toUIMessageStream } from "ai";
import type { UIMessage } from "ai";
import { frontendTools } from "@assistant-ui/react-ai-sdk";
export async function POST(req: Request) {
const {
messages,
system,
tools,
}: {
messages: UIMessage[];
system?: string;
tools?: any;
} = await req.json();
const result = streamText({
model: openai("gpt-5.6-luna"),
system,
messages: await convertToModelMessages(messages),
tools: {
...frontendTools(tools),
// your backend tools...
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}Frontend tools are registered through the provider's config (see the tools guide) and serialized for the backend via frontendTools.
Multi-step tool calls
AI SDK v7 supports running multiple tool-call rounds in a single request via stopWhen. Use stepCountIs(N) to cap iterations:
import { openai } from "@ai-sdk/openai";
import {
streamText,
convertToModelMessages,
stepCountIs,
createUIMessageStreamResponse,
toUIMessageStream,
} from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-5.6-luna"),
messages: await convertToModelMessages(messages),
tools: {
/* ... */
},
stopWhen: stepCountIs(10), // cap at 10 tool-call iterations
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}Without a stopWhen, AI SDK runs a single inference step. Set stepCountIs (or one of AI SDK's other stop conditions) when your tools chain multiple calls.
Server-side tool approval
AI SDK v7 gates tool execution with the call-level toolApproval option. The server pauses, emits an approval-requested part, and resumes once the client posts an approval response. assistant-ui surfaces the gate as approval on the tool part and adds a respondToApproval prop on the renderer, so tool components stay decoupled from chatHelpers.
On the backend, configure the gate with the call-level toolApproval option (per-tool status or function):
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages, tool, createUIMessageStreamResponse, toUIMessageStream } from "ai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-5.6-luna"),
messages: await convertToModelMessages(messages),
tools: {
deploy: tool({
description: "Deploy the current build to an environment.",
inputSchema: z.object({ target: z.string() }),
execute: async ({ target }) => ({ deployed: target }),
}),
},
toolApproval: {
deploy: (input) =>
input.target === "production" ? "user-approval" : "not-applicable",
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}On the client, configure useChat with sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses so the response is sent back to the server when the user decides:
"use client";
import { useChat } from "@ai-sdk/react";
import {
AssistantRuntimeProvider,
useAISDKRuntime,
} from "@assistant-ui/react-ai-sdk";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { Thread } from "@/components/assistant-ui/thread";
export default function Page() {
const chat = useChat({
api: "/api/chat",
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const runtime = useAISDKRuntime(chat);
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}Render the gate with a toolkit entry. The approval field carries the gate state, and respondToApproval is the only correct way to acknowledge it (it reads the approval id from the part):
"use client";
import { useChat } from "@ai-sdk/react";
import {
AssistantRuntimeProvider,
AuiConfig,
defineToolkit,
Tools,
} from "@assistant-ui/react";
import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { Thread } from "@/components/assistant-ui/thread";
const toolkit = defineToolkit({
deploy: {
type: "backend",
render: ({ args, approval, respondToApproval, result }) => {
if (approval?.approved === undefined) {
return (
<div>
<p>Approve deploy to {args.target}?</p>
<button onClick={() => respondToApproval({ approved: true })}>
Approve
</button>
<button
onClick={() =>
respondToApproval({ approved: false, reason: "user denied" })
}
>
Deny
</button>
</div>
);
}
if (approval?.approved === false) return <p>Denied</p>;
if (result === undefined) return <p>Approved, deploying…</p>;
return <p>Deployed {result.deployed}</p>;
},
},
});
export default function Page() {
const chat = useChat({
api: "/api/chat",
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const runtime = useAISDKRuntime(chat);
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
<Thread />
</AssistantRuntimeProvider>
);
}See the tool UI guide for the full three-state semantics of approval.approved and the isAutomatic flag.
Quote context
assistant-ui's composer can attach quote metadata to user messages (e.g. when the user selects text and clicks "Quote"). On the server, injectQuoteContext flattens that metadata into a markdown blockquote prefix so the LLM sees the quoted text:
import {
streamText,
convertToModelMessages,
createUIMessageStreamResponse,
toUIMessageStream,
} from "ai";
import { injectQuoteContext } from "@assistant-ui/react-ai-sdk";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-5.6-luna"),
messages: await convertToModelMessages(injectQuoteContext(messages)),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}injectQuoteContext is idempotent: if the blockquote prefix is already present, it is not duplicated.
Token usage
useThreadTokenUsage exposes thread-level token usage on the client. Attach usage and modelId via messageMetadata on the server:
import { streamText, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream } from "ai";
import { frontendTools } from "@assistant-ui/react-ai-sdk";
export async function POST(req: Request) {
const { messages, tools, config } = await req.json();
const result = streamText({
model: getModel(config?.modelName),
messages: await convertToModelMessages(messages),
tools: frontendTools(tools),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
messageMetadata: ({ part }) => {
if (part.type === "finish") {
return { usage: part.totalUsage };
}
if (part.type === "finish-step") {
return { modelId: part.response.modelId };
}
return undefined;
},
}),
});
}"use client";
import { useThreadTokenUsage } from "@assistant-ui/react-ai-sdk";
export function TokenCounter() {
const usage = useThreadTokenUsage();
if (!usage) return null;
return <div>{usage.totalTokens} total tokens</div>;
}getThreadMessageTokenUsage(message) returns per-message usage if you need to show it inline.
Attachments
Attachments work through the standard attachment adapter. Pass it via useChatRuntime:
const runtime = useChatRuntime({
adapters: { attachments: myAttachmentAdapter },
});Your attachment adapter's send should return content parts in the AI SDK shape, e.g. for files:
return {
...attachment,
status: { type: "complete" },
content: [
{
type: "file",
mimeType: attachment.contentType ?? "",
filename: attachment.name,
data: await getFileDataURL(attachment.file),
},
],
};For images, use { type: "image", image: <data url or remote url> }. AI SDK will pass these to providers that accept multimodal content.
Persisting chat history
By default, messages live only in memory and reset on reload. To persist and restore history per thread, provide a ThreadHistoryAdapter via adapters.history.
The adapter must implement withFormat. useChatRuntime persists history through withFormat(fmt) so messages round-trip as AI SDK UIMessage objects. An adapter without withFormat throws at runtime; load and append on the top level are unused in the AI SDK path.
For server-side cloud persistence with zero adapter code, see the AssistantCloud integration.
"use client";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import type { ThreadHistoryAdapter } from "@assistant-ui/react";
const historyAdapter: ThreadHistoryAdapter = {
// Required by the type — unused by useChatRuntime.
async load() {
return { headId: null, messages: [] };
},
async append() {},
// fmt encodes UIMessage <-> storage rows (ai-sdk/v6 format).
withFormat: (fmt) => ({
async load() {
const rows = await fetch("/api/history").then((r) => r.json());
return { messages: rows.map(fmt.decode) };
},
async append(item) {
await fetch("/api/history", {
method: "POST",
body: JSON.stringify({
id: fmt.getId(item.message),
parent_id: item.parentId,
format: fmt.format,
content: fmt.encode(item),
}),
});
},
}),
};
function Chat() {
const runtime = useChatRuntime({ adapters: { history: historyAdapter } });
// ...
}Each persisted row follows { id, parent_id, format, content }. fmt.encode produces the content payload and fmt.decode reverses it, so your backend never needs to know about UIMessage internals.
Custom transport
AssistantChatTransport is the default. To point at a non-default endpoint, instantiate it with a custom api:
import {
useChatRuntime,
AssistantChatTransport,
} from "@assistant-ui/react-ai-sdk";
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/my-custom-api/chat" }),
});If you need a transport that does not inherit from AssistantChatTransport, you forfeit automatic system-message and frontend-tool forwarding; the transport is then a regular AI SDK ChatTransport and you control everything explicitly.
Runtime options
Beyond transports and adapters, useChatRuntime accepts options that control thread routing, message grouping, and run resumption.
onThreadIdChange
Called whenever the runtime changes the active thread's settled ID, e.g. to sync the active thread to a URL query param. Changes supplied through the controlled threadId option are not echoed back through this callback.
const runtime = useChatRuntime({
onThreadIdChange: (threadId) => {
const url = new URL(window.location.href);
if (threadId) {
url.searchParams.set("thread", threadId);
} else {
url.searchParams.delete("thread");
}
window.history.replaceState(null, "", url);
},
});Only the settled ID is emitted: while a freshly created thread has not been initialized yet, the value is undefined, and the real ID is emitted once the thread initializes (with AssistantCloud, this is the cloud thread ID). The transient local ID is never surfaced.
joinStrategy
Controls how consecutive assistant messages are rendered:
const runtime = useChatRuntime({
joinStrategy: "none",
});"concat-content" (the default) merges consecutive assistant messages into a single thread message. "none" keeps each assistant message as its own thread message, which is useful when a backend persists proactive or consecutive assistant messages as separate entries. See the external store guide for the underlying join semantics.
onResume
Called when runtime.thread.resumeRun(config) is invoked. Provide it to bridge resume invocations into a custom replay channel, for example an SSE reconnect endpoint keyed by turn id:
const runtime = useChatRuntime({
onResume: async (config) => {
await reconnectToRun(config.runConfig);
},
});When omitted, resumeRun throws "Runtime does not support resuming runs.". For streams that should survive a page reload without a custom channel, use the built-in transport-level path instead: see Resumable streams, which reconnects automatically on mount and reports failures via onResumeError.
Advanced: useAISDKRuntime
When you need direct access to the useChat instance (e.g. to share it with non-assistant-ui code), drop down to useAISDKRuntime:
import { useChat } from "@ai-sdk/react";
import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
const chat = useChat();
const runtime = useAISDKRuntime(chat);useAISDKRuntime does not provide cloud or the higher-level adapter slots; those are part of useChatRuntime. If you need both your own useChat access AND cloud thread support, you generally want useChatRuntime and to read the chat state through assistant-ui hooks instead.
useAISDKRuntime accepts joinStrategy and onResume (see Runtime options) plus one option of its own, cancelPendingToolCallsOnSend:
const runtime = useAISDKRuntime(chat, {
cancelPendingToolCallsOnSend: false,
});When enabled (the default), sending a new message marks any pending interactive tool calls as failed with an error indicating the user cancelled them. Set it to false to leave pending tool calls untouched when the user sends a new message.
Adapter support
| Adapter | Supported via |
|---|---|
| Attachments | adapters.attachments |
| Speech | adapters.speech |
| Dictation | adapters.dictation |
| Feedback | adapters.feedback |
| History | adapters.history (must implement withFormat) |
| threadList | Via cloud (managed) or RemoteThreadListRuntime (custom DB) |
Key changes from v5
| Feature | v5 | v7 |
|---|---|---|
ai package | ai@^5 | ai@^7 |
@ai-sdk/react | @ai-sdk/react@^2 | @ai-sdk/react@^4 |
convertToModelMessages | Sync | Async (await) |
| Tool schema | parameters: z.object({...}) | inputSchema: zodSchema(z.object({...})) |
| Response | toDataStreamResponse() | createUIMessageStreamResponse() |
Example
examples/with-ai-sdk-v7 shows a complete reference implementation.
For responses that should survive a page reload mid-stream, see Resumable streams, which wraps the same v7 byte stream in assistant-stream/resumable.