本页还没有译文
本页 116 段还没译成简体中文,下面显示的是英文原文。@tanstack/ai-client
还没翻译译自英文(tanstack.com)本页 116 段都还没译
Framework-agnostic headless client for managing chat state and streaming.
For typed headless chat UI types and selectors, import @tanstack/ai-client/ui. See Custom Chat UI Adapters.
Installation
npm i @tanstack/ai-clientpnpm add @tanstack/ai-clientyarn add @tanstack/ai-clientbun add @tanstack/ai-clientregisterWebMCPTools(tools, options)
Expose executable client tools through the browser WebMCP API. Abort the required signal to remove all tools from this registration.
For a complete setup and behavior guide, see WebMCP Tools.
import {
registerWebMCPTools,
type RegisterWebMCPToolsOptions,
} from "@tanstack/ai-client";
import { searchProducts } from "./tools";
const controller = new AbortController();
const tools = [searchProducts];
const options: RegisterWebMCPToolsOptions<typeof tools> = {
signal: controller.signal,
toolOptions: {
searchProducts: {
title: "Search products",
annotations: { readOnlyHint: true },
},
},
};
await registerWebMCPTools(tools, options);
// Remove these tools when their owner is no longer active.
controller.abort();The function returns Promise<void>. It resolves without registration during server rendering, in an insecure context, or when WebMCP is unavailable.
It validates Standard Schema inputs and outputs. A tool with needsApproval: true causes registration to fail.
Public option types
RegisterWebMCPToolsOptions<TTools, TContext>- Requiressignal. It also containstoolOptionsand the client tool runtimecontext.WebMCPToolOptionsByName<TTools>- A partial options map keyed by the inferred tool names.WebMCPToolOptions- Contains the optionaltitleandannotationsfields for one tool.WebMCPToolAnnotations- Contains the optionalreadOnlyHintanduntrustedContentHintfields.
context is required when a tool declares a required runtime context. If registration fails, the function removes tools that it registered during the call.
ChatClient
The 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();Lifecycle: attach() and detach()
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:
attach()is safe to call more than once. Attaching an attached client does nothing.detach()keeps the transcript, the resume pointer and the run id. The run keeps going on the server while nobody watches, so re-attaching repaints at once and picks it back up from the durable log.detach()is neitherstop()(which ends the run) nordispose()(which ends the client). It says only that no view is watching right now.- A chat with no persistence has no resume pointer and no stored thread, so
attach()issues no request at all.
Migrating from constructor tailing
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.
Constructor Options
connection- Connection adapter for streaminginitialMessages?- Initial messages arraythreadId?- The only identity for this chat. Required when persistence is on. If omitted, minted after mount.forwardedProps?- Arbitrary client-controlled JSON forwarded to the server in the AG-UIRunAgentInput.forwardedPropsfieldbody?- Deprecated. UseforwardedPropsinstead. Still works — values are merged intoforwardedPropson the wire and mirrored under the legacydatafield for backward compatibilitybyok?- Optional BYOK keyring fromdefineByok. On each send the client prepares the resolved provider and stampsx-byok-*request headers. Keys never go in the bodybyokProvider?- Optional function that returns the provider slug for this chat. If it returns a slug, only that key is prepared and sent. Otherwise the mergedproviderfromforwardedProps,body, and per-callsendMessagebodyis used. Later sources win. If no slug resolves, the send throws instead of attaching every stored keycontext?- Typed client-local runtime context passed to client tool implementations. This value is not serialized to the servertools?- Registered.client()tool implementations. The client automatically executes matching tools when the model calls themonResponse?- Callback when response is receivedonChunk?- Callback when stream chunk is receivedonFinish?- Callback when response finishesonError?- Callback when error occursonInterruptStateChange?- Callback when interrupt state changes; context source ishydratefor restored state andlivefor streamed or client-initiated updatesonMessagesChange?- Callback when messages changeonLoadingChange?- Callback when loading state changesonHydratingChange?- Callback when the client starts or stops loading the chat from persistenceonErrorChange?- Callback when error state changesstreamProcessor?- Stream processing configuration
Methods
sendMessage(content: string | MultimodalContent, body?, sendOptions?)
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.
The second argument is extra JSON merged into this request's forwardedProps. The third argument is SendMessageOptions. { whenBusy } overrides the queue policy for this send. { body } is extra JSON too.
Chat-level body, the positional argument, and sendOptions.body shallow-merge. sendOptions.body wins on key collisions.
Framework hooks expose sendMessage(content, sendOptions) with no positional body. Pass { body } there. It merges with the hook's body the same way.
import { client } from "./client";
await client.sendMessage("Hello!");
await client.sendMessage({
content: "Show me failed logins",
metadata: { author: { id: "user-42", name: "Dana" } },
});
await client.sendMessage("Summarize the attached files", undefined, {
body: { attachmentIds: ["att_1", "att_2"] },
whenBusy: "interrupt",
});Your server reads the merged JSON from chatParamsFromRequest:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, forwardedProps } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
});
if (
forwardedProps &&
typeof forwardedProps === "object" &&
"attachmentIds" in forwardedProps
) {
const { attachmentIds } = forwardedProps;
if (Array.isArray(attachmentIds) && attachmentIds.length > 0) {
// Look up the uploads. Do not add them to `messages`.
}
}
return toServerSentEventsResponse(stream);
}append(message: ModelMessage | UIMessage)
Appends a message to the conversation. If you pass a UIMessage, append copies uiMessage.metadata onto the stored message.
append() resolves after the full HTTP response is processed when this call starts the stream. If a stream is already in progress, this call queues the send and the returned promise can resolve before that queued response is processed. A RUN_FINISHED with finishReason: "tool_calls" does not end the wait when the agent loop continues in that response.
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);reload()
Reloads the last assistant message.
import { client } from "./client";
await client.reload();attach()
Start tailing. Rejoins a run that is still in progress and, in server-authoritative mode, loads the stored thread. Idempotent. See Lifecycle.
detach()
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.
stop()
Stops the current response generation. It aborts the in-flight request.
Pending client-tool results for that turn are ignored. A later
addToolResult() call for that turn is ignored. It does not start a resume.
import { client } from "./client";
client.stop();clear()
Clears all messages.
import { client } from "./client";
client.clear();setMessagesManually(messages: UIMessage[])
Manually sets the messages array.
import { client } from "./client";
import type { UIMessage } from "@tanstack/ai-client";
const newMessages: UIMessage[] = [];
client.setMessagesManually([...newMessages]);addToolResult(result)
Adds the result of a client-side tool execution. After stop(), a result for
the stopped turn is ignored. A new user message starts a new turn. Then
addToolResult() applies to that turn.
import { client } from "./client";
await client.addToolResult({
toolCallId: "call_123",
tool: "toolName",
output: { result: "..." },
state: "output-available",
});addToolApprovalResponse(response)
Responds to a tool approval request.
import { client } from "./client";
await client.addToolApprovalResponse({
id: "approval_123",
approved: true,
});Properties
messages: UIMessage[]- Current messagesisLoading: boolean- Whether a response is being generatedisHydrating: boolean- Whether the chat is loading from persistence. Read it withgetIsHydrating()error: Error | undefined- Current error, if any
Connection Adapters
For a complete transport walkthrough, see Transports. For React Native and Expo, see React Native and Quick Start: React Native.
fetchServerSentEvents(url, options?)
Creates an SSE connection adapter.
import { fetchServerSentEvents } from "@tanstack/ai-client";
const adapter = fetchServerSentEvents("/api/chat", {
headers: {
Authorization: "Bearer token",
},
});fetchHttpStream(url, options?)
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.
fetchJson(url, options?)
Creates a connection adapter for one JSON body per request. Pair it with
toJsonResponse() on the server. It does not need a streaming response body.
import { fetchJson } from "@tanstack/ai-client";
const adapter = fetchJson("/api/chat", {
pollIntervalMs: 500,
});It takes the fetch adapter options, plus pollIntervalMs (default 250). When a
reply has done: false, the adapter waits pollIntervalMs and sends
GET ?runId=<id>&offset=<offset>. Serve that GET with resumeJsonResponse().
See JSON.
xhrHttpStream(url, options?)
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,
});xhrServerSentEvents(url, options?)
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");Adapter options
Fetch adapters accept:
headers?: Record<string, string> | Headerscredentials?: RequestCredentialssignal?: AbortSignalbody?: Record<string, any>fetchClient?: typeof globalThis.fetch
XHR adapters accept:
headers?: Record<string, string> | HeaderswithCredentials?: booleansignal?: AbortSignalbody?: Record<string, any>xhrFactory?: () => XMLHttpRequest
body is merged into the AG-UI forwardedProps payload. Values from
forwardedProps on the client and per-message sendMessage body (positional
or sendOptions.body) override static adapter body values.
Stream errors
UnsupportedResponseStreamError- thrown by fetch-based adapters whenResponse.body,Response.body.getReader(), orTextDecoderis missing.StreamTruncatedError- thrown when an SSE or NDJSON stream ends with unterminated trailing data, usually because the server, proxy, or network cut the connection mid-line.
stream(connectFn)
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);
});Helper Functions
clientTools(...tools)
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
}
});
});createChatClientOptions(options)
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 }).
defineByok
Factory for a headless BYOK keyring. Import it from @tanstack/ai-client/byok. Pass the instance into ChatClient, useChat, or a generation hook. See Bring Your Own Key for a full client and relay walkthrough.
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
export const byok = defineByok({
storage: defaultByokStorage(),
});Factory options
storage?- AKeyringStorageimplementation. Default ismemoryStorage()(session only, not saved)providers?- Descriptors whosewithcompanions the store expands. With[cloudflareByok, cloudflareAccountByok], a send forcloudflarecarries the token and the account id headers, andprepareprompts for each missing one
Methods
update(provider, key)- Persist a key for a provider slug ([a-z][a-z0-9-]{0,63}), then update the snapshot. Throws if the id is not a slug, the key is empty, or storage failsupdate(key)- Persist a key for the currentpromptprovider. Throws ifpromptis nullclear(provider?)- Persist the removal, then drop one key, or all keys when you omitproviderunlock()- Decrypt unlockable storage (passkey). No-op for memory storageheaders(provider)- Returnx-byok-*headers for that slug and its companions. Chat and generation clients throw if no slug resolves — they do not send every stored keyprepare(provider?)- Wait for hydration, then unlock if needed. Ifprovideris set and the key for it or a companion is empty with no server coverage, throwByokBlockedErrorand setpromptfor that slugready()- Resolve when constructor hydration (peek/load) finishessetServerCoverage(flags)-truemeans do not block a send when the browser has no key (the relay can fill from env).falserestores the default: block. A record merges per-slug flagsrequest(provider, reason)- Setpromptto{ provider, reason }(missing|locked|invalid)getSnapshot()- Return the currentByokSnapshotsubscribe(listener)- Calllisteneron each change. Returns an unsubscribe functionkeys()- Return a copy of the raw keyring. Do not render this in the UI
Snapshot
getSnapshot() (and framework readers such as useByok) return:
type ByokSnapshot = {
status: Partial<Record<string, KeyStatus>>;
locked: boolean;
prompt: { provider: string; reason: "missing" | "locked" | "invalid" } | null;
storageError: string | null;
};
type KeyStatus =
| { state: "empty" }
| { state: "set"; masked: string }
| { state: "locked"; masked: string }
| { state: "validating"; masked: string }
| { state: "valid"; masked: string }
| { state: "invalid"; masked: string }
| { state: "error"; masked: string; message: string };status is sparse: only slugs that have a key, a lock, or a validation result appear. A missing entry means no key. masked is the last four characters of the key (maskKey). Read it with "masked" in status — { state: "empty" } has no masked. storageError is set when peek/load fails. The snapshot never includes the raw key.
Storage
defaultByokStorage(options?)- Passkey-encrypted IndexedDB when WebAuthn is available in a secure context. OtherwisememoryStorage()with a warning. WebAuthn support is not the same as PRF — first save still throws if the authenticator lacks PRFmemoryStorage()- In-memory on theByokClientinstance. This backend does not persist. Keys are gone when the client is dropped or the page reloadspasskeyStorage(options?)- Encrypt the keyring with a WebAuthn passkey. First save throws if the authenticator does not support PRFKeyringStorage-{ id, label, persistent, unlockable?, peek?, load, save, clear }
This library does not ship a dialog. Call byok.update(provider, value) from your own UI.
Types
UIMessage
interface UIMessage {
id: string;
role: "system" | "user" | "assistant";
parts: MessagePart[];
name?: string;
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.
MessagePart
type MessagePart = TextPart | ThinkingPart | ToolCallPart | ToolResultPart;TextPart
interface TextPart {
type: "text";
content: string;
}ThinkingPart
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).
ToolCallPart
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.
ToolResultPart
interface ToolResultPart {
type: "tool-result";
id?: string;
name?: string;
toolCallId: string;
content: string | ContentPart[];
state: ToolResultState;
error?: string;
metadata?: Record<string, unknown>;
createdAt?: Date;
}ToolCallState
type ToolCallState =
| "awaiting-input"
| "input-streaming"
| "input-complete"
| "approval-requested"
| "approval-responded"
| "complete";ToolResultState
type ToolResultState =
| "streaming"
| "complete"
| "error";Stream Processing
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
},
});Next Steps
- Getting Started - Learn the basics
- Bring Your Own Key - Store keys in the browser and send
x-byok-*headers - Transports - Learn about adapters
- @tanstack/ai-react API - React hooks wrapper