One run can pause on several interrupts at once. Render the queue, then send every answer in one request.
useChat collects the answers as local drafts. It sends them together when every item has an answer, and the server validates the full batch.
This page uses the transfer tool from Tool Approval.
Map over interrupts and switch on kind. Give each item its own buttons, and add root buttons for the full queue:
// 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>
)
}Ask the model for two transfers in one message. The queue shows two items. Approve one, then the other. The client sends both answers in one request, and the run continues.
You can resolve items in two ways:
Both ways stage local drafts. The client sends nothing until every pending item has an answer. Then it submits the full set at once. The server validates the full set before it accepts any item.
In a mixed batch, kind tells you what each item is:
resolveInterrupts(callback) runs your callback once for each item that can resume, in one synchronous transaction. This includes external generic items with a valid binding. The callback must resolve or cancel every item. If it throws or skips an item, nothing is sent:
resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
interrupt.resolveInterrupt(true, { payload: { note: 'Batch review' } })
return
}
interrupt.cancel()
})Two shortcuts cover the common cases:
A bad answer does not clear the queue. The item keeps your last valid draft and shows the error. The user fixes it and submits again.
Errors come in two places. Show both:
This component shows both kinds of error, disables its buttons at the right times, and offers the two ways to recover:
// app/error-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 ErrorQueue() {
const { interrupts, interruptErrors, retryInterrupts, resuming } = useChat({
threadId: 'account-42',
connection: fetchServerSentEvents('/api/chat'),
tools: [transferTool] as const,
})
// Retry helps only after 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.
// Also check the item status and whether the run is 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 ways to recover:
With persistence, InterruptStore.commitBatch() can commit the validated batch in one database transaction. If the store has no commitBatch(), a fallback writes the items one after the other. That fallback is not atomic.
With that sequential storage, a failed request can already have committed some items. Before you call retryInterrupts(), reload the current pending records. Then retry only the rest of the batch.
onInterruptStateChange gets a source with each update:
Use it to apply a different policy to restored batches. Cancellation still applies to every item in the batch that can resume.