A client tool runs in the browser. Use it for a notification, localStorage, the clipboard, or navigation.
The server tells the model that the tool exists. When the model calls it, the browser runs your .client() function and sends the result back. Then the model continues.
Good jobs for a client tool:
The server and the browser both import this file. It has no implementation yet:
// tools/definitions.ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const savePreferenceDef = toolDefinition({
name: "save_preference",
description: "Save a user preference in the browser, such as the color theme",
inputSchema: z.object({
key: z.string().meta({ description: "Preference name, for example theme" }),
value: z.string().meta({ description: "Preference value, for example dark" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});On the server, pass the definition, not an implementation:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { savePreferenceDef } from "../tools/definitions";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
parentRunId: params.parentRunId,
...(params.resume ? { resume: params.resume } : {}),
tools: [savePreferenceDef],
});
return toServerSentEventsResponse(stream);
}The browser sends the tool result in a second request. parentRunId and resume let chat() find the paused call and continue it.
Create the implementation at module scope. Then add it to tools:
// app/chat.tsx
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
// The same definition as tools/definitions.ts. In your app, import it from that file.
const savePreferenceDef = toolDefinition({
name: "save_preference",
description: "Save a user preference in the browser, such as the color theme",
inputSchema: z.object({
key: z.string().meta({ description: "Preference name, for example theme" }),
value: z.string().meta({ description: "Preference value, for example dark" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});
const savePreference = savePreferenceDef.client((input) => {
localStorage.setItem(input.key, input.value);
return { saved: true };
});
export function Chat() {
const [input, setInput] = useState("");
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
tools: [savePreference],
});
return (
<>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === "text" ? <p key={index}>{part.content}</p> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage(input);
setInput("");
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit">Send</button>
</form>
</>
);
}Every framework takes the same tools option:
Ask your chat "Use the dark theme from now on." The model calls save_preference. Your browser saves theme as dark in localStorage, and the model confirms. Open the dev tools of your browser to see the saved value.
sequenceDiagram
participant LLM Service
participant Server
participant Browser
participant ClientTool
LLM Service->>Server: tool_call chunk<br/>{name: "save_preference", args: {...}}
Server->>Server: Check if tool has<br/>server execute
Note over Server: No execute function<br/>= client tool
Server->>Browser: RUN_FINISHED client-tool<br/>interrupt via SSE/HTTP
Browser->>Browser: Find registered<br/>client tool
Browser->>ClientTool: execute(args)
ClientTool->>ClientTool: Update UI,<br/>localStorage, etc.
ClientTool-->>Browser: Return result
Browser->>Server: POST tool result
Server->>LLM Service: Add tool_result<br/>to conversation
Note over LLM Service: Model uses result<br/>to continue
LLM Service-->>Server: Stream response
Server-->>Browser: Forward chunksYou do not write code for steps 2 to 5. useChat does them for you.
Each tool call is a tool-call part in the assistant message. When you pass the tools to the hook, a check on part.name gives you typed part.input and part.output:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { createChatClientOptions, type InferChatMessages } from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
// The same definition as tools/definitions.ts. In your app, import it from that file.
const savePreferenceDef = toolDefinition({
name: "save_preference",
description: "Save a user preference in the browser, such as the color theme",
inputSchema: z.object({
key: z.string().meta({ description: "Preference name, for example theme" }),
value: z.string().meta({ description: "Preference value, for example dark" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});
const savePreference = savePreferenceDef.client((input) => {
localStorage.setItem(input.key, input.value);
return { saved: true };
});
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
tools: [savePreference],
});
type ChatMessages = InferChatMessages<typeof chatOptions>;
function Message({ message }: { message: ChatMessages[number] }) {
return (
<div>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={index}>{part.content}</p>;
}
if (part.type === "tool-call" && part.name === "save_preference") {
// part.input is { key: string; value: string }
// part.output is { saved: boolean } | undefined
return (
<p key={part.id}>
Saving {part.input?.key}
{part.output?.saved ? " (done)" : "..."}
</p>
);
}
return null;
})}
</div>
);
}
export function Chat() {
const { messages } = useChat(chatOptions);
return (
<>
{messages.map((message) => (
<Message key={message.id} message={message} />
))}
</>
);
}A plain array in tools is enough for this type inference. You do not need as const or a wrapper. For every state that a tool call goes through, see Tool Architecture. For typed tool events, see Type-safe tool call events.
Add needsApproval: true to the definition. The tool first pauses on a tool-approval interrupt. You resolve only that decision. After the user approves, the client runs the .client() function and sends its result:
const approval = interrupts.find(
(interrupt) =>
interrupt.kind === 'tool-approval' &&
interrupt.toolName === 'delete_local_data',
)
if (
approval?.kind === 'tool-approval' &&
approval.toolName === 'delete_local_data'
) {
approval.resolveInterrupt(true)
}You never resolve the run of the tool by hand. The .client() function does that. If you register a tool with no .client() function and want to give the result yourself, call addToolResult. It checks the result against the output schema of the tool.
For approval forms, see Tool Approval Flow. For the approval lifecycle, see Interrupts.
For the ephemeral lifecycle and batches, see Interrupts.
A client tool gets typed runtime context as its second argument. This context belongs to the ChatClient or the hook instance. It is not sent to the server.
import { createChatClientOptions } from "@tanstack/ai-client";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
import { toast } from "./toast";
const activeProjectId = "";
type ClientContext = {
activeProjectId: string;
toast(message: string): void;
};
const showToast = toolDefinition({
name: "show_toast",
description: "Show a browser notification",
}).client<ClientContext>((_input, ctx) => {
ctx.context.toast(`Project ${ctx.context.activeProjectId} updated`);
return { ok: true };
});
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
tools: [showToast],
context: {
activeProjectId,
toast: (message) => toast(message),
},
});
const chat = useChat(chatOptions);Does the server also need a value from the client? Send it with forwardedProps, check it in your route, and put it in the server chat({ context }). See Runtime Context.
A definition can have a .server() and a .client() implementation. The tool that you pass to chat() decides where it runs:
import { toolDefinition, chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
import { db } from "./db";
const addToCartDef = toolDefinition({
name: "add_to_cart",
description: "Add item to shopping cart",
inputSchema: z.object({
itemId: z.string(),
quantity: z.number(),
}),
outputSchema: z.object({
success: z.boolean(),
cartId: z.string(),
}),
});
// Server implementation: store the cart in the database
const addToCartServer = addToCartDef.server(async (input) => {
const cart = await db.carts.create({
data: { itemId: input.itemId, quantity: input.quantity },
});
return { success: true, cartId: cart.id };
});
// Client implementation: update a local wishlist
const addToCartClient = addToCartDef.client((input) => {
const wishlist = JSON.parse(localStorage.getItem("wishlist") || "[]");
wishlist.push(input.itemId);
localStorage.setItem("wishlist", JSON.stringify(wishlist));
return { success: true, cartId: "local" };
});
// Pass the definition: the client runs the tool
chat({ adapter: openaiText("gpt-6.1-sol"), messages: [], tools: [addToCartDef], threadId: "thread-1" });
// Or pass the server implementation: the server runs the tool
chat({ adapter: openaiText("gpt-6.1-sol"), messages: [], tools: [addToCartServer], threadId: "thread-1" });Pass only one of the two to a chat() call. Both have the same name, and the tool names in one tools array must be unique.
Pass the definitions on the server, as in the quick setup. Then the server alone decides which tools the model sees, and a client cannot add tools that you did not allow.
To let the client declare its tools for each request through AG-UI RunAgentInput.tools, use mergeAgentTools. Read its security note first, because the client controls params.tools.
Your agent runs on another AG-UI server, such as Pydantic AI. It calls a frontend tool, then ends the run. useChat runs the tool and starts the next run with the result.
Register the client tools as in step 3. Point the connection at the endpoint of that server, for example fetchServerSentEvents("/agent").
The AG-UI spec ends such a run with RUN_FINISHED and a success outcome. The tool call gets no TOOL_CALL_RESULT. When that run ends:
These calls do not run:
WebMCP lets browser agents call your client tools. This path is separate from a TanStack chat run:
Register the same client tools with a framework wrapper or registerWebMCPTools. See WebMCP Tools for setup and security rules.
A plain array, tools: [toolA, toolB], already gives you typed tool names, inputs, and outputs. clientTools() does the same capture by name. Use it only to build a shared tool list outside the hook or the options call:
import { clientTools } from "@tanstack/ai-client";
import { toolDefinition } from "@tanstack/ai";
const notify = toolDefinition({
name: "notify",
description: "Show a notification",
}).client(() => ({ ok: true }));
// Same as `const tools = [notify]`
const tools = clientTools(notify);