- 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
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- 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
- Keep Keys on the ServerNew
- 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
Approval Flow Processing Architecture
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.
Set up an approval#
- Mark the tool with needsApproval: true:
// 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) }
})- Pass parentRunId and resume from the request to chat():
// 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)
}- Render the pending interrupts from useChat:
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.
How it works#
One approval goes through these steps:
- The model emits a tool call. On the client, the tool-call part reaches approval-requested.
- The run ends with RUN_FINISHED, and outcome.type is 'interrupt'.
- useChat shows a bound item in interrupts. While an interrupt waits, useChat rejects normal input, so no second branch starts.
- The UI calls resolveInterrupt(...) or cancel(). One item submits at once. A batch waits for a valid draft for each item.
- The next request has a fresh runId, the interrupted parentRunId, and the exact AG-UI resume array.
- The server checks the full set. Then the engine continues the tool call.
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. |
Advanced#
The interrupt terminal event#
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.
- With server state persistence, the server stores the complete descriptor and binding batch before it exposes the terminal event.
- With SSE delivery durability, the adapter gives each delivered event an opaque resume offset. This is a separate layer.
If your code uses custom approval events, see Migrate to AG-UI interrupts.
Descriptor to continuation pipeline#
The rule is descriptor, then validate all, then continuation, then history:
- The engine builds public descriptors and bindings. The output includes MESSAGES_SNAPSHOT, an optional STATE_SNAPSHOT, and the interrupt RUN_FINISHED terminal.
- The client binds a descriptor only when it matches the tool registry of the client. The match covers reason, tool identity, call ID, schema hashes, interrupted run, and generation. An untrusted descriptor becomes generic. It does not get a typed tool resolver.
- Item methods check and stage local drafts. The submit boundary holds each pending interrupt ID exactly once.
- The client submits a fresh run with the full current message history, the interrupted parentRunId, and the complete resume batch.
- The server checks all payloads, edited inputs, outputs, hashes, and correlation before it executes anything. It rebuilds the expected batch from the history that the client sent and from its current tool definitions.
- Resumed tool calls emit results only. They do not replay synthetic tool-call start or argument events. The successful history belongs to the continuation run.
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.
Resolve a batch#
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>
)
}State durability and delivery durability#
These are two different layers:
- State. Interrupts run ephemerally. The engine rebuilds the paused call from the message history that the browser sends on the continuation request. Durable recovery is an optional layer, and approvals do not need it.
- Delivery. Delivery durability makes the live byte stream replayable after a dropped connection. You configure it on toServerSentEventsResponse. It gives one opaque SSE id per chunk. It is not available for NDJSON. See Resumable Streams.