- 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 Flow
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.
Add an approval#
1. Mark the tool in a shared file#
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,
})2. Add the server function and forward the resume fields#
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)
}3. Show the approval in your chat#
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>
)
}- canResolve is false until the interrupt is ready.
- resuming is true while an answer is on its way to the server.
Disable the buttons on both.
4. Try it#
Ask your chat "Email ada@example.com and say hello." The Approve and Deny buttons show the email.
- Click Approve. Your server logs the email, and the model confirms that it sent it.
- Click Deny. The tool does not run. The model gets a denied result and can tell the user.
How it works#
- The model calls send_email.
- chat() sees needsApproval: true. It does not run the tool. It ends the run with an interrupt.
- useChat puts the pending call in interrupts.
- The user clicks a button. resolveInterrupt sends the answer to the server in a new request.
- chat() runs the tool if the user approved, or records a denied result. Then the model continues.
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.
Advanced#
Tool call states#
A tool call that needs approval moves through these ToolCallState values on the tool-call part:
- awaiting-input: the call started, with no arguments yet.
- input-streaming: the arguments arrive.
- input-complete: all arguments are in.
- approval-requested: the call waits for the user.
- approval-responded: the user approved or denied.
When the result comes in, the call part gets output, and a sibling tool-result part shows complete or error. See Tool Architecture.
Approve with a note, or deny with a reason#
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 or cancel#
Deny and cancel are different:
- resolveInterrupt(false, ...) records a rejection. The rejection can carry the reject payload.
- cancel() takes no payload and does not use the reject schema.
approval.cancel()Several approvals at once#
- One pending approval submits as soon as it has a valid answer.
- Several pending approvals wait until all of them have valid answers. Then they submit together.
- To answer several in one step, call resolveInterrupts(...) on the hook.
See Multiple Interrupts.
Approval on a client tool#
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],
})Tell a denial from a failure#
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:
- 'denied': the user or a middleware policy denied the call.
- 'cancelled': the call was cancelled.
- No outcome: a normal tool failure.
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.
Tips for approval prompts#
- Use approval for actions that you cannot undo. Emails, payments, and deletes are good examples.
- Show what will happen. Show the arguments in a form that a person can read, not raw JSON.
- Handle a denial. Read outcome and show the user that nothing happened.
- Think about time limits. A user can leave an approval open for a long time.