本页还没有译文
本页 22 段还没译成Português (BR),下面显示的是英文原文。Approval Flow Processing Architecture
还没翻译译自英文(tanstack.com)本页 22 段都还没译
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.
Responsibilities
| 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. |
Descriptor to continuation pipeline
The invariant is descriptor → validate all → continuation → history:
- The engine builds public descriptors and bindings. Output includes
MESSAGES_SNAPSHOT, optionalSTATE_SNAPSHOT, and the interruptRUN_FINISHEDterminal. - The client binds only descriptors whose reason, tool identity, call ID,
schema hashes, interrupted run, and generation match its tool registry.
Anything untrusted degrades to
genericrather than gaining a typed tool resolver. - Item methods validate and stage local drafts. The submit boundary contains every 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 validates all payloads, edited inputs, outputs, hashes, and correlation before executing anything, reconstructing the expected batch from the client-provided history and its current tool definitions.
- Resumed tool calls emit results only; they do not replay synthetic tool-call start/argument events. Successful history belongs to the continuation run.
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.
Server setup
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.
Client state machine
A single approval follows this sequence:
- The model emits a tool call.
- The client tool-call part reaches
approval-requested. - The run ends with
RUN_FINISHED.outcome.type === 'interrupt'. useChatexposes a bound item ininterrupts.- The UI calls
resolveInterrupt(...)orcancel(); a singleton submits immediately, while a multi-item batch waits for every valid draft. - The next request carries a fresh
runId, the interruptedparentRunId, and the exact AG-UIresumearray. - The server validates the full set before the engine continues the tool call.
Normal input is rejected at step 4. This prevents a second branch from being created while the existing run still waits for a decision.
React approval UI
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>
)
}State durability versus delivery durability
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.