- 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 bound interrupts (resolveInterrupt), generic middleware interrupts with defineInterrupt(), lazy tool discovery with lazy:true, rendering ToolCallPart and ToolResultPart in UI.
- type
- sub-skill
- library
- tanstack-ai
- library_version
- 0.42.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.
The four files below share one scope, so later files use the earlier exports directly.
```typescript group=product-catalog
// 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 group=product-catalog
// tools/server.ts (uses getProductsDef from tools/definitions.ts)
import { db } from './db'
export const getProducts = getProductsDef.server(async ({ query, limit }) => {
const results: Array<{ id: string; name: string; price: number }> =
await db.products.search(query, { limit: limit ?? 10 })
return {
products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
}
})
```
```typescript group=product-catalog
// api/chat/route.ts (uses getProducts and updateCartUIDef from tools/)
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: [getProducts, updateCartUIDef], // server tool + client definition
})
return toServerSentEventsResponse(stream)
}
```
```tsx group=product-catalog
// app/chat.tsx (uses updateCartUIDef from tools/definitions.ts)
import {
useChat,
fetchServerSentEvents,
createChatClientOptions,
type InferChatMessages,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
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,
})
const { messages, sendMessage } = useChat(chatOptions)
// InferChatMessages ties part types to the configured tools when needed:
// type Messages = InferChatMessages<typeof chatOptions>
return (
<div>
<span>Cart: {cartCount}</span>
{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
### Generic middleware interrupts
Use `defineInterrupt()` when middleware needs typed data from the client. This
does not replace `needsApproval`. Tool approval asks whether a tool can run.
Generic interrupts ask for application data at a chat lifecycle boundary.
Define the interrupt once. Register it with both `chat({ interrupts })` and
`useChat({ interrupts })`. Emit it only from `onInterruptBoundary`, then read
the typed result in `onInterruptResolution`.
```typescript
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'
const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.object({ approved: z.boolean() }),
})
const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeTools') return
return {
interrupts: [
reviewPlan.interrupt({
key: 'release-plan',
reason: 'review-required',
message: 'Approve this plan?',
payload: { title: 'Release plan' },
}),
],
}
},
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}
```
Several middleware can request generic interrupts at one boundary. They share
one AG-UI interrupt batch with tool approvals. A continuation starts only after
the client resolves or cancels every bound item. `stop` is more restrictive than
`cancel`, which is more restrictive than `continue`.
Do not emit raw AG-UI interrupt events from middleware. Use the boundary hook
so the engine creates one terminal event and persistence records the batch.
### 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 { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
import { db } from './db'
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:
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: [getUserData],
})
return toServerSentEventsResponse(stream)
}
```
### 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 group=notification-tool
// tools/definitions.ts
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 group=notification-tool
// api/chat/route.ts (uses showNotificationDef from tools/definitions.ts)
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: [showNotificationDef],
})
return toServerSentEventsResponse(stream)
}
```
Client -- pass `.client()` implementation:
```tsx group=notification-tool
// app/chat.tsx (uses showNotificationDef from tools/definitions.ts)
import {
useChat,
fetchServerSentEvents,
createChatClientOptions,
} from '@tanstack/ai-react'
import { clientTools } from '@tanstack/ai-client'
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 with
`RUN_FINISHED.outcome.type === 'interrupt'`. The primary client API is bound
`interrupts` + `resolveInterrupt` / `resolveInterrupts` / `cancel`.
`addToolApprovalResponse` and `pendingInterrupts` remain as deprecated
compatibility shims during migration.
```typescript group=email-approval
// tools/email.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'
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 }
})
```
Server route must forward `resume` / `parentRunId` (via `chatParamsFromRequest`
or equivalent). Client -- render bound interrupts:
```tsx group=email-approval
// app/chat.tsx (registers sendEmailDef so the approval interrupt is typed)
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function ChatPage() {
const { messages, interrupts, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
tools: [sendEmailDef],
})
return (
<div>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'tool-approval') return null
return (
<div key={interrupt.id}>
<p>Approve "{interrupt.toolName}"?</p>
<pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
<button onClick={() => interrupt.resolveInterrupt(false)}>
Deny
</button>
<button onClick={() => interrupt.cancel()}>Cancel</button>
</div>
)
})}
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part) =>
part.type === 'text' ? (
<p key={part.content}>{part.content}</p>
) : null,
)}
</div>
))}
</div>
)
}
```
Batch all pending approvals with `resolveInterrupts` (void — submission is
async; watch `resuming` / `interruptErrors`):
```tsx group=email-approval
function ApproveAllButton() {
const { resolveInterrupts, resuming } = useChat({
connection: fetchServerSentEvents('/api/chat'),
tools: [sendEmailDef],
})
// Payloadless tool-approvals only
const approveAll = () => resolveInterrupts(true)
GitHubで見る