Interrupts

Multiple Interrupts

Multiple Interrupts

One run can pause on several decisions at once. A tool approval and a generic middleware request can land in the same batch. You want to show the whole queue and send the answers back together, not one round trip each.

Two ways to resolve

You have already seen the first one on the Tool Approval page: call a method on the item itself.

ts
// Per item: resolve each one where you render it.
interrupt.resolveInterrupt(true)

When several client-owned items are pending, it is often easier to answer them all from one place. The useChat hook gives you root helpers for those items:

ts
// All at once: one callback decides every pending item.
resolveInterrupts((interrupt) => {
  if (interrupt.kind === 'tool-approval') {
    interrupt.resolveInterrupt(true)
    return
  }
  interrupt.cancel()
})

Both stage local drafts. Nothing goes to the server until every pending item has an answer. Then the client submits the whole set at once. The server validates the whole set before it accepts any item.

When persistence is enabled, InterruptStore.commitBatch() can commit the validated batch in one database transaction. Without commitBatch(), the compatibility fallback writes items in sequence. That fallback is not atomic.

For a mixed batch, switch on kind. Registered generic items use their definition id to narrow their payload and response. Tool approvals keep their own controls. A generic item with a valid raw binding has untyped controls and joins the resume batch. An unbound item has a missing, malformed, or unsupported binding. It remains visible, has no controls, and does not block the resumable items.

Render the queue

Map over interrupts and switch on kind. Each item carries its own canResolve and errors:

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

export function DecisionQueue() {
  const { interrupts, resolveInterrupts, cancelInterrupts, resuming } = useChat({
    threadId: 'account-42',
    connection: fetchServerSentEvents('/api/chat'),
    tools: [transferTool] as const,
  })

  if (interrupts.length === 0) return null

  return (
    <section>
      <p>{interrupts.length} decision(s) needed</p>

      {interrupts.map((interrupt) => {
        if (
          interrupt.kind === 'tool-approval' &&
          interrupt.toolName === 'transfer'
        ) {
          return (
            <article key={interrupt.id}>
              <p>
                {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>
            </article>
          )
        }
        return <article key={interrupt.id}>Unsupported: {interrupt.kind}</article>
      })}

      <button onClick={() => resolveInterrupts(true)} disabled={resuming}>
        Approve all
      </button>
      <button onClick={() => cancelInterrupts()} disabled={resuming}>
        Cancel all
      </button>
    </section>
  )
}

Resolve every item from one callback

resolveInterrupts(callback) runs your callback once per resumable item in a single synchronous transaction. This includes valid raw external generic bindings. It must resolve or cancel every such item. If it throws or leaves one unanswered, nothing submits:

ts
resolveInterrupts((interrupt) => {
  if (interrupt.kind === 'tool-approval') {
    interrupt.resolveInterrupt(true, { payload: { note: 'Batch review' } })
    return
  }
  interrupt.cancel()
})

Two shortcuts cover the common cases:

  • resolveInterrupts(true) / resolveInterrupts(false) approves or rejects the whole queue. It works only when every item is a tool approval that needs no payload or edits. Generic items, mixed queues, or required payloads are rejected.
  • cancelInterrupts() cancels every resumable item with no payload.

When an answer is wrong

A bad answer does not tear down the queue. The item keeps your last valid draft, shows what went wrong, and lets you fix it and resubmit. Errors come in two places, and you render both.

Each item carries its own errors: a bad payload, invalid edited args, or an expired item. The root interruptErrors carries failures for the whole batch: transport problems, server rejections, and errors for the internal client-tool steps that never show up as their own item.

This component renders both, gates its buttons correctly, and offers the two recovery paths:

tsx
// app/robust-queue.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 RobustQueue() {
  const { interrupts, interruptErrors, retryInterrupts, resuming } = useChat({
    threadId: 'account-42',
    connection: fetchServerSentEvents('/api/chat'),
    tools: [transferTool] as const,
  })

  // Retry only helps a transport failure. Expired or stale batches cannot
  // be retried, so do not offer it for those.
  const canRetry = interruptErrors.some((error) => error.code === 'transport')

  return (
    <section>
      {interrupts.map((interrupt) => {
        if (
          interrupt.kind !== 'tool-approval' ||
          interrupt.toolName !== 'transfer'
        ) {
          return null
        }

        // canResolve reflects the schema and binding, not the live phase, so
        // also gate on the item's status and the run being busy.
        const busy = interrupt.status === 'submitting' || resuming

        return (
          <article key={interrupt.id}>
            <p>
              {interrupt.originalArgs.amount} to{' '}
              {interrupt.originalArgs.recipient}
            </p>
            <button
              disabled={!interrupt.canResolve || busy}
              onClick={() => interrupt.resolveInterrupt(true)}
            >
              Approve
            </button>
            <button disabled={busy} onClick={() => interrupt.clearResolution()}>
              Start over
            </button>

            {/* Item errors: bad payload, bad edited args, expired. */}
            {interrupt.errors.map((error) => (
              <p key={`${error.code}:${error.path?.join('.') ?? ''}`}>
                {error.message}
              </p>
            ))}
          </article>
        )
      })}

      {/* Batch errors: transport, server, and hidden client-tool steps. */}
      {interruptErrors.map((error) => (
        <p key={error.code}>{error.message}</p>
      ))}
      {canRetry ? (
        <button onClick={() => retryInterrupts()} disabled={resuming}>
          Retry
        </button>
      ) : null}
    </section>
  )
}

The two recovery paths, side by side:

  • interrupt.clearResolution() drops one item's draft so the user can answer it again from scratch. Fixing a form and calling resolveInterrupt again works too, the draft is replaced, not stacked.
  • retryInterrupts() retries a staged batch after a transport failure. With sequential durable storage, a failed request may have committed earlier entries already. Reload the current pending records, then retry only the remaining batch. It does nothing for expired or stale batches, start a fresh run to get a new set of interrupts for those.