Tool approval is an interrupt-and-resume protocol. 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 } } },
],
},
},
],
},
}The canonical event stream is the only native approval event stream. It works ephemerally without persistence. When server state persistence is configured, it stores the complete descriptor/binding batch before the terminal is exposed; SSE delivery durability separately assigns opaque resume offsets to delivered events. Native paths do not emit approval-requested or tool-input-available custom events.
See Interrupts for the public server/client guide and Migrate to AG-UI interrupts for deprecated readers.
| Layer | Responsibility |
|---|---|
| Tool definition | Declares needsApproval: true for a sensitive operation. |
| Chat engine | Stops before tool execution 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 uses resolveInterrupt, cancel, or root batch controls. |
| Delivery adapter | Optionally replays SSE events by opaque adapter-owned offsets. |
The invariant is descriptor → validate all → continuation → history:
Because the batch is rebuilt from client-provided history, ephemeral mode does not provide replay, exactly-once, restart, or cross-instance guarantees; the message history is validated but remains client-provided input.
Define the tool normally. The following route is the ephemeral flow (no persistence middleware): chat + chatParamsFromRequest resume the interrupt batch from client message history and tool definitions. Durable recovery is a separate optional layer and is not required for tool approvals.
// tools.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
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 }) => {
await deleteProjectFromDatabase(projectId)
return { deleted: true }
})
declare function deleteProjectFromDatabase(projectId: string): Promise<void>// 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-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
parentRunId: params.parentRunId,
...(params.resume ? { resume: params.resume } : {}),
tools: [deleteProject],
})
return toServerSentEventsResponse(stream)
}No server storage is required to emit or resolve interrupts. The route above is the complete flow: the browser sends the full message history back on the continuation request, and the engine rebuilds the paused call from it.
A single approval follows this sequence:
Normal input is rejected at step 4. This prevents a second branch from being created while the existing run still waits for a decision.
Use the bound values returned by useChat. Rendering interrupts keeps IDs, tool types, drafts, and errors connected to the hook that owns the run.
import type { ItemInterruptError } from '@tanstack/ai'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { deleteProjectDefinition } from './tools'
export function ApprovalQueue() {
const chat = useChat({
id: 'project-chat',
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>
)
}For a batch, 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>
)
}Interrupts run ephemerally: the paused call is rebuilt from the message history the browser replays on the continuation request. That is separate from delivery durability, which makes the live byte stream replayable after a dropped connection. Delivery durability is configured on toServerSentEventsResponse and assigns one opaque SSE id per chunk (it is not available for NDJSON). See Resumable Streams.