Skip to content
Interrupts

Overview

An interrupt pauses a run until a person decides. The run then continues from the same point.

Quick setup

This setup gates one tool behind a human approval. If you already have a chat route, it takes about 5 minutes.

1. Mark the tool

Add needsApproval: true. Put the tool in a file that the server and the browser both import, so both sides get the same types:

ts
// tools/transfer.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const transferTool = toolDefinition({
  name: 'transfer',
  description: 'Transfer funds to a recipient',
  needsApproval: true,
  inputSchema: z.object({
    amount: z.number().positive(),
    recipient: z.string().min(1),
  }),
  outputSchema: z.object({ receiptId: z.string() }),
})

2. Serve it

Add the server implementation. Forward parentRunId and resume into chat(), so the continuation can find the paused call:

ts
// app/api/chat/route.ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { transferTool } from '../../../tools/transfer'

const transfer = transferTool.server(
  async (input: { amount: number; recipient: string }) => ({
    receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`,
  }),
)

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: [transfer],
  })
  return toServerSentEventsResponse(stream)
}

3. Render the approval

Pass the same tool to useChat. Render each item in interrupts, and call interrupt.resolveInterrupt(...) on it:

tsx
// app/transfer-chat.tsx
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { transferTool } from '../tools/transfer'

export function TransferChat() {
  const { messages, sendMessage, interrupts, resuming } = useChat({
    threadId: 'account-42',
    connection: fetchServerSentEvents('/api/chat'),
    tools: [transferTool] as const,
  })
  const [input, setInput] = useState('')

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          <strong>{message.role}: </strong>
          {message.parts.map((part, i) =>
            part.type === 'text' ? <span key={i}>{part.content}</span> : null,
          )}
        </div>
      ))}

      {interrupts.map((interrupt) => {
        if (
          interrupt.kind !== 'tool-approval' ||
          interrupt.toolName !== 'transfer'
        ) {
          return null
        }
        return (
          <div key={interrupt.id}>
            <p>
              Send {interrupt.originalArgs.amount} to{' '}
              {interrupt.originalArgs.recipient}?
            </p>
            <button
              disabled={!interrupt.canResolve || resuming}
              onClick={() => interrupt.resolveInterrupt(true)}
            >
              Approve
            </button>
            <button
              disabled={!interrupt.canResolve || resuming}
              onClick={() => interrupt.resolveInterrupt(false)}
            >
              Reject
            </button>
          </div>
        )
      })}

      <form
        onSubmit={(event) => {
          event.preventDefault()
          void sendMessage(input)
          setInput('')
        }}
      >
        <input value={input} onChange={(event) => setInput(event.target.value)} />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

4. Try it

Ask the model to send 20 to Ada. The chat stops on the transfer call and shows Approve and Reject. Click Approve. The tool runs, and the model gives its answer.

How it works

  1. The model calls a tool that has needsApproval: true. The server ends the run with an interrupt outcome, with no final answer.
  2. useChat puts the pending decision in interrupts.
  3. You resolve each item: approve, reject, submit a value, or cancel.
  4. The client starts a new run, the continuation. It carries your answers, and the agent continues from the pause.
mermaid
sequenceDiagram
    participant User
    participant Client
    participant Server

    Client->>Server: send message, run starts
    Server-->>Client: interrupt outcome, run ends without a final answer
    Client->>User: pending decisions in `interrupts`
    User->>Client: approve, reject, or submit a value
    Client->>Server: continuation request with the answers, a new run
    Server-->>Client: the agent continues from the pause, final answer

One turn that the user sees is two runs: the interrupted run and the continuation. See Threads and runs.

The server needs no database. The browser sends the full message history back on the continuation request. Then a stateless server can rebuild the paused step and continue.

What can pause a run

kindYou get a pause whenGuide
tool-approvalThe model calls a tool that has needsApprovalTool Approval
genericMiddleware asks the client for typed data at a lifecycle boundaryGeneric Interrupts

An MCP server can also ask for input during a tool call. The run then ends with an interrupt. See MCP client input.

A tool with a .client() implementation runs in the browser and reports its own result. You make no decision, so it does not show in interrupts. See Client Tools.

A tool pauses only when you mark it needsApproval: true. Then it waits for a yes or no first, on the server or in the browser:

ToolWhat you handle
Server tool (.server())Nothing. With needsApproval, you approve first, then it runs on the server.
Client tool (.client())Nothing. It runs in the browser. With needsApproval, you approve first, then it runs in the browser.

Both kinds use the same tool-approval interrupt, so your approval UI does not change.

Where to go next

You want toPage
Add a note or a reason to an approval, or edit the argumentsTool Approval
Resolve several pending decisions at onceMultiple Interrupts
Ask the user something that is not a tool callGeneric Interrupts
Pick beforeModel, afterModel, beforeTools, or afterToolsLifecycle Boundaries
Use a generic answer in prompts, or stop the runApply Answers
Move a 0.x approval UI to 1.0Migration

Advanced

Replayed events

When the client replays saved events, it follows parentRunId links. Then it does not show a resolved pause again.

  • A continuation that ends without an interrupt clears the pending decisions of its ancestor. This is true also when the parent link arrives later.
  • Intermediate tool_calls events do not mark the run or its ancestors as answered.

Restored client tools

Persistence can restore a pending client-tool execution. The client keeps it pending and does not run the browser code again. For recovery policies, see Client persistence.

First-party generic interrupts

A generic interrupt that TanStack AI owns starts with defineInterrupt():

  • Register the definition with both chat({ interrupts }) and useChat({ interrupts }).
  • Middleware emits it from onInterruptBoundary.
  • The client gets a typed item that it can resolve or cancel.

See Generic Interrupts.

Interrupts from other systems

An interrupt is a standard AG-UI object. Other producers can put one on the same stream. Examples are a workflow engine that pauses for a durable approval, or another agent framework on the same connection.

The client sorts each generic interrupt into one of three cases:

  • Registered first-party generic. kind: 'generic', a literal definitionId, a typed payload, and a typed resolveInterrupt.
  • External generic with a valid binding. Also kind: 'generic'. Its response is unknown, but it has resolveInterrupt, cancel, and clearResolution. It joins the root batch controls.
  • No valid binding. kind: 'unbound'. The binding is missing, malformed, or of an unsupported version. The item is visible, but it has no controls.

The binding is in the interrupt metadata under INTERRUPT_BINDING_METADATA_KEY. It records the interrupted run and the generation. The client uses it to send the answer to the matching paused step.

Show unbound items as status only. Do not show a response form for them:

tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const transferTool = toolDefinition({
  name: 'transfer',
  description: 'Move money between accounts',
  needsApproval: true,
  inputSchema: z.object({ recipient: z.string(), amount: z.number() }),
  outputSchema: z.object({ receiptId: z.string() }),
}).client()

export function Pauses() {
  const { interrupts } = useChat({
    threadId: 'thread-1',
    connection: fetchServerSentEvents('/api/chat'),
    tools: [transferTool] as const,
  })

  return (
    <>
      {interrupts.map((interrupt) => {
        if (interrupt.kind === 'unbound') {
          return (
            <p key={interrupt.id}>
              External pause: {interrupt.message ?? interrupt.reason}
            </p>
          )
        }
        if (interrupt.kind === 'generic') {
          return (
            <article key={interrupt.id}>
              <p>{interrupt.message ?? interrupt.reason}</p>
              <button
                onClick={() =>
                  interrupt.resolveInterrupt({ speed: 'express' })
                }
              >
                Choose express
              </button>
              <button onClick={() => interrupt.cancel()}>Cancel</button>
              <button onClick={() => interrupt.clearResolution()}>
                Clear choice
              </button>
            </article>
          )
        }
        return (
          <button
            key={interrupt.id}
            onClick={() => interrupt.resolveInterrupt(true)}
          >
            Approve {interrupt.toolName}
          </button>
        )
      })}
    </>
  )
}

The library does not make up a binding for an unbound item. With a made-up binding, the answer goes to a run that has nothing pending. Then the submit fails after the user fills in the form. unbound tells you that the pause belongs to a different system. Unbound items never block the items that are yours.

For an external producer that wants the chat client to resume its pause, attach a valid binding with withInterruptBinding. Do not write the metadata key yourself. Use the exact interrupted run id and generation that own the pause:

ts
import {
  INTERRUPT_BINDING_VERSION,
  canonicalInterruptJson,
  digestInterruptJson,
  withInterruptBinding,
} from '@tanstack/ai'

const responseSchema = {
  type: 'object',
  properties: { speed: { type: 'string' } },
  required: ['speed'],
}

const descriptor = withInterruptBinding(
  {
    id: 'shipping-1',
    reason: 'confirmation',
    message: 'Which shipping speed?',
    responseSchema,
  },
  {
    v: INTERRUPT_BINDING_VERSION,
    kind: 'generic',
    interruptId: 'shipping-1',
    interruptedRunId: 'run-42',
    generation: 0,
    // The server compares the schema it sends with the schema it validates
    // against, so the hash comes from the schema itself.
    responseSchemaHash: digestInterruptJson(
      canonicalInterruptJson(responseSchema),
    ),
  },
)

The client treats this item as an untyped generic interrupt. Like the generic branch above, it can stage a value, cancel, or clear the draft. It can also join resolveInterrupts(...) with tool approvals and first-party generic interrupts.

v is the version of the binding format. The client rejects unknown versions and bad fields. Those interrupts become unbound, so you never get a form that cannot resume the owner.