This page lists each step from the model tool call to the tool result, after a person approves the call.
An approval is an interrupt. The run stops before the tool executes and ends with an interrupt outcome. The client sends the decision in a new run. The server checks that decision and then runs the tool.
For the public guide, see Interrupts. This page shows the internals.
// tools.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const projects = new Map([['demo', { name: 'Demo project' }]])
export const deleteProjectDefinition = toolDefinition({
name: 'delete_project',
description: 'Delete a project permanently',
inputSchema: z.object({ projectId: z.string() }),
outputSchema: z.object({ deleted: z.boolean() }),
needsApproval: true,
})
export const deleteProject = deleteProjectDefinition.server(async ({ projectId }) => {
return { deleted: projects.delete(projectId) }
})// app/api/chat/route.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { deleteProject } from './tools'
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: [deleteProject],
})
return toServerSentEventsResponse(stream)
}import type { ItemInterruptError } from '@tanstack/ai'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { deleteProjectDefinition } from './tools'
export function ApprovalQueue() {
const chat = useChat({
threadId: 'project-thread',
connection: fetchServerSentEvents('/api/chat'),
tools: [deleteProjectDefinition] as const,
})
return (
<section>
{chat.interrupts.map((interrupt) => (
<article key={interrupt.id}>
<p>Approval required: {interrupt.reason}</p>
{interrupt.kind === 'tool-approval' ? (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
) : null}
<button onClick={() => interrupt.cancel()}>Cancel</button>
{interrupt.errors.map((error: ItemInterruptError) => (
<p key={`${error.code}:${error.path?.join('.') ?? ''}`}>
{error.message}
</p>
))}
</article>
))}
</section>
)
}Ask the model to delete the demo project. The Approve button shows. Click it, and the tool runs.
This route needs no server storage. The browser sends the full message history back with the decision, and the engine rebuilds the paused call from it.
One approval goes through these steps:
Each layer has one job:
| Layer | Job |
|---|---|
| Tool definition | Declares needsApproval: true for a sensitive operation. |
| Chat engine | Stops before the tool executes and emits the interrupt outcome. |
| Chat client | Binds descriptors to typed methods, stages drafts, and submits one exact resume batch. |
| Application UI | Explains the operation and calls resolveInterrupt, cancel, or the root batch controls. |
| Delivery adapter | Optional. Replays SSE events by opaque offsets that the adapter owns. |
A run that needs user input ends with one canonical event:
const interruptTerminal = {
type: 'RUN_FINISHED',
runId: 'run-1',
threadId: 'thread-1',
timestamp: Date.now(),
outcome: {
type: 'interrupt',
interrupts: [
{
id: 'approval-1',
reason: 'tool_call',
toolCallId: 'call-1',
responseSchema: {
oneOf: [
{ type: 'object', properties: { approved: { const: true } } },
{ type: 'object', properties: { approved: { const: false } } },
],
},
},
],
},
}This event stream is the only native approval event stream. It works without persistence.
If your code uses custom approval events, see Migrate to AG-UI interrupts.
The rule is descriptor, then validate all, then continuation, then history:
The server rebuilds the batch from history that the client sends. So ephemeral mode does not give replay, exactly-once, restart, or cross-instance guarantees. The server checks the message history, but it is still input from the client.
When several interrupts wait, stage every resolution in one synchronous root callback:
function ResolveAll({ approved }: { approved: boolean }) {
const chat = useChat({
threadId: 'project-thread',
connection: fetchServerSentEvents('/api/chat'),
tools: [deleteProjectDefinition] as const,
})
return (
<button
onClick={() =>
void chat.resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
if (approved) {
interrupt.resolveInterrupt(true)
} else {
interrupt.resolveInterrupt(false)
}
return
}
interrupt.cancel()
})
}
>
Resolve all
</button>
)
}These are two different layers: