You have a stream of chunks. You need to know which ones are tokens, which ones are tools, and when the run is done.
Branch on chunk.type. Two ids frame every stream: threadId and runId.
Public StreamChunk follows AG-UI. TanStack extras live under metadata.tanstack.
Do now:
Later:
On RUN_FINISHED, in-process chat() still uses TanStack TokenUsage (promptTokens). The SSE and HTTP wires use the spec usage array (inputTokens). Read finishReason from metadata.tanstack.finishReason. Custom servers: see Event metadata.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-5.6"),
messages: [{ role: "user", content: "Hello!" }],
});
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);
}
}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 whole agentic cycle is one run, however 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
endBecause run ids are short-lived, anything long-lived anchors on the thread:
The media generation hooks take a threadId too. There it names a slot, not a conversation. See Id map.
SSE and HTTP TOOL_CALL_END does not carry parsed input. In-process chat() still has input. Tool input and output also live on UIMessage parts.
On the server, feed chunks into StreamProcessor. On the client, read useChat messages.
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-5.6"),
messages: [{ role: "user", content: "What is the weather in Paris?" }],
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"),
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, and which id is the conversation versus one run.