Class References
Function References
- text
- textOptions
- combineStrategies
- convertMessagesToModelMessages
- convertZodToJsonSchema
- createReplayStream
- embedding
- generateMessageId
- maxIterations
- messages
- modelMessageToUIMessage
- modelMessagesToUIMessages
- normalizeToUIMessage
- parsePartialJSON
- summarize
- toServerSentEventsStream
- toServerSentEventsResponse
- toolDefinition
- uiMessageToModelMessages
- realtimeToken
- untilFinishReason
Interface References
- AIAdapter
- AIAdapterConfig
- AgentLoopState
- AudioPart
- AudioVisualization
- BaseStreamChunk
- TextCompletionChunk
- TextOptions
- ChunkRecording
- ChunkStrategy
- ClientTool
- ContentPartSource
- ContentStreamChunk
- DefaultMessageMetadataByModality
- DocumentPart
- DoneStreamChunk
- EmbeddingOptions
- EmbeddingResult
- ErrorStreamChunk
- ImagePart
- InternalToolCallState
- JSONParser
- ModelMessage
- ProcessorResult
- ProcessorState
- RealtimeAdapter
- RealtimeClientOptions
- RealtimeConnection
- RealtimeMessage
- RealtimeSessionConfig
- RealtimeToken
- RealtimeTokenAdapter
- RealtimeTokenOptions
- ResponseFormat
- ServerTool
- StreamProcessorEvents
- StreamProcessorHandlers
- StreamProcessorOptions
- SummarizationOptions
- SummarizationResult
- TextPart
- ThinkingPart
- ThinkingStreamChunk
- Tool
- ToolCall
- ToolCallPart
- ToolCallStreamChunk
- ToolConfig
- ToolDefinition
- ToolDefinitionConfig
- ToolDefinitionInstance
- ToolInputAvailableStreamChunk
- ToolResultPart
- ToolResultStreamChunk
- UIMessage
- VideoPart
Type Alias References
- AgentLoopStrategy
- AnyClientTool
- TextStreamOptionsForModel
- TextStreamOptionsUnion
- ConstrainedContent
- ConstrainedModelMessage
- ContentPart
- ContentPartForModalities
- ExtractModalitiesForModel
- ExtractModelsFromAdapter
- InferToolInput
- InferToolName
- InferToolOutput
- MessagePart
- RealtimeEvent
- RealtimeMessagePart
- RealtimeMode
- RealtimeStatus
- ModalitiesArrayToUnion
- Modality
- StreamChunk
- ToolCallState
- ToolResultState
Variable References
@tanstack/ai-client
The headless client under every framework package. Use it directly for vanilla JavaScript or a custom binding. For typed chat UI types and selectors, import @tanstack/ai-client/ui and see Custom Chat UI Adapters.
Installation#
npm i @tanstack/ai-clientpnpm add @tanstack/ai-clientyarn add @tanstack/ai-clientbun add @tanstack/ai-clientChatClient#
Holds the messages and the run state of one chat, and talks to your server route.
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
const unsubscribe = client.subscribeSnapshot(() => {
const { messages, isLoading } = client.getSnapshot();
console.log(messages.length, isLoading);
});
client.attach();
await client.sendMessage("Hello!");Lifecycle: attach() and detach()#
A new client opens no connection. When your view appears, call attach(). When it goes away, call detach(). The framework hooks do this for you on mount and unmount.
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
threadId: "thread-1",
persistence: true,
});
client.attach(); // rejoin a run in progress, and load the thread
client.detach(); // drop the connection, keep the messages and the run pointerThe connection follows the view because a browser allows only about six connections to one origin. A chat that follows a run holds one connection for as long as the run lasts. If every chat on a page held one, the other requests wait behind them.
- attach() is safe to call more than once. A second call does nothing.
- detach() keeps the messages, the resume pointer, and the run id. The run goes on at the server, and a later attach() picks it up from the durable log.
- detach() does not end the run (that is stop()) or the client (that is dispose()).
- A chat with no persistence has nothing to rejoin, so attach() sends no request.
Constructor options#
You must pass threadId. You must also pass connection or fetcher, not both.
| Option | What it does |
|---|---|
| connection | Connection adapter. See Connection adapters. |
| fetcher | A function that gets { messages, data, threadId, runId, ... } and an abort signal, and returns a Response or a stream. Use it with server functions. |
| tools | Client tools from .client(). The client runs them when the model calls them. See Client Tools. |
| threadId | Required. The id of this chat. Use a stable id for each conversation, for example the id from your route. |
| persistence | true to load the thread from the server, or a storage adapter. See Chat Persistence. |
| history | { pageSize }: load only the newest messages first. Only with persistence: true. |
| initialMessages | The messages to show first. |
| forwardedProps | JSON sent to the server in the AG-UI RunAgentInput.forwardedProps field. |
| context | Client-only runtime context for client tools. It is not sent to the server. |
| queue | What sendMessage does while a run is busy: "queue" (default), "drop", "interrupt", a config, or a function. See Queueing. |
| interrupts | Definitions from defineInterrupt(). See Interrupts. |
| byok | A keyring from defineByok. The client sends the key in x-byok-* headers, never in the body. |
| byokProvider | A function that returns the provider slug for this chat. If it returns nothing, the client uses provider from the merged forwardedProps and send body. If no slug resolves, the send throws. It does not send every stored key. |
| streamProcessor | { chunkStrategy }. Controls when text updates are applied. Default: ImmediateStrategy, every chunk. |
| initialResumeSnapshot | A saved resume state to restore pending interrupts after a reload. |
| devtools, devtoolsBridgeFactory | Devtools metadata, and the bridge factory from @tanstack/ai-client/devtools. |
Callbacks:
| Callback | When it runs |
|---|---|
| onResponse, onChunk, onFinish, onError | When a response arrives, a chunk arrives, a response ends, or an error occurs. |
| onMessagesChange, onLoadingChange, onErrorChange, onStatusChange | When that piece of state changes. |
| onSubscriptionChange, onConnectionStatusChange, onSessionGeneratingChange | When the live subscription changes. |
| onHydratingChange | When loading from persistence starts or stops. |
| onQueueChange, onRunIdChange | When the queue or the run id changes. |
| onInterruptStateChange | When interrupt state changes. The second argument is { source: "hydrate" } for restored state and { source: "live" } otherwise. |
| onResumeStateChange | When the resume state or pending interrupts change. |
| onCustomEvent | When a server tool calls emitCustomEvent(). See Custom Events. |
Methods#
| Method | What it does |
|---|---|
| sendMessage(content, body?, sendOptions?) | Sends a user message and starts a run. |
| append(message) | Adds a UIMessage or ModelMessage and starts a run. |
| reload() | Removes the answer after the last user message and runs that message again. |
| stop() | Aborts the request in flight. Client tool results for that turn are ignored. |
| clear() | Clears all messages. |
| setMessagesManually(messages) | Replaces the messages. |
| addToolResult(result) | Gives a client tool result by hand: { toolCallId, tool, output, state?, errorText? }. |
| attach(), detach() | Start and stop the connection for a view. |
| subscribe(options?), unsubscribe() | Start and stop the live subscription loop. |
| getSnapshot(), subscribeSnapshot(listener) | Read the UI state, and listen for changes. |
| getInterrupts() | Pending interrupts, each with resolveInterrupt(...). |
| resolveInterrupts(...), cancelInterrupts(), retryInterrupts(), resumeInterruptsUnsafe(resume, state?) | Act on all pending interrupts at once. |
| cancelQueued(id), getQueue() | Cancel or read queued messages. |
| loadOlderMessages(), getHasOlderMessages() | Page in older messages. |
| updateOptions(options) | Change the transport, forwardedProps, tools, context, queue, BYOK, or callbacks. |
| dispose() | Ends the client: stops the subscription and releases devtools. |
Read single values with getMessages(), getIsLoading(), getStatus(), getError(), getIsHydrating(), getIsSubscribed(), getConnectionStatus(), getSessionGenerating(), and getCurrentRunId().
sendMessage(content, body?, sendOptions?)#
content is a string or MultimodalContent: { content, id?, metadata? }. Use the object form to put metadata on the user UIMessage. TanStack writes the tanstack key in metadata. Your keys stay at the top level.
body and sendOptions.body are extra JSON for this request. They shallow-merge with the chat forwardedProps. On a key collision, sendOptions.body wins. sendOptions.whenBusy overrides the queue policy for this send.
The framework hooks expose sendMessage(content, sendOptions) with no positional body. Pass { body } there.
import { client } from "./client";
await client.sendMessage({
content: "Summarize the attached files",
metadata: { author: { id: "user-42", name: "Dana" } },
});
await client.sendMessage("Summarize the attached files", undefined, {
body: { attachmentIds: ["att_1", "att_2"] },
whenBusy: "interrupt",
});On the server, read 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, threadId } = await chatParamsFromRequest(request);
const attachmentIds = forwardedProps.attachmentIds;
if (Array.isArray(attachmentIds)) {
// Look up the uploads. Do not add them to `messages`.
}
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
});
return toServerSentEventsResponse(stream);
}If append() starts the stream, it resolves after the full HTTP response is processed. If a stream is already in progress, the call queues the send, and the promise can resolve before the queued response is processed. A RUN_FINISHED with finishReason: "tool_calls" does not end the wait when the agent loop goes on in the same response.
append() copies metadata from a UIMessage onto the stored message.
After stop(), a later addToolResult() for the stopped turn is ignored. A new user message starts a new turn, and addToolResult() applies to that turn.
getSnapshot() and subscribeSnapshot(listener)#
The snapshot has messages, status, isLoading, error, queue, runId, isSubscribed, connectionStatus, sessionGenerating, hasOlderMessages, isHydrating, and interruptState.
- The snapshot, messages, queue, and their entries are frozen copies. messages.push throws. Use setMessagesManually.
- interruptState is not copied.
- subscribeSnapshot(listener) returns an unsubscribe function. It does not fire with the current value, so read getSnapshot() first.
- subscribeSnapshot is not subscribe(). subscribe() starts the live connection loop.
Connection adapters#
Each adapter pairs with a server response helper from @tanstack/ai. See Transports.
| Adapter | Server pair | Use it when |
|---|---|---|
| fetchServerSentEvents(url, options?) | toServerSentEventsResponse() | Default for browsers. |
| fetchHttpStream(url, options?) | toHttpResponse() | You want newline-delimited JSON. |
| fetchJson(url, options?) | toJsonResponse() | The host cannot stream. See JSON. |
| xhrHttpStream(url, options?) | toHttpResponse() | React Native and Expo. See React Native. |
| xhrServerSentEvents(url, options?) | toServerSentEventsResponse() | XHR progress events work better than streaming fetch. |
| webSocket(url, options?) | toWebSocketResponse() | You want one socket for many runs. See WebSockets. |
| stream(factory), rpcStream(call) | Any AsyncIterable<StreamChunk> | You call a server function directly. See Server Functions. |
import { fetchServerSentEvents } from "@tanstack/ai-client";
const connection = fetchServerSentEvents("/api/chat", {
headers: { Authorization: "Bearer token" },
});url can be a string or a function that returns one. options can be an object or a function that returns one per request.
Adapter options#
Fetch adapters take headers, credentials, signal, body, fetchClient, and reconnect.
XHR adapters take headers, withCredentials, signal, body, xhrFactory, and reconnect.
fetchJson() also takes pollIntervalMs (default 250). When a reply has done: false, it waits that long and sends GET ?runId=<id>&offset=<offset>. Serve that GET with resumeJsonResponse().
The adapter body merges into the AG-UI forwardedProps. The chat forwardedProps and the per-send body override it.
fetchHttpStream() needs streaming fetch, Response.body.getReader(), and TextDecoder. If the runtime has none of these, it throws UnsupportedResponseStreamError. Use the XHR adapters in React Native and Expo.
stream(factory)#
Builds a custom adapter from a function that returns AsyncIterable<StreamChunk>. The function gets (messages, data, abortSignal, input). data is the merged forwardedProps. input is the AG-UI RunAgentInput for the run, the same body the HTTP adapters send. Read it on the server with chatParamsFromRequestBody.
import { stream } from "@tanstack/ai-client";
import { chatOnServer } from "./server";
const connection = stream((_messages, _data, signal, input) =>
chatOnServer({ input, signal }),
);Stream errors#
- UnsupportedResponseStreamError: a fetch adapter found no Response.body, getReader(), or TextDecoder.
- StreamTruncatedError: an SSE or NDJSON stream ended in the middle of a line. A server, a proxy, or the network cut the connection.
- DurableStreamIncompleteError and StreamReconnectLimitError: a resumable stream did not finish. See Resumable Streams.
Persistence adapters#
Browser storage adapters for persistence. Each one keeps the messages and the resume state for a threadId. See Client Persistence.
| Adapter | Storage |
|---|---|
| localStoragePersistence(options?) | localStorage |
| sessionStoragePersistence(options?) | sessionStorage |
| indexedDBPersistence(options?) | IndexedDB. The default databaseName is "tanstack-ai". |
They throw StorageUnavailableError when the browser storage is not available.
createChatClientOptions(options)#
Returns the options unchanged, with full type inference. Use it to define options outside a hook and to get the message type.
import {
createChatClientOptions,
fetchServerSentEvents,
type InferChatMessages,
} from "@tanstack/ai-client";
import { searchProducts } from "./tools";
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
tools: [searchProducts],
});
type ChatMessages = InferChatMessages<typeof chatOptions>;A plain tools array keeps tool names, inputs, and outputs typed. You do not need as const. When you narrow with part.name === "searchProducts", part.input and part.output get their types from the schemas of that tool.
Client context stays typed too. If a .client<ClientContext>() tool needs context, context becomes required. It stays on the client. To send values to the server, use forwardedProps, then validate them and pass them to chat({ context }).
clientTools(...tools)#
Returns its arguments as a typed tuple. A plain array already gives the same types, so use this only to build a shared tools tuple outside the options call.
import { clientTools } from "@tanstack/ai-client";
import { searchProducts, showToast } from "./tools";
export const tools = clientTools(searchProducts, showToast);GenerationClient#
Runs one generation request (image, audio, speech, transcription, summary, or your own type) and keeps its result. The framework generation hooks wrap it. See Generation Hooks.
import type { ImageGenerationResult } from "@tanstack/ai";
import {
GenerationClient,
fetchServerSentEvents,
type ImageGenerateInput,
} from "@tanstack/ai-client";
const generation = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
connection: fetchServerSentEvents("/api/generate/image"),
threadId: "generation",
onResult: (result) => console.log(result.images.length),
});
await generation.generate({ prompt: "A mountain at sunset" });- Options: threadId (required), connection or fetcher, body, persistence, byok, byokProvider, devtools, onResult, onError, onProgress, onChunk.
- Methods: generate(input), stop(), reset(), updateOptions(options), dispose(), getResult(), getIsLoading(), getError(), getStatus().
- Snapshot: result, isLoading, error, status ("idle", "generating", "success", or "error"), and runId.
VideoGenerationClient does the same for video jobs. Its snapshot adds jobId and videoStatus, and it takes onJobCreated and onStatusUpdate.
RealtimeClient#
Runs a voice conversation with a realtime model. Pass getToken and adapter. Methods: connect(), disconnect(), startListening(), stopListening(), interrupt(), sendText(text), sendImage(data, mimeType), updateSession(config), and destroy(). See Realtime Chat.
When connect() succeeds, it clears the previous transcript. A transcript that arrives while the microphone starts is kept. A failed connection keeps the previous transcript. Snapshot parts are frozen at runtime, but their TypeScript type stays mutable. Tool input, output, and subagent transcripts on a snapshot are copies.
AudioRecorder#
Records audio from the microphone. Methods: start(), stop() (resolves to the recording), and cancel(). See Audio Recording.
Listeners on the other clients#
GenerationClient, VideoGenerationClient, RealtimeClient, and ByokClient use subscribe(listener) and getSnapshot():
- The listener gets no argument. Call getSnapshot() inside it.
- A listener that throws does not stop the other listeners. The error goes to reportError in the browser and to console.error in other runtimes.
AudioRecorder.subscribe passes the new state: subscribe((state) => ...). getSnapshot() returns that same state.
registerWebMCPTools(tools, options)#
Exposes client tools through the browser WebMCP API. Abort options.signal to remove the tools. See WebMCP Tools.
import { registerWebMCPTools } from "@tanstack/ai-client";
import { searchProducts } from "./tools";
const controller = new AbortController();
await registerWebMCPTools([searchProducts], {
signal: controller.signal,
toolOptions: {
searchProducts: {
title: "Search products",
annotations: { readOnlyHint: true },
},
},
});
// Later, when the owner of these tools goes away:
controller.abort();| Option | What it does |
|---|---|
| signal | Required. Aborting it removes all tools of this call. |
| toolOptions | Per tool name: title and annotations (readOnlyHint, untrustedContentHint). |
| context | Client tool runtime context. Required when a tool declares a required context. |
- It returns Promise<void>. During server rendering, in an insecure context, or when WebMCP is not there, it resolves and registers nothing.
- It validates Standard Schema inputs and outputs.
- A tool with needsApproval: true makes the registration fail. If registration fails, the function removes the tools it registered during the call.
Option types: RegisterWebMCPToolsOptions, WebMCPToolOptionsByName, WebMCPToolOptions, WebMCPToolAnnotations.
getWebMCPTools(options?) and subscribeWebMCPTools(listener, options)#
Read the tools that the page registered with WebMCP, as client tools. See Page WebMCP Tools in Chat.
- getWebMCPTools({ filter? }) resolves to the current tools, or [] when WebMCP is not there.
- subscribeWebMCPTools(listener, { signal, filter?, onError? }) calls listener with the tools each time the page adds or removes one. Abort signal to stop.
filter gets a WebMCPPageTool (name, title, description, inputSchema, origin, annotations). Return false to skip it.
createMcpAppBridge(options)#
Creates the bridge that lets an MCP App widget call tools and send chat messages. Options: threadId, callEndpoint, chat, fetchImpl, onLink. See MCP Apps.
defineByok#
Creates a headless BYOK keyring. Import it from @tanstack/ai-client/byok. Pass the keyring to ChatClient, useChat, or a generation hook. See Bring Your Own Key.
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
export const byok = defineByok({
storage: defaultByokStorage(),
});Factory options#
- storage: a KeyringStorage. Default: memoryStorage(), which keeps keys for the session only.
- providers: descriptors whose with companions the keyring adds. With [cloudflareByok, cloudflareAccountByok], a send for cloudflare carries the token and the account id headers. prepare asks for each missing one.
Methods#
| Method | What it does |
|---|---|
| update(provider, key) | Saves a key for a provider slug ([a-z][a-z0-9-]{0,63}), then updates the snapshot. Throws if the id is not a slug, the key is empty, or storage fails. |
| update(key) | Saves a key for the current prompt provider. Throws if prompt is null. |
| clear(provider?) | Removes one key, or all keys when you omit provider. |
| unlock() | Decrypts unlockable storage (passkey). Does nothing for memory storage. |
| headers(provider) | Returns the x-byok-* headers for that slug and its companions. |
| prepare(provider?) | Waits for hydration, then unlocks if needed. If the key for provider or a companion is empty and the server does not cover it, it throws ByokBlockedError and sets prompt. |
| ready() | Resolves when the first load from storage ends. |
| setServerCoverage(flags) | true: do not block a send when the browser has no key, because the relay can use its env. false: block (default). A record sets flags per slug. |
| request(provider, reason) | Sets prompt to { provider, reason }. reason is "missing", "locked", or "invalid". |
| getSnapshot(), subscribe(listener) | Read the snapshot, and listen for changes. |
| keys() | Returns a copy of the raw keyring. Do not show it in the UI. |
The chat and generation clients throw if no provider slug resolves. They do not send every stored key.
Snapshot#
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 with a key, a lock, or a validation result appear. A missing entry means no key.
- masked is the last four characters of the key. { state: "empty" } has no masked, so check with "masked" in status.
- storageError is set when loading from storage fails.
- The snapshot never holds the raw key.
Storage#
| Storage | What it does |
|---|---|
| defaultByokStorage(options?) | Passkey-encrypted IndexedDB when WebAuthn is available in a secure context. Otherwise memoryStorage(), with a warning. The first save still throws if the authenticator has no PRF support. |
| memoryStorage() | Keeps keys on the ByokClient instance. Keys are gone when the page reloads. |
| passkeyStorage(options?) | Encrypts the keyring with a WebAuthn passkey. The first save throws if the authenticator has no PRF support. |
| KeyringStorage | The storage interface: { id, label, persistent, unlockable?, peek?, load, save, clear }. |
The library ships no key dialog. Call byok.update(provider, value) from your own UI.
Types#
UIMessage#
interface UIMessage<TTools = any> {
id: string;
role: "system" | "user" | "assistant" | "activity";
name?: string;
parts: Array<MessagePart<TTools>>;
createdAt?: Date;
metadata?: Record<string, any>;
}metadata is an AG-UI bag. TanStack writes the tanstack key. Your keys stay at the top level.
MessagePart#
| Part | type | What it holds |
|---|---|---|
| TextPart | "text" | content: the text. |
| ThinkingPart | "thinking" | content: the model reasoning. See Thinking Content. |
| ToolCallPart | "tool-call" | id, name, arguments (JSON string, can be partial while it streams), input, output, state, and approval for tools with needsApproval: true. |
| ToolResultPart | "tool-result" | toolCallId, content, state, error, outcome, metadata. |
| ImagePart, AudioPart, VideoPart, DocumentPart | media types | Media content. See Multimodal Content. |
| StructuredOutputPart | "structured-output" | Typed data and partial. See Structured Outputs. |
| ActivityPart, UIResourcePart, SubagentPart | other | Activity records, MCP App resources, and subagent runs. |
Thinking parts are for the UI only. The client does not send them back to the model.
With a typed tools array, ToolCallPart is a union with name as the key. Narrow on part.name to get typed input and output. The approval field exists only on tools with needsApproval: true, so narrow by part.name or check "approval" in part first.
States#
type ToolCallState =
| "awaiting-input"
| "input-streaming"
| "input-complete"
| "approval-requested"
| "approval-responded"
| "complete"
| "error";
type ToolResultState = "streaming" | "complete" | "error";