A chat() stream yields text deltas, tool-call chunks, and a finish chunk. This page names each chunk.
Every chunk has a type. Branch on it.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
console.log(chunk.delta);
}
if (chunk.type === "RUN_FINISHED") {
console.log(chunk.usage);
console.log(chunk.metadata?.tanstack?.finishReason);
}
}useChat builds messages from the events for you. To see each raw event, use onChunk:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function Chat() {
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
onChunk: (chunk) => {
if (chunk.type === "RUN_FINISHED") {
console.log("Run done:", chunk.runId);
}
},
});
return <p>{messages.length} messages</p>;
}Run it. The server prints the tokens, then the usage and the finish reason.
StreamChunk follows AG-UI. TanStack extras live under metadata.tanstack.
Start with these:
| Event | Main fields |
|---|---|
| RUN_STARTED | threadId, runId |
| TEXT_MESSAGE_START / CONTENT / END | messageId, delta |
| TOOL_CALL_START / ARGS / END | toolCallId, toolCallName, args delta |
| RUN_FINISHED / RUN_ERROR | usage and finish reason |
A rate-limited RUN_ERROR also says how long to wait. See Rate limits. For the provider response id, see Read the provider response identity.
Later, as you need them:
Two ids frame every stream. They come from the AG-UI protocol, not from a storage layer.
Tool calls and follow-up responses stream inside the same run. The full agentic cycle is one run, no matter how many loops it takes.
flowchart LR
subgraph thread ["Thread (threadId, stable)"]
direction LR
subgraph r1 ["Run r1, finished"]
direction TB
e1["RUN_STARTED then text then tool call then tool result then final text then RUN_FINISHED"]
end
subgraph r2 ["Run r2, finished"]
direction TB
e2["RUN_STARTED then text then RUN_FINISHED"]
end
subgraph r3 ["Run r3, running"]
direction TB
e3["RUN_STARTED then text"]
end
r1 --> r2 --> r3
endA run id is short-lived, so anything long-lived uses the thread:
The media generation hooks take a threadId too. There it names a slot, not a conversation. See Id map.
Read tool input and output from the tool-call parts of UIMessage, not from the raw events:
import { chat, StreamProcessor, 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 stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "What is the weather in Paris?" }],
threadId: "thread-1",
tools: [weatherTool],
});
const processor = new StreamProcessor();
for await (const chunk of stream) {
processor.processChunk(chunk);
}
processor.finalizeStream();
for (const message of processor.getMessages()) {
for (const part of message.parts) {
if (part.type === "tool-call") {
console.log(part.name, part.input, part.output);
}
}
}Pass your .client() tools to useChat. A check on part.name narrows part.input and part.output:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
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(),
}),
}).client(async (input) => {
return { location: input.location };
});
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
tools: [weatherTool],
});
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "tool-call" && part.name === "get_weather") {
console.log(part.input?.location);
}
}
}You now know which chunk is text, which is a tool call, and which id is the conversation and which is one run.
Some fields have a different shape in process and on the SSE or HTTP wire:
| Field | In-process chat() | SSE and HTTP wire |
|---|---|---|
| RUN_FINISHED usage | TanStack TokenUsage (promptTokens) | the spec usage array (inputTokens) |
| TOOL_CALL_END input | input, parsed from the argument string | no input. metadata.tanstack.args has the argument string as the provider sent it. |
Read finishReason from metadata.tanstack.finishReason in both cases. If you write a custom server, see Event metadata.
Other AG-UI servers can send TEXT_MESSAGE_CHUNK, TOOL_CALL_CHUNK, or REASONING_MESSAGE_CHUNK in place of START / CONTENT / END.
The client builds the same message from both forms.
SUBAGENT_STARTED, SUBAGENT_FINISHED, and SUBAGENT_ERROR frame a child agent.
See Subagents.
You want to know what a run cost. RUN_FINISHED.usage.promptTokens is the full input, cached tokens included. The cache parts are also on promptTokensDetails:
To get the uncached part, subtract both:
import type { TokenUsage } from "@tanstack/ai";
function uncachedTokens(usage: TokenUsage) {
const read = usage.promptTokensDetails?.cachedTokens ?? 0;
const written = usage.promptTokensDetails?.cacheWriteTokens ?? 0;
return usage.promptTokens - read - written;
}You got a 429 and want to know how long to wait. If the provider sends a retry-after-ms or retry-after header, the RUN_ERROR has the wait in metadata.tanstack.retryAfterMs, in milliseconds.
These adapters set it:
On the server:
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
const stream = chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "RUN_ERROR") {
const waitMs = chunk.metadata?.tanstack?.retryAfterMs;
console.log(waitMs === undefined ? "No wait sent" : `Retry in ${waitMs} ms`);
}
}The value also reaches the browser. On the client:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
onChunk: (chunk) => {
if (chunk.type === "RUN_ERROR") {
console.log(chunk.metadata?.tanstack?.retryAfterMs);
}
},
});If there is no retryAfterMs, the provider sent no wait header. Pick your own backoff.
The provider SDKs also retry a 429 by themselves first. To handle every 429 yourself, set maxRetries: 0 in the adapter config.
You do not have to wait and retry in your own code. Pass retry. Then chat() waits for the retryAfterMs time and calls the model again:
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
const stream = chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
retry: { maxRetries: 3, maxWaitMs: 30_000 },
});chat() retries only when all of these are true:
Limits:
On a serverless route, a long wait keeps the request open. If your host has a short time limit, keep maxWaitMs low. Then retry the longer wait times from the client.
You got a RUN_ERROR and want to know why the provider failed, or you want to send the request id to the provider support. Read metadata.tanstack on the RUN_ERROR:
code stays the HTTP status, for example "529". The Anthropic adapter and the adapters built on @tanstack/openai-base set both fields when the provider sends them.
On the server:
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
const stream = chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "RUN_ERROR") {
const tanstack = chunk.metadata?.tanstack;
console.log(chunk.code, tanstack?.errorType, tanstack?.requestId);
// "529" "overloaded_error" "req_011..."
}
}On the client:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
onChunk: (chunk) => {
if (chunk.type === "RUN_ERROR") {
const tanstack = chunk.metadata?.tanstack;
console.log(tanstack?.errorType, tanstack?.requestId);
}
},
});You want to find a reply in the logs of the provider, or see which model really answered. Read metadata.tanstack on RUN_FINISHED:
On the server:
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
const stream = chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "RUN_FINISHED") {
const tanstack = chunk.metadata?.tanstack;
console.log(tanstack?.responseId, tanstack?.model, tanstack?.source);
}
}The same fields land on each assistant message. StreamProcessor merges the metadata of RUN_FINISHED and RUN_ERROR into the messages of that model call. A failed call also gets stopReason: "error", and a stopped call gets stopReason: "aborted".
On the client:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
for (const message of messages) {
const tanstack = message.metadata?.tanstack;
console.log(tanstack?.responseId, tanstack?.source, tanstack?.stopReason);
}If there is no responseId, the provider sent no generation id. The adapter never fills it with an HTTP request id.