Interrupts

Overview

Interrupts

Most agent runs are fire and forget. The model calls tools, they run, you get an answer back. But some steps shouldn't happen on their own: moving money, deleting a project, sending an email. And sometimes the agent needs an answer only the user can give before it can go on.

An interrupt is a pause. The run stops, hands you a decision to make, and then picks up exactly where it left off once you answer.

How it works

  1. The server reaches a step that needs a decision and ends the run with an interrupt outcome instead of a final answer.
  2. The client gives you the pending decisions as interrupts.
  3. You resolve each one (approve, reject, submit a value, or cancel).
  4. The client starts a fresh continuation run that carries your answers and continues the agent.

No database is required. The browser sends the full message history back on the continuation request, so a stateless server can rebuild the paused step and keep going.

What pauses a run

Two kinds of interrupt show up in the interrupts array for you to resolve:

kindYou get a pause whenGuide
tool-approvalA tool is marked needsApproval and the model calls itTool Approval
genericYour app ends a run to ask the user something that isn't a toolGeneric Interrupts

Interrupts that aren't ours: unbound

An interrupt is a standard AG-UI object, and TanStack AI is not the only thing that can put one on a stream. A workflow engine pausing for a durable approval, or another agent framework sharing the same connection, emits the same envelope.

What makes a pause resumable here is a binding this library attaches to the interrupt's metadata, under a key exported as INTERRUPT_BINDING_METADATA_KEY. It records which run and generation the pause belongs to, so your answer can be matched back to the paused step.

When an interrupt arrives without one, you get it with kind: 'unbound' and canResolve: false, and there is no resolveInterrupt to call:

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) => {
        // Someone else owns this pause: show it, but offer no way to answer it.
        if (interrupt.kind === 'unbound') {
          return (
            <p key={interrupt.id}>
              Paused elsewhere: {interrupt.message ?? interrupt.reason}
            </p>
          )
        }
        if (interrupt.kind === 'generic') {
          return (
            <button
              key={interrupt.id}
              onClick={() => interrupt.resolveInterrupt({ confirmed: true })}
            >
              {interrupt.message ?? interrupt.reason}
            </button>
          )
        }
        return (
          <button
            key={interrupt.id}
            onClick={() => interrupt.resolveInterrupt(true)}
          >
            Approve {interrupt.toolName}
          </button>
        )
      })}
    </>
  )
}

The library will not invent a binding to make these resolvable. Doing so would render a form whose answer gets submitted against a run that has nothing pending — failing only after the user has filled it in. unbound says plainly that the pause belongs to something else, and unbound items never block you from resolving the ones that are yours.

If you emit your own pauses and want them resumable here, attach the binding with withInterruptBinding rather than writing the metadata key by hand:

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',
    // The server checks the schema it hands out still matches the one it
    // validates against, so the hash is computed from the schema itself.
    responseSchemaHash: digestInterruptJson(
      canonicalInterruptJson(responseSchema),
    ),
  },
)

v is the binding's wire version. Readers reject a version they don't recognise instead of guessing at the fields, which is what keeps another producer's binding from being mistaken for one of ours.

What about client tools?

A tool with a .client() implementation runs in the browser on its own and reports its own result. That is not a decision you make, so it never appears in interrupts. See Client Tools.

The one time a tool pauses is when you mark it needsApproval: true. Then it stops for a yes or no first, whether it runs on the server or in the browser:

ToolWhat you handle
Server toolNothing, unless needsApproval adds a tool-approval pause. It then runs on the server after you approve.
Client toolNothing, it runs in the browser automatically. With needsApproval it pauses for approval first, then runs in the browser.

So approval is the only thing you resolve for either kind of tool, and both use the same tool-approval interrupt.

Where to go next

You want toPage
Approve or reject a single tool callTool Approval
Resolve several pending decisions at onceMultiple Interrupts
Ask the user something that isn't a toolGeneric Interrupts
Run a tool in the browserClient Tools
Move off the old approval-requested eventsMigration