Set needsApproval: true on a tool that sends email, pays, or deletes data. The tool runs after a person approves the call.
Add needsApproval: true to the tool. The run pauses before the tool runs. Your UI shows the call with Approve and Deny buttons. Then the run continues with the answer of the user.
The server and the browser both import this definition:
// tools/definitions.ts
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(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({
sent: z.boolean(),
}),
needsApproval: true,
})The answer of the user arrives in a second request. Forward parentRunId and resume, so chat() can rebuild the paused call:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
toolDefinition,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
// The same definition as tools/definitions.ts. In your app, import it from that file.
const sendEmailDef = toolDefinition({
name: 'send_email',
description: 'Send an email to a recipient',
inputSchema: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
outputSchema: z.object({
sent: z.boolean(),
}),
needsApproval: true,
})
const sendEmail = sendEmailDef.server(async ({ to, subject }) => {
// This runs only after the user approves. Call your email service here.
console.log(`Sending "${subject}" to ${to}`)
return { sent: true }
})
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
parentRunId: params.parentRunId,
...(params.resume ? { resume: params.resume } : {}),
tools: [sendEmail],
})
return toServerSentEventsResponse(stream)
}Pass the definition to useChat, so toolName and originalArgs are typed. Pending approvals come in interrupts:
// app/chat.tsx
import { useState } from 'react'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { sendEmailDef } from '../tools/definitions'
export function Chat() {
const [input, setInput] = useState('')
const { messages, sendMessage, interrupts, resuming } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
tools: [sendEmailDef],
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <p key={index}>{part.content}</p> : null,
)}
</div>
))}
{interrupts.map((interrupt) =>
interrupt.kind === 'tool-approval' ? (
<div key={interrupt.id}>
<p>Send this email?</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,
)}
<form
onSubmit={(event) => {
event.preventDefault()
void sendMessage(input)
setInput('')
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit">Send</button>
</form>
</div>
)
}Disable the buttons on both.
Ask your chat "Email ada@example.com and say hello." The Approve and Deny buttons show the email.
No database is necessary. The browser sends the full message history back with the answer, so a stateless route can rebuild the paused call.
For the full interrupt lifecycle, batch controls, generic interrupts, and recovery, see Interrupts and Tool Approval. To move from the 0.x approval APIs, see Migrate to AG-UI interrupts.
A tool call that needs approval moves through these ToolCallState values on the tool-call part:
When the result comes in, the call part gets output, and a sibling tool-result part shows complete or error. See Tool Architecture.
An approvalSchema gives each answer its own typed data:
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() }),
},
})Put that data under payload. When the user approves, you can also replace the arguments in full with editedArgs. A rejection never takes edits:
approval.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Reviewed' },
})
approval.resolveInterrupt(false, {
payload: { reason: 'Policy limit' },
})approvalSchema needs needsApproval: true. Without it, toolDefinition() throws. If edited arguments do not fit inputSchema, the resume stops with an invalid-edited-args error.
Deny and cancel are different:
approval.cancel()See Multiple Interrupts.
A client tool can also need approval. The approval interrupt is the same, so your UI does not change. After the user approves, the browser runs the .client() function:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
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,
})
// Runs only after the user approves
const deleteLocalData = deleteLocalDataDef.client((input) => {
localStorage.removeItem(input.key)
return { deleted: true }
})
const { messages, interrupts } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
tools: [deleteLocalData],
})A denied or cancelled tool result has state: 'error' and the wire state output-error. To tell it apart from a real tool failure, read outcome on the tool-result part:
const denied = message.parts.some(
(part) => part.type === 'tool-result' && part.outcome === 'denied',
)Use outcome, not the error text. When a result is saved as a ModelMessage, the outcome goes in metadata.tanstack.toolResultOutcome, and it comes back on the UI part. The AG-UI interrupt resume path also sends the outcome and keeps the error content.