@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
412 lines (347 loc) • 11.6 kB
Markdown
---
name: ai-core/tool-calling
description: >
Isomorphic tool system: toolDefinition() with Zod schemas,
.server() and .client() implementations, passing tools to both
chat() on server and useChat/clientTools on client, tool approval
flows with needsApproval and addToolApprovalResponse(), lazy tool
discovery with lazy:true, rendering ToolCallPart and ToolResultPart
in UI.
type: sub-skill
library: tanstack-ai
library_version: '0.10.0'
sources:
- 'TanStack/ai:docs/tools/tools.md'
- 'TanStack/ai:docs/tools/server-tools.md'
- 'TanStack/ai:docs/tools/client-tools.md'
- 'TanStack/ai:docs/tools/tool-approval.md'
- 'TanStack/ai:docs/tools/lazy-tool-discovery.md'
---
# Tool Calling
This skill builds on ai-core. Read it first for critical rules.
## Setup
Complete end-to-end example: shared definition, server tool, client tool, server route, React client.
```typescript
// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const getProductsDef = toolDefinition({
name: 'get_products',
description: 'Search for products in the catalog',
inputSchema: z.object({
query: z.string().meta({ description: 'Search keyword' }),
limit: z.number().optional().meta({ description: 'Max results' }),
}),
outputSchema: z.object({
products: z.array(
z.object({ id: z.string(), name: z.string(), price: z.number() }),
),
}),
})
export const updateCartUIDef = toolDefinition({
name: 'update_cart_ui',
description: 'Update the shopping cart UI with item count',
inputSchema: z.object({ itemCount: z.number(), message: z.string() }),
outputSchema: z.object({ displayed: z.boolean() }),
})
```
```typescript
// tools/server.ts
import { getProductsDef } from './definitions'
export const getProducts = getProductsDef.server(async ({ query, limit }) => {
const results = await db.products.search(query, { limit: limit ?? 10 })
return {
products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
}
})
```
```typescript
// api/chat/route.ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { getProducts } from '@/tools/server'
import { updateCartUIDef } from '@/tools/definitions'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
tools: [getProducts, updateCartUIDef], // server tool + client definition
})
return toServerSentEventsResponse(stream)
}
```
```typescript
// app/chat.tsx
import {
useChat,
fetchServerSentEvents,
clientTools,
createChatClientOptions,
type InferChatMessages,
} from "@tanstack/ai-react";
import { updateCartUIDef } from "@/tools/definitions";
import { useState } from "react";
function ChatPage() {
const [cartCount, setCartCount] = useState(0);
const updateCartUI = updateCartUIDef.client((input) => {
setCartCount(input.itemCount);
return { displayed: true };
});
const tools = clientTools(updateCartUI);
const chatOptions = createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools,
});
type Messages = InferChatMessages<typeof chatOptions>;
const { messages, sendMessage } = useChat(chatOptions);
return (
<div>
<span>Cart: {cartCount}</span>
{(messages as Messages).map((msg) => (
<div key={msg.id}>
{msg.parts.map((part) => {
if (part.type === "text") return <p>{part.content}</p>;
if (part.type === "tool-call") {
return <div key={part.id}>Tool: {part.name} ({part.state})</div>;
}
return null;
})}
</div>
))}
</div>
);
}
```
## Core Patterns
### Pattern 1: Server-Only Tool
Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tools })`.
The server executes it automatically. The client never runs code for this tool.
```typescript
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const getUserDataDef = toolDefinition({
name: 'get_user_data',
description: 'Look up user by ID',
inputSchema: z.object({
userId: z.string().meta({ description: "The user's ID" }),
}),
outputSchema: z.object({ name: z.string(), email: z.string() }),
})
const getUserData = getUserDataDef.server(async ({ userId }) => {
const user = await db.users.findUnique({ where: { id: userId } })
return { name: user.name, email: user.email }
})
// In your route handler:
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
tools: [getUserData],
})
```
### Pattern 2: Client-Only Tool
Pass the bare definition (no `.server()`) to `chat({ tools })` so the LLM knows
about it. Pass the `.client()` implementation to `useChat` via `clientTools()`.
```typescript
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const showNotificationDef = toolDefinition({
name: 'show_notification',
description: 'Display a toast notification to the user',
inputSchema: z.object({
message: z.string(),
type: z.enum(['success', 'error', 'info']),
}),
outputSchema: z.object({ shown: z.boolean() }),
})
```
Server -- pass definition only (no execute function):
```typescript
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
tools: [showNotificationDef],
})
```
Client -- pass `.client()` implementation:
```typescript
import {
useChat,
fetchServerSentEvents,
clientTools,
createChatClientOptions,
} from "@tanstack/ai-react";
import { showNotificationDef } from "@/tools/definitions";
import { useState } from "react";
function ChatPage() {
const [toast, setToast] = useState<string | null>(null);
const showNotification = showNotificationDef.client((input) => {
setToast(input.message);
setTimeout(() => setToast(null), 3000);
return { shown: true };
});
const { messages, sendMessage } = useChat(
createChatClientOptions({
connection: fetchServerSentEvents("/api/chat"),
tools: clientTools(showNotification),
})
);
return (
<div>
{toast && <div className="toast">{toast}</div>}
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part) =>
part.type === "text" ? <p>{part.content}</p> : null
)}
</div>
))}
</div>
);
}
```
### Pattern 3: Tool with Approval Flow
Set `needsApproval: true` in the definition. Execution pauses until the client
calls `addToolApprovalResponse()`. The part has `state: "approval-requested"`
and an `approval` object with an `id`.
```typescript
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const sendEmailDef = toolDefinition({
name: 'send_email',
description: 'Send an email to a recipient',
inputSchema: z.object({
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({ success: z.boolean(), messageId: z.string() }),
needsApproval: true,
})
export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
const result = await emailService.send({ to, subject, body })
return { success: true, messageId: result.id }
})
```
Client -- render approval UI and respond:
```typescript
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
function ChatPage() {
const { messages, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part) => {
if (part.type === "text") return <p>{part.content}</p>;
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<div key={part.id}>
<p>Approve "{part.name}"?</p>
<pre>{part.arguments}</pre>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: false,
})
}
>
Deny
</button>
</div>
);
}
return null;
})}
</div>
))}
</div>
);
}
```
### Pattern 4: Lazy Tool Discovery
Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic
`__lazy__tool__discovery__` tool and discovers schemas on demand. Saves tokens.
```typescript
import {
toolDefinition,
chat,
toServerSentEventsResponse,
maxIterations,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const getProductsDef = toolDefinition({
name: 'getProducts',
description: 'List all products',
inputSchema: z.object({}),
outputSchema: z.array(
z.object({ id: z.number(), name: z.string(), price: z.number() }),
),
})
const getProducts = getProductsDef.server(async () => db.products.findMany())
const compareProductsDef = toolDefinition({
name: 'compareProducts',
description: 'Compare two or more products side by side',
inputSchema: z.object({ productIds: z.array(z.number()).min(2) }),
lazy: true, // not sent to LLM upfront
})
const compareProducts = compareProductsDef.server(async ({ productIds }) => {
return db.products.findMany({ where: { id: { in: productIds } } })
})
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
tools: [getProducts, compareProducts],
agentLoopStrategy: maxIterations(20),
})
return toServerSentEventsResponse(stream)
}
```
The LLM sees `getProducts` and `__lazy__tool__discovery__` upfront.
To compare, it first calls `__lazy__tool__discovery__({ toolNames: ["compareProducts"] })`,
gets the full schema, then calls `compareProducts` directly.
Once discovered, a tool stays available for the conversation.
When all lazy tools are discovered, the discovery tool is removed automatically.
## Common Mistakes
### a. HIGH: Not passing tool definitions to both server and client
Server tools need `chat({ tools })`. Client tools need their definition in
`chat({ tools })` AND their `.client()` in `useChat({ tools: clientTools(...) })`.
Wrong -- tool only on server, client cannot execute:
```typescript
chat({ adapter, messages, tools: [myToolDef] })
useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
```
Wrong -- tool only on client, LLM does not know about it:
```typescript
chat({ adapter, messages }); // no tools
useChat({ ..., tools: clientTools(myToolDef.client(() => result)) });
```
Correct:
```typescript
chat({ adapter, messages, tools: [myToolDef] });
useChat({ ..., tools: clientTools(myToolDef.client((input) => ({ success: true }))) });
```
Source: docs/tools/tools.md
## Cross-References
- See also: ai-core/chat-experience/SKILL.md -- Tools are used within chat
- See also: `@tanstack/ai-code-mode` package skills -- Code Mode is an alternative to tools for complex multi-step operations