Most agent runs are fire and forget. The model calls tools, they run, you get an answer back. But some steps must not 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.
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 surface as `interrupts`
User->>Client: approve / reject / submit a value
Client->>Server: continuation request with the answers, a fresh run
Server-->>Client: the agent picks up where it paused, final answerNote that the pause spans two runs: the interrupted one ends, and the continuation is a new run. One user-visible turn, two run lifecycles. See Threads and runs.
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.
Two kinds of interrupt show up in the interrupts array for you to resolve:
| kind | You get a pause when | Guide |
|---|---|---|
| tool-approval | A tool is marked needsApproval and the model calls it | Tool Approval |
| generic | Middleware requests typed client data at a lifecycle boundary | Generic Interrupts |
For a generic interrupt that TanStack AI owns, define it once with defineInterrupt(). Register the definition with both chat({ interrupts }) and useChat({ interrupts }). Middleware emits it through onInterruptBoundary, and the client receives a typed bound item that it can resolve or cancel. See Generic Interrupts. To pick a phase, see Lifecycle Boundaries. To apply the answer, see Apply Answers.
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.
There are three cases:
The binding is stored in the interrupt metadata under INTERRUPT_BINDING_METADATA_KEY. It records the interrupted run and generation. The client uses it to send the answer to the matching paused step.
Render unbound items as status information. Do not render a response form for them:
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 will not invent a binding to make these resolvable. Doing so would render a form whose answer is sent to a run that has nothing pending. The submit would fail only after the user has filled it in. unbound says that the pause belongs to something else. Unbound items never block you from resolving the ones that are yours.
If an external producer wants the chat client to resume its pause, attach a valid binding with withInterruptBinding. Do not write the metadata key by hand. Use the exact interrupted run id and generation that own the pause:
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 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),
),
},
)The client treats this as an untyped generic interrupt. The example above can stage a value, cancel it, or clear the draft. It can also join resolveInterrupts(...) with tool approvals and first-party generic interrupts.
v is the binding wire version. The client rejects unknown versions and bad fields. Those interrupts become unbound rather than a form that cannot resume the owner.
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:
| Tool | What you handle |
|---|---|
| Server tool | Nothing, unless needsApproval adds a tool-approval pause. It then runs on the server after you approve. |
| Client tool | Nothing, 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.
| You want to | Page |
|---|---|
| Approve or reject a single tool call | Tool Approval |
| Resolve several pending decisions at once | Multiple Interrupts |
| Ask the user something that isn't a tool | Generic Interrupts |
| Pick beforeModel, afterModel, beforeTools, or afterTools | Lifecycle Boundaries |
| Apply a generic answer to prompts or stop the run | Apply Answers |
| Run a tool in the browser | Client Tools |
| Move off the old approval-requested events | Migration |