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, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
threadId,
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'),
threadId: 'support-42',
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, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
threadId,
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, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
threadId,
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'),
threadId: 'support-42',
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 were removed in 1.0.
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'),
threadId: 'support-42',
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'),
threadId: 'support-42',
tools: [sendEmailDef],
})
// Payloadless tool-approvals only
const approveAll = () => resolveInterrupts(true)
// Or per-item:
const approveEach = () =>
resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
interrupt.resolveInterrupt(true)
}
})