# Approval Flow Processing Architecture

# Approval Flow Processing Architecture

Tool approval is an interrupt-and-resume protocol. A run that needs user input
ends with one canonical event:

```ts
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](../interrupts/overview) for the public server/client guide and
[Migrate to AG-UI interrupts](../interrupts/migration) 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**:

1. The engine builds public descriptors and bindings. Output includes
   `MESSAGES_SNAPSHOT`, optional `STATE_SNAPSHOT`, and the interrupt
   `RUN_FINISHED` terminal.
2. 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 `generic` rather than gaining a typed tool
   resolver.
3. Item methods validate and stage local drafts. The submit boundary contains
   every pending interrupt ID exactly once.
4. The client submits a fresh run with the full current message history, the
   interrupted `parentRunId`, and the complete resume batch.
5. 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.
6. 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.

```ts
// 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>
```

```ts
// 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:

1. The model emits a tool call.
2. The client tool-call part reaches `approval-requested`.
3. The run ends with `RUN_FINISHED.outcome.type === 'interrupt'`.
4. `useChat` exposes a bound item in `interrupts`.
5. The UI calls `resolveInterrupt(...)` or `cancel()`; a singleton
   submits immediately, while a multi-item batch waits for every valid draft.
6. The next request carries a fresh `runId`, the interrupted `parentRunId`, and
   the exact AG-UI `resume` array.
7. 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.

```tsx group=approval-ui
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:

```tsx group=approval-ui
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](../resumable-streams/overview).
