TanStack AI supports streaming responses for real-time chat experiences. Streaming allows you to display responses as they're generated, rather than waiting for the complete response.
When you use chat(), it returns an async iterable stream of chunks:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello!" }],
});
// Stream contains chunks as they arrive
for await (const chunk of stream) {
console.log(chunk); // Process each chunk
}Convert the stream to an HTTP response using toServerSentEventsResponse:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
});
// Convert to HTTP response with proper headers
return toServerSentEventsResponse(stream);
}The useChat hook automatically handles streaming:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
// Messages update in real-time as chunks arrive
messages.forEach((message) => {
// Message content updates incrementally
});TanStack AI implements the AG-UI Protocol for streaming. Stream events contain different types of data:
Tip: Some models expose their internal reasoning as thinking content that streams before the response. See Thinking & Reasoning.
When you pass typed tools (defined with toolDefinition() and Zod schemas) to chat(), the stream chunks automatically carry type information for tool call events. Prefer the AG-UI field toolCallName (or the deprecated toolName alias) — both narrow to the union of your tool name literals. The input field on TOOL_CALL_END is typed as the union of your tool input schemas (typically set on the adapter-emitted END once arguments are complete):
import { chat, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const weatherTool = toolDefinition({
name: "get_weather",
description: "Get weather for a location",
inputSchema: z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
});
const messages = [
{ role: "user" as const, content: "What's the weather in Paris?" },
];
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [weatherTool],
});
for await (const chunk of stream) {
// `'type' in chunk` is required for control-flow narrowing across the
// StreamChunk union (AG-UI event types from `@ag-ui/core` use Zod
// passthrough, which otherwise hides the discriminant from property access).
if ("type" in chunk && chunk.type === "TOOL_CALL_END") {
chunk.toolCallName; // ✅ typed as "get_weather" (not string)
chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } | undefined
}
}Without typed tools, names default to string and input/output default to unknown — the same behavior as before. The type narrowing is automatic when you use toolDefinition() with Zod schemas.
When multiple tools are provided, tool call events form a discriminated union — checking toolCallName (or toolName) narrows input / output to that specific tool's type:
import { chat, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const weatherTool = toolDefinition({
name: "get_weather",
description: "Get weather for a location",
inputSchema: z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
});
const searchTool = toolDefinition({
name: "search",
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
});
const messages = [
{ role: "user" as const, content: "Find the weather for Paris" },
];
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [weatherTool, searchTool],
});
for await (const chunk of stream) {
if ("type" in chunk && chunk.type === "TOOL_CALL_END") {
if (chunk.toolCallName === "get_weather") {
// ✅ input is narrowed to { location: string; unit?: "celsius" | "fahrenheit" }
console.log(`Weather in ${chunk.input?.location}`);
}
if (chunk.toolCallName === "search") {
// ✅ input is narrowed to { query: string }
console.log(`Searched for: ${chunk.input?.query}`);
}
}
}Tip: The typed stream type is exported as TypedStreamChunk<TTools>. The default (no type args) matches ChatStream: standard chunks plus the known framework CUSTOM event union. Free-form emitCustomEvent names still flow at runtime; cast to StreamChunk if you need to read them.
Adapters emit reasoning as both the canonical REASONING_MESSAGE_* events and the older STEP_STARTED / STEP_FINISHED events. Rather than parsing those raw events yourself, read the reconciled ThinkingPart from message.parts — the stream processor merges both event families into a single part for you:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "thinking") {
console.log("Thinking:", part.content); // Accumulated thinking content
}
}
}Thinking content is automatically converted to ThinkingPart in UIMessage objects. It is UI-only and excluded from messages sent back to the model. See Thinking & Reasoning for the full rendering pattern.
TanStack AI provides connection adapters for different streaming protocols:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});import { useChat, fetchHttpStream } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchHttpStream("/api/chat"),
});For a fully custom request, use the fetcher transport. The fetcher receives the request input plus an AbortSignal, and returns a Response (whose SSE body the client parses) or an AsyncIterable<StreamChunk>. It may return that value synchronously, as a Promise, or as an async function*:
import { useChat } from "@tanstack/ai-react";
const { messages } = useChat({
fetcher: ({ messages, data }, { signal }) =>
fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages, ...data }),
signal,
}),
});Note: The lower-level stream() connection adapter takes a factory that must return an AsyncIterable<StreamChunk> synchronously (e.g. a generator) — it does not accept an async (...) => {...} function that returns a Promise. Prefer the fetcher transport above unless you specifically need the connection adapter.
You can monitor stream progress with callbacks:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
onChunk: (chunk) => {
console.log("Received chunk:", chunk);
},
onFinish: (message) => {
console.log("Stream finished:", message);
},
});Cancel ongoing streams:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { stop } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
// Cancel the current stream
stop();Calling stop() aborts the underlying fetch; the resulting AbortError is expected and normal. This differs from a connection being cut mid-line: a truncated stream throws a StreamTruncatedError and moves the client into its error state. See Connection Adapters for the underlying behavior.
On the server, pass an AbortController to toServerSentEventsResponse(stream, { abortController }) so the chat run is cancelled when the client disconnects:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({ adapter: openaiText("gpt-5.5"), messages });
const abortController = new AbortController();
return toServerSentEventsResponse(stream, { abortController });
}By default, calling sendMessage while a stream is already in flight queues the message instead of dropping it — it sends automatically once the current run settles successfully. Configure this with the queue option, which accepts a QueueConfig object, a plain shorthand string, or a strategy function:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages, queue, sendMessage, cancelQueued, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
queue: { whenBusy: "queue", drain: "fifo", maxSize: 5 },
});You can also pass a plain WhenBusy string (e.g. queue: "interrupt") as shorthand for { whenBusy: "interrupt" }, or a QueueStrategy function for per-send action control. Strategy form always drains FIFO (no batch); actions are 'queue' | 'drop' | 'interrupt' (no concurrent streams). Per-call whenBusy overrides both config and strategy.
useChat exposes the pending queue as queue so you can render it distinctly from messages, along with cancelQueued(id) to cancel an item before it sends:
function PendingQueue() {
return (
<>
{queue.map((q) => (
<div key={q.id} className="pending">
{typeof q.content === "string" ? q.content : "[attachment]"}
<button onClick={() => cancelQueued(q.id)}>Cancel</button>
</div>
))}
</>
);
}Override the configured policy for a single send with the second argument to sendMessage:
sendMessage("Never mind, do this instead", { whenBusy: "interrupt" });Note: This is a default-behavior change — messages sent while streaming used to be silently dropped. They are now queued unless you opt into queue: "drop" (or { whenBusy: "drop" }) to restore the old behavior, or queue: "interrupt".