Framework-agnostic headless client for managing chat state and streaming.
npm install @tanstack/ai-clientThe main client class for managing chat state.
import {
ChatClient,
fetchServerSentEvents,
type UIMessage,
} from "@tanstack/ai-client";
import { myClientTool } from "./tools";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
initialMessages: [],
tools: [myClientTool],
onMessagesChange: (messages: UIMessage[]) => {
console.log("Messages updated:", messages);
},
});
// A new client is IDLE. Attach it when your view appears, detach when it goes.
client.attach();One page can hold many chats. A browser allows only about six connections to one origin, and a chat that is tailing a run holds one for as long as that run lasts. If every chat held a connection, a handful of open views would use every slot and every other request would queue behind them, including the request that loads your messages.
So the connection follows the view. A new client holds none, attach() starts it, and detach() stops it.
If you use a framework package (@tanstack/ai-react, -vue, -solid, -svelte, -preact, -angular), the hook already does this: it attaches when its view mounts and detaches when it unmounts. Call these yourself only when you use ChatClient directly.
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
threadId: "thread-1",
persistence: true,
});
client.attach(); // start: rejoin a run in progress, and load the thread
client.detach(); // stop: drop the connection, keep messages and the run pointerWhat each one guarantees:
Earlier versions started tailing inside the constructor. If you build a ChatClient yourself, add client.attach() where your view appears and client.detach() where it goes away. Users of the framework hooks need no change.
Sends a user message and starts the run.
MultimodalContent is { content, id?, metadata? }. The string form has no metadata. Pass the object form to stamp metadata on the user UIMessage. TanStack writes the tanstack key. Your keys stay at the top of the bag.
import { client } from "./client";
await client.sendMessage("Hello!");
await client.sendMessage({
content: "Show me failed logins",
metadata: { author: { id: "user-42", name: "Dana" } },
});Appends a message to the conversation. If you pass a UIMessage, append copies uiMessage.metadata onto the stored message.
import { client } from "./client";
import type { UIMessage } from "@tanstack/ai-client";
await client.append({
role: "user",
content: "Additional context",
});
const stamped: UIMessage = {
id: "user-1",
role: "user",
parts: [{ type: "text", content: "Show me failed logins" }],
metadata: { author: { id: "user-42", name: "Dana" } },
};
await client.append(stamped);Reloads the last assistant message.
import { client } from "./client";
await client.reload();Start tailing. Rejoins a run that is still in progress and, in server-authoritative mode, loads the stored thread. Idempotent. See Lifecycle.
Stop tailing and drop the connection. Keeps messages, the run pointer and the run id, so a later attach() continues where it left off. See Lifecycle.
Stops the current response generation.
import { client } from "./client";
client.stop();Clears all messages.
import { client } from "./client";
client.clear();Manually sets the messages array.
import { client } from "./client";
import type { UIMessage } from "@tanstack/ai-client";
const newMessages: UIMessage[] = [];
client.setMessagesManually([...newMessages]);Adds the result of a client-side tool execution.
import { client } from "./client";
await client.addToolResult({
toolCallId: "call_123",
tool: "toolName",
output: { result: "..." },
state: "output-available",
});Responds to a tool approval request.
import { client } from "./client";
await client.addToolApprovalResponse({
id: "approval_123",
approved: true,
});For a complete transport walkthrough, see Connection Adapters. For React Native and Expo, see Quick Start: React Native.
Creates an SSE connection adapter.
import { fetchServerSentEvents } from "@tanstack/ai-client";
const adapter = fetchServerSentEvents("/api/chat", {
headers: {
Authorization: "Bearer token",
},
});Creates a newline-delimited JSON HTTP stream connection adapter. Pair it with toHttpResponse() on the server.
import { fetchHttpStream } from "@tanstack/ai-client";
const adapter = fetchHttpStream("/api/chat");fetchHttpStream() requires a runtime with streaming fetch, Response.body.getReader(), and TextDecoder. If the runtime cannot expose an incremental response body, it throws UnsupportedResponseStreamError; use the XHR adapters in React Native or Expo.
Creates an XMLHttpRequest-backed newline-delimited JSON stream adapter. This is the recommended default for React Native and Expo chat screens. Pair it with toHttpResponse() on the server.
import { xhrHttpStream } from "@tanstack/ai-client";
const adapter = xhrHttpStream("http://192.168.1.10:8787/chat/http", {
headers: { Authorization: "Bearer token" },
withCredentials: true,
});Creates an XMLHttpRequest-backed SSE adapter for runtimes where XHR progress events are more reliable than streaming fetch. Pair it with toServerSentEventsResponse() on the server.
import { xhrServerSentEvents } from "@tanstack/ai-client";
const adapter = xhrServerSentEvents("http://192.168.1.10:8787/chat/sse");Fetch adapters accept:
XHR adapters accept:
body is merged into the AG-UI forwardedProps payload. Values from forwardedProps on the client and per-message sendMessage(..., data) calls override static adapter body values.
Creates a custom connection adapter.
import { stream } from "@tanstack/ai-client";
const adapter = stream(async (messages, data, signal) => {
// `data` here carries the merged forwardedProps. The fetch-based
// adapters serialize it as the AG-UI `RunAgentInput.forwardedProps`
// field on the wire (with a backward-compat `data` mirror).
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages, forwardedProps: data }),
signal,
});
return processStream(response);
});Optional. A plain array — tools: [tool1, tool2] — already narrows tool names, inputs and outputs without any wrapper or as const. clientTools() is an identity helper that performs the same capture explicitly; reach for it only when you want to build a shared, reusable tools tuple outside the hook/options call.
import {
clientTools,
createChatClientOptions,
fetchServerSentEvents,
type UIMessage,
} from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const messages: UIMessage[] = [];
const myTool1 = toolDefinition({
name: "myTool1",
description: "First tool",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ result: z.string() }),
});
const myTool2 = toolDefinition({
name: "myTool2",
description: "Second tool",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ result: z.string() }),
});
// Create client implementations
const tool1Client = myTool1.client((input) => {
// Implementation
return { result: input.query };
});
const tool2Client = myTool2.client((input) => {
// Implementation
return { result: input.query };
});
// The explicit-capture form (equivalent to `[tool1Client, tool2Client]`).
const tools = clientTools(tool1Client, tool2Client);
// Now when you use these tools in chat options:
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools, // Fully typed with literal tool names
});
// In your component:
messages.forEach((message) => {
message.parts.forEach((part) => {
if (part.type === "tool-call" && part.name === "myTool1") {
// ✅ TypeScript knows part.name is literally "myTool1"
// ✅ part.input is typed from myTool1's input schema
// ✅ part.output is typed from myTool1's output schema
}
});
});Helper function to create typed chat client options with proper type inference.
import {
createChatClientOptions,
fetchServerSentEvents,
type InferChatMessages,
} from "@tanstack/ai-client";
import { tool1, tool2 } from "./tools";
const tools = [tool1, tool2];
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
// Use InferChatMessages to extract message types
type ChatMessages = InferChatMessages<typeof chatOptions>;createChatClientOptions also preserves typed client runtime context:
import {
createChatClientOptions,
fetchServerSentEvents,
} from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
type ClientContext = {
activeProjectId: string;
};
const projectTool = toolDefinition({
name: "projectAction",
description: "Run a project action",
inputSchema: z.object({ action: z.string() }),
outputSchema: z.object({ ok: z.boolean() }),
});
const tool = projectTool.client<ClientContext>((input, ctx: { context: ClientContext }) => {
console.log(ctx.context.activeProjectId, input.action);
return { ok: true };
});
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools: [tool],
context: {
activeProjectId: "project_123",
},
});Client runtime context is local to the client instance. Use forwardedProps for explicit client-to-server handoff of serializable values, then validate and map those values into server chat({ context }).
interface UIMessage {
id: string;
role: "user" | "assistant";
parts: MessagePart[];
createdAt?: Date;
metadata?: Record<string, any>;
}metadata is an optional AG-UI bag (Record<string, any>). TanStack writes the tanstack key. Your keys stay at the top.
type MessagePart = TextPart | ThinkingPart | ToolCallPart | ToolResultPart;interface TextPart {
type: "text";
content: string;
}interface ThinkingPart {
type: "thinking";
content: string;
}Thinking parts represent the model's internal reasoning process. They are typically displayed in a collapsible format and automatically collapse when the response text appears. Thinking parts are UI-only and are not sent back to the model in subsequent requests.
Note: Thinking parts are only available when using models that support reasoning/thinking (e.g., Anthropic Claude with thinking enabled, OpenAI GPT-5 with reasoning enabled).
interface ToolCallPart {
type: "tool-call";
id: string;
name: string;
arguments: string; // JSON string (may be incomplete during streaming)
input?: any; // Parsed tool input (typed from tool's inputSchema)
state: ToolCallState;
approval?: ApprovalRequest; // only on tools declared `needsApproval: true`
output?: any; // Tool execution output (typed from tool's outputSchema)
}When you pass a typed tools array (a plain array works — clientTools() is optional), the input and output fields are automatically typed based on your tool's Zod schemas, and name becomes a discriminated union enabling type narrowing. The approval field is present only on parts for tools declared with needsApproval: true — narrow by part.name (or guard with 'approval' in part) before accessing it.
interface ToolResultPart {
type: "tool-result";
toolCallId: string;
content: string;
state: ToolResultState;
error?: string;
}type ToolCallState =
| "awaiting-input"
| "input-streaming"
| "input-complete"
| "approval-requested"
| "approval-responded"
| "complete";type ToolResultState =
| "streaming"
| "complete"
| "error";Configure stream processing with chunk strategies:
import {
ChatClient,
ImmediateStrategy,
fetchServerSentEvents,
} from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
streamProcessor: {
chunkStrategy: new ImmediateStrategy(), // Emit every chunk
},
});