Set needsApproval: true on a tool. The model can plan the call. The tool runs after a person approves it.
The chat shows an approve or reject prompt, and the run continues with the decision.
Put the tool in a file that the server and the browser both import, so both sides get the same types:
// tools/transfer.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const transferTool = toolDefinition({
name: 'transfer',
description: 'Transfer funds to a recipient',
needsApproval: true,
inputSchema: z.object({
amount: z.number().positive(),
recipient: z.string().min(1),
}),
outputSchema: z.object({ receiptId: z.string() }),
})The server runs the tool only after the user approves. Forward parentRunId and resume into chat(). The browser sends back the message history and the decision, so chat() can rebuild the paused call with no database:
// app/api/chat/route.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { transferTool } from '../../../tools/transfer'
const transfer = transferTool.server(
async (input: { amount: number; recipient: string }) => ({
receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`,
}),
)
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: [transfer],
})
return toServerSentEventsResponse(stream)
}Pass the shared tool to useChat, so toolName and originalArgs are typed. When the run pauses, the approval shows up in interrupts. Resolve it on the item with interrupt.resolveInterrupt(...):
// app/transfer-chat.tsx
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { transferTool } from '../tools/transfer'
export function TransferChat() {
const { messages, sendMessage, interrupts, resuming } = useChat({
threadId: 'account-42',
connection: fetchServerSentEvents('/api/chat'),
tools: [transferTool] as const,
})
const [input, setInput] = useState('')
return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}: </strong>
{message.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.content}</span> : null,
)}
</div>
))}
{interrupts.map((interrupt) => {
if (
interrupt.kind !== 'tool-approval' ||
interrupt.toolName !== 'transfer'
) {
return null
}
return (
<div key={interrupt.id} className="approval">
<p>
Send {interrupt.originalArgs.amount} to{' '}
{interrupt.originalArgs.recipient}?
</p>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Reject
</button>
</div>
)
})}
<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>
)
}Ask the model to send 20 to Ada. The chat stops and asks "Send 20 to Ada?". Click Approve. The tool runs, and the model replies with the receipt.
With one pending decision, resolveInterrupt submits at once. There is no extra step. To answer several decisions together, see Multiple Interrupts.
Approval works the same for server tools and client tools. Only the place where the tool runs after the yes is different:
The approval interrupt is the same in both cases, so the UI above does not change. A client tool without needsApproval runs on its own and never pauses. See Client Tools.
Reject and cancel are two different answers:
If the user answered no, use reject. If the user leaves the workflow without an answer, use cancel.
Sometimes the decision needs data: a review note, or the reason for a no. Add an approvalSchema to the tool definition. Use one schema for both branches, or an { approve, reject } map for a different payload per branch:
// tools/transfer.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
export const transferTool = toolDefinition({
name: 'transfer',
description: 'Transfer funds to a recipient',
needsApproval: true,
inputSchema: z.object({
amount: z.number().positive(),
recipient: z.string().min(1),
}),
outputSchema: z.object({ receiptId: z.string() }),
approvalSchema: {
approve: z.object({ note: z.string().min(1) }),
reject: z.object({ reason: z.string().min(1) }),
},
})Now each decision carries a payload. On approval, you can also replace the arguments:
// Approve with the approve-branch payload.
interrupt.resolveInterrupt(true, { payload: { note: 'Reviewed' } })
// Approve, but replace the arguments first.
interrupt.resolveInterrupt(true, {
editedArgs: { amount: 12, recipient: 'Ada' },
payload: { note: 'Capped to policy' },
})
// Reject with the reject-branch payload.
interrupt.resolveInterrupt(false, { payload: { reason: 'Too large' } })Without an approvalSchema, resolveInterrupt(true) and resolveInterrupt(false) are all you need.
editedArgs become the arguments that the tool runs with. You wire up nothing: execute always gets the final input, edited or not, already validated against inputSchema:
// server/transfer-tool.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const transferTool = toolDefinition({
name: 'transfer',
description: 'Move money between accounts',
needsApproval: true,
inputSchema: z.object({
recipient: z.string(),
amount: z.number(),
}),
outputSchema: z.object({ receiptId: z.string() }),
})
export const transfer = transferTool.server(async (input) => {
// input holds the model's arguments, or the approver's editedArgs when
// they changed them. The code is the same in both cases.
return {
receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`,
}
})The payload is decision data, not tool input. Each branch uses it in a different way: