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.
Client 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().describe("Message to display"),
type: z.enum(["success", "error", "info"]).describe("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().describe("Storage key"),
value: z.string().describe("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().describe("Message to display"),
type: z.enum(["success", "error", "info"]).describe("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().describe("Storage key"),
value: z.string().describe("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, toServerSentEventsStream } from "@tanstack/ai";
import { openai } 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: openai(),
messages,
model: "gpt-4o",
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
});
return toServerSentEventsStream(stream);
}
// api/chat/route.ts
import { chat, toServerSentEventsStream } from "@tanstack/ai";
import { openai } 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: openai(),
messages,
model: "gpt-4o",
tools: [updateUIDef, saveToLocalStorageDef], // Pass definitions
});
return toServerSentEventsStream(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
} from "@tanstack/ai-client";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
import { useState } from "react";
function ChatComponent() {
const [notification, setNotification] = useState(null);
// Step 1: Create client implementations
const updateUI = updateUIDef.client((input) => {
// Update React state - fully typed!
setNotification({ 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>;
const { messages, sendMessage, isLoading } = useChat(chatOptions);
// Step 4: Render with full type safety
return (
<div>
{messages.map((message) => (
<MessageComponent key={message.id} message={message} />
))}
{notification && (
<div className={`notification ${notification.type}`}>
{notification.message}
</div>
)}
</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
} from "@tanstack/ai-client";
import { updateUIDef, saveToLocalStorageDef } from "@/tools/definitions";
import { useState } from "react";
function ChatComponent() {
const [notification, setNotification] = useState(null);
// Step 1: Create client implementations
const updateUI = updateUIDef.client((input) => {
// Update React state - fully typed!
setNotification({ 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>;
const { messages, sendMessage, isLoading } = useChat(chatOptions);
// Step 4: Render with full type safety
return (
<div>
{messages.map((message) => (
<MessageComponent key={message.id} message={message} />
))}
{notification && (
<div className={`notification ${notification.type}`}>
{notification.message}
</div>
)}
</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. No manual onToolCall callback needed! The flow is:
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!
}
}
});
});
Client tools go through a small set of observable lifecycle states you can surface in the UI to indicate progress:
Use these states to show loading indicators, streaming progress, and final success/error feedback. The example below maps each state to a simple UI message.
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, executing...</div>;
}
if (part.output) {
return <div>✅ Tool completed successfully</div>;
}
return null;
}
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, executing...</div>;
}
if (part.output) {
return <div>✅ Tool completed successfully</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({ tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
chat({ 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({ tools: [addToCartDef] }); // Client will execute
// Or pass server implementation for server execution
chat({ tools: [addToCartServer] }); // Server will execute
