Client tools execute in the browser, enabling UI updates, local storage access, and browser API interactions. Unlike server tools, client tools don't have an execute function in their server definition.
sequenceDiagram
participant LLM Service
participant Server
participant Browser
participant ClientTool
LLM Service->>Server: tool_call chunk<br/>{name: "updateUI", args: {...}}
Server->>Server: Check if tool has<br/>server execute
Note over Server: No execute function<br/>= client tool
Server->>Browser: Forward tool-input-available<br/>chunk 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 chunkssequenceDiagram
participant LLM Service
participant Server
participant Browser
participant ClientTool
LLM Service->>Server: tool_call chunk<br/>{name: "updateUI", args: {...}}
Server->>Server: Check if tool has<br/>server execute
Note over Server: No execute function<br/>= client tool
Server->>Browser: Forward tool-input-available<br/>chunk 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 chunksClient tools use the same toolDefinition() API but with the .client() method:
// tools/definitions.ts - Shared between server and client
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const updateUIDef = toolDefinition({
name: "update_ui",
description: "Update the UI with new information",
inputSchema: z.object({
message: z.string().meta({ description: "Message to display" }),
type: z.enum(["success", "error", "info"]).meta({ description: "Message type" }),
}),
outputSchema: z.object({
success: z.boolean(),
}),
});
export const saveToLocalStorageDef = toolDefinition({
name: "save_to_local_storage",
description: "Save data to browser local storage",
inputSchema: z.object({
key: z.string().meta({ description: "Storage key" }),
value: z.string().meta({ description: "Value to store" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});// tools/definitions.ts - Shared between server and client
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const updateUIDef = toolDefinition({
name: "update_ui",
description: "Update the UI with new information",
inputSchema: z.object({
message: z.string().meta({ description: "Message to display" }),
type: z.enum(["success", "error", "info"]).meta({ description: "Message type" }),
}),
outputSchema: z.object({
success: z.boolean(),
}),
});
export const saveToLocalStorageDef = toolDefinition({
name: "save_to_local_storage",
description: "Save data to browser local storage",
inputSchema: z.object({
key: z.string().meta({ description: "Storage key" }),
value: z.string().meta({ description: "Value to store" }),
}),
outputSchema: z.object({
saved: z.boolean(),
}),
});To give the LLM access to client tools, pass the tool definitions (not implementations) to the server when creating the chat:
// api/chat/route.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
});
return toServerSentEventsResponse(stream);
}// api/chat/route.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
});
return toServerSentEventsResponse(stream);
}Create client implementations with automatic execution and full type safety:
// app/chat.tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import {
clientTools,
createChatClientOptions,
type InferChatMessages,
type ToolCallPart,
} from "@tanstack/ai-client";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
// Step 1: Create client implementations (module scope)
const updateUI = updateUIDef.client((input) => {
// Update UI state - fully typed!
showNotification({ message: input.message, type: input.type });
return { success: true };
});
const saveToLocalStorage = saveToLocalStorageDef.client((input) => {
localStorage.setItem(input.key, input.value);
return { saved: true };
});
// Step 2: Create typed tools array (no 'as const' needed!)
const tools = clientTools(updateUI, saveToLocalStorage);
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
// Step 3: Infer message types for full type safety
type ChatMessages = InferChatMessages<typeof chatOptions>;
function ChatComponent() {
const { messages, sendMessage, isLoading } = useChat(chatOptions);
// Step 4: Render with full type safety
return (
<div>
{messages.map((message) => (
<MessageComponent key={message.id} message={message} />
))}
</div>
);
}
// Messages component with full type safety
function MessageComponent({ message }: { message: ChatMessages[number] }) {
return (
<div>
{message.parts.map((part) => {
if (part.type === "text") {
return <p>{part.content}</p>;
}
if (part.type === "tool-call") {
// ✅ part.name is narrowed to specific tool names
if (part.name === "update_ui") {
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
return (
<div>
Tool: {part.name}
{part.output && <span>✓ Success</span>}
</div>
);
}
}
})}
</div>
);
}// app/chat.tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import {
clientTools,
createChatClientOptions,
type InferChatMessages,
type ToolCallPart,
} from "@tanstack/ai-client";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
// Step 1: Create client implementations (module scope)
const updateUI = updateUIDef.client((input) => {
// Update UI state - fully typed!
showNotification({ message: input.message, type: input.type });
return { success: true };
});
const saveToLocalStorage = saveToLocalStorageDef.client((input) => {
localStorage.setItem(input.key, input.value);
return { saved: true };
});
// Step 2: Create typed tools array (no 'as const' needed!)
const tools = clientTools(updateUI, saveToLocalStorage);
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
// Step 3: Infer message types for full type safety
type ChatMessages = InferChatMessages<typeof chatOptions>;
function ChatComponent() {
const { messages, sendMessage, isLoading } = useChat(chatOptions);
// Step 4: Render with full type safety
return (
<div>
{messages.map((message) => (
<MessageComponent key={message.id} message={message} />
))}
</div>
);
}
// Messages component with full type safety
function MessageComponent({ message }: { message: ChatMessages[number] }) {
return (
<div>
{message.parts.map((part) => {
if (part.type === "text") {
return <p>{part.content}</p>;
}
if (part.type === "tool-call") {
// ✅ part.name is narrowed to specific tool names
if (part.name === "update_ui") {
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
return (
<div>
Tool: {part.name}
{part.output && <span>✓ Success</span>}
</div>
);
}
}
})}
</div>
);
}Client tools are automatically executed when the model calls them. The flow is:
Client tools can receive typed runtime context as their second argument. This context is local to the ChatClient or framework hook instance and is not serialized to the server.
import { createChatClientOptions, clientTools } from "@tanstack/ai-client";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
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"),
tools: clientTools(showToast),
context: {
activeProjectId,
toast: (message) => toast(message),
},
});
const chat = useChat(chatOptions);import { createChatClientOptions, clientTools } from "@tanstack/ai-client";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
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"),
tools: clientTools(showToast),
context: {
activeProjectId,
toast: (message) => toast(message),
},
});
const chat = useChat(chatOptions);Use context for local browser dependencies. If the server also needs a value from the client, send it with forwardedProps, validate it in your route, and map it into server chat({ context }) explicitly. See Runtime Context for the full pattern.
The isomorphic architecture provides complete end-to-end type safety:
messages.forEach((message) => {
message.parts.forEach((part) => {
if (part.type === "tool-call" && part.name === "update_ui") {
// ✅ TypeScript knows part.name is literally "update_ui"
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
console.log(part.input.message); // ✅ Fully typed!
if (part.output) {
console.log(part.output.success); // ✅ Fully typed!
}
}
});
});messages.forEach((message) => {
message.parts.forEach((part) => {
if (part.type === "tool-call" && part.name === "update_ui") {
// ✅ TypeScript knows part.name is literally "update_ui"
// ✅ part.input is typed as { message: string, type: "success" | "error" | "info" }
// ✅ part.output is typed as { success: boolean } | undefined
console.log(part.input.message); // ✅ Fully typed!
if (part.output) {
console.log(part.output.success); // ✅ Fully typed!
}
}
});
});A tool-call part moves through a small set of observable ToolCallState values you can surface in the UI to indicate progress:
The ToolCallState union includes a complete value, but the runtime never transitions a tool-call part to it — a finished call settles at input-complete. Once the tool runs, the result appears two ways: part.output becomes populated on the tool-call part, and a sibling tool-result part is emitted whose own state is complete or error (the error case carries part.error). Use the tool-call states for loading/streaming progress and the tool-result part for final success/error feedback.
import type { ToolCallPart } from "@tanstack/ai-client";
function ToolCallDisplay({ part }: { part: ToolCallPart }) {
if (part.state === "awaiting-input") {
return <div>🔄 Waiting for arguments...</div>;
}
if (part.state === "input-streaming") {
return <div>📥 Receiving arguments...</div>;
}
if (part.state === "input-complete") {
return <div>✓ Arguments received, running tool...</div>;
}
// Completion shows up as a populated `part.output` (and as a sibling
// `tool-result` part whose state is `complete` / `error`).
if (part.output) {
return <div>✅ Tool complete</div>;
}
return null;
}import type { ToolCallPart } from "@tanstack/ai-client";
function ToolCallDisplay({ part }: { part: ToolCallPart }) {
if (part.state === "awaiting-input") {
return <div>🔄 Waiting for arguments...</div>;
}
if (part.state === "input-streaming") {
return <div>📥 Receiving arguments...</div>;
}
if (part.state === "input-complete") {
return <div>✓ Arguments received, running tool...</div>;
}
// Completion shows up as a populated `part.output` (and as a sibling
// `tool-result` part whose state is `complete` / `error`).
if (part.output) {
return <div>✅ Tool complete</div>;
}
return null;
}Tools can be implemented for both server and client, enabling flexible execution:
// Define once
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 in 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 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" };
});
// Server: Pass definition for client execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartServer] }); // Server will execute// Define once
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 in 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 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" };
});
// Server: Pass definition for client execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
chat({ adapter: openaiText('gpt-5.5'), messages: [], tools: [addToCartServer] }); // Server will execute