The tool approval flow allows you to require user approval before executing sensitive tools, giving users control over actions like sending emails, making purchases, or deleting data. A tool call moves through the ToolCallState lifecycle:
The current client API exposes approvals as bound AG-UI interrupts. For the complete server/client lifecycle, atomic batch controls, generic interrupts, and recovery, see Interrupts. For deprecated API mapping, see Migrate to AG-UI interrupts.
After approval-responded the call executes (if approved). Although complete exists in the ToolCallState union, the runtime never transitions the tool-call part to it — the result surfaces as a populated part.output plus a sibling tool-result part whose own state is complete or error.
Approvals run ephemerally: the run resumes from the full client message history that the browser sends back, so a stateless route needs no server storage to rebuild the paused call.
When a tool requires approval, the typical flow is:
Without an approvalSchema, use the boolean shorthand. Approval uses the original tool input by default:
const approval = interrupts.find(
(interrupt) => interrupt.kind === 'tool-approval',
)
if (approval?.kind === 'tool-approval') {
approval.resolveInterrupt(true)
}An approvalSchema can define separate application payloads for approval and rejection:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const transferDefinition = toolDefinition({
name: 'transfer',
description: 'Transfer funds',
needsApproval: true,
inputSchema: z.object({
amount: z.number().positive(),
recipient: z.string(),
}),
approvalSchema: {
approve: z.object({ note: z.string() }),
reject: z.object({ reason: z.string() }),
},
})Keep branch data under payload. Approved arguments can optionally be replaced in full with editedArgs; rejection never accepts edits:
approval.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Reviewed' },
})
approval.resolveInterrupt(false, {
payload: { reason: 'Policy limit' },
})Denial and cancellation are different. resolveInterrupt(false, ...) records a resolved rejection for the continuation. cancel() is payloadless and does not select the reject schema:
approval.cancel()A singleton submits after its valid resolution. Multiple items stage until all are valid, then submit atomically. Use root resolveInterrupts(...) for one synchronous batch transaction. See Multiple Interrupts.
Tools can be marked as requiring approval by setting needsApproval: true in the definition:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { emailService } from './email-service'
// Step 1: Define tool with approval requirement
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, // This tool requires approval
})
// Step 2: Create server implementation
const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
// Only executes if approved
await emailService.send({ to, subject, body })
return { success: true, messageId: '...' }
})On the server, tools with needsApproval: true will pause execution and wait for approval:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { sendEmail } from './tools'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages,
tools: [sendEmail],
})
return toServerSentEventsResponse(stream)
}Render pending approvals from the hook's interrupts array. Each tool-approval interrupt carries the tool name, the original arguments, and a resolveInterrupt you call with the user's decision. The array is already tool-agnostic, so one block handles every tool marked needsApproval: true — no per-tool part.name branch and no reading part.approval off a mixed union:
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { sendEmail } from './tools'
function ChatComponent() {
const { messages, sendMessage, interrupts, resuming } = useChat({
connection: fetchServerSentEvents('/api/chat'),
tools: [sendEmail],
})
return (
<div>
{/* ...render messages... */}
{interrupts.map((interrupt) =>
interrupt.kind === 'tool-approval' ? (
<div key={interrupt.id} className="approval-prompt">
<p>🔒 Approve {interrupt.toolName}?</p>
<pre>{JSON.stringify(interrupt.originalArgs, null, 2)}</pre>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Deny
</button>
</div>
) : null,
)}
</div>
)
}canResolve stays false until the interrupt is bound and ready; resuming is true while a resolution is in flight, so gate the buttons on both.
Older UIs read part.approval off tool-call parts and called addToolApprovalResponse({ id, approved }). That API is deprecated. Render from the interrupts array and call resolveInterrupt instead (see Approval UI above) — it is tool-agnostic by default, so the per-tool narrowing the part-based pattern needed goes away. For the full mapping, see Migrate to AG-UI interrupts.
Client tools can also require approval:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
// tools/definitions.ts
const deleteLocalDataDef = toolDefinition({
name: 'delete_local_data',
description: 'Delete data from local storage',
inputSchema: z.object({
key: z.string(),
}),
outputSchema: z.object({
deleted: z.boolean(),
}),
needsApproval: true, // Requires approval even on client
})
// Client: Create implementation
const deleteLocalData = deleteLocalDataDef.client((input) => {
// This will only execute after approval
localStorage.removeItem(input.key)
return { deleted: true }
})
const { messages, interrupts } = useChat({
connection: fetchServerSentEvents('/api/chat'),
// Pass client tools as a plain array — literal tool-name inference works
// without a wrapper. The approval surfaces as a `tool-approval` interrupt you
// resolve from `interrupts` (see Approval UI); the tool runs on approval.
tools: [deleteLocalData], // Automatic execution after approval
})import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { createOrder } from './orders'
// Define tool with approval requirement
const purchaseItemDef = toolDefinition({
name: 'purchase_item',
description: 'Purchase an item from the store',
inputSchema: z.object({
itemId: z.string(),
quantity: z.number(),
price: z.number(),
}),
outputSchema: z.object({
orderId: z.string(),
total: z.number(),
}),
needsApproval: true,
})
// Create server implementation
const purchaseItem = purchaseItemDef.server(
async ({ itemId, quantity, price }) => {
const order = await createOrder({ itemId, quantity, price })
return { orderId: order.id, total: price * quantity }
},
)The user will see an approval prompt showing the item, quantity, and price before the purchase is made. The tool will only execute after the user approves.