- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Tool Approval
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.
Quick setup#
1. Define the tool#
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() }),
})2. Serve it#
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)
}3. Render it in the chat#
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>
)
}4. Try it#
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.
How it works#
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:
- A server tool (.server()) runs on the server.
- A client tool (.client()) runs in the browser.
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:
- interrupt.resolveInterrupt(false) is a no. The run continues, and the model sees the rejection, so it can respond to it.
- interrupt.cancel() drops the pause with no answer. It has no payload and does not use the reject branch.
If the user answered no, use reject. If the user leaves the workflow without an answer, use cancel.
Add data to the decision#
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.
Advanced#
Rules for editedArgs#
- Only approval accepts editedArgs. Reject does not.
- editedArgs is a full replacement of the arguments, not a merge.
- The tool inputSchema validates editedArgs.
- The server validates the full decision again before it runs the tool.
Where editedArgs and payload go on the server#
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:
- Reject payload. It goes back to the model as the failed result of the call. Then the model can read why the call was refused. resolveInterrupt(false, { payload: { reason: 'Too large' } }) gives { reason: 'Too large' } to the model as the result of that call.
- Approve payload. It is validated decision data for your app: an audit log, a "reviewed by" record, an analytics event. execute does not get it. If the tool needs a value from the approver, put that value in editedArgs.