Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/generic-interrupts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-preact': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-vue': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-angular': minor
'@tanstack/ai-persistence': minor
---

Add first-party generic interrupts.

Use `defineInterrupt()` to describe a pause, register it on `chat()` and the client hooks, and return requests from `onInterruptBoundary`. The client gets typed payloads and `resolveInterrupt`. Resume validates the answer and runs `onInterruptResolution`.

`GenericInterrupt<typeof reviewPlan>` types one bound card. `INTERRUPT_BOUNDARY_PHASES` and `INTERRUPT_TOOL_RESUMES` are the shared phase and resume lists.
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
201 changes: 199 additions & 2 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,180 @@ const budget: ChatMiddleware = {

For a full per-turn + cumulative tool budget recipe, see [Tool-call budgets](../chat/agentic-cycle#tool-call-budgets-middleware-recipe).

### onInterruptBoundary and onInterruptResolution

Use these hooks when middleware needs data from the client. Define the request
with `defineInterrupt()` and register it with `chat({ interrupts })` and
`useChat({ interrupts })`. Do not emit raw AG-UI events from middleware.

`onInterruptBoundary` runs at four points in an agent iteration:

- `beforeModel`, before the adapter starts.
- `afterModel`, after the model response is complete.
- `beforeTools`, before tool execution starts.
- `afterTools`, after the tool phase is complete.

Each middleware can return requests from one boundary. The engine combines all
requests from that boundary into one AG-UI interrupt batch. The batch ends the
run with one interrupt outcome.

This hook cannot change config. Its only legal return is `{ interrupts }` or
nothing. The continuation is a new `chat()` call, so the hook runs again. Skip
the emit when `ctx.parentRunId` is set if this pause belongs to the original
request only.

What is in `ctx` at each phase, and when to use each phase, is in
[Lifecycle Boundaries](../interrupts/boundaries).

Create one shared definition. Both the server and the client import this value,
so the definition ID and response shape stay the same on both sides.

```typescript title="review-plan.ts"
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'

export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.object({ approved: z.boolean() }),
})

export const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'review-plan',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeTools') return
if (ctx.parentRunId) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'release-plan',
reason: 'review-required',
message: 'Approve this plan?',
payload: { title: 'Release plan' },
}),
],
}
},
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}
```

Register the definition on the server. Forward `parentRunId` and `resume` so
a client resolution starts the continuation with its full context.

```typescript title="route.ts"
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reviewMiddleware, reviewPlan } from './review-plan'

export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [reviewMiddleware],
})

return toServerSentEventsResponse(stream)
}
```

Register the same definition on the client. Check `kind` and `definitionId`.
TypeScript then treats the item as `GenericInterrupt<typeof reviewPlan>`.
`resolveInterrupt` uses the response shape from `reviewPlan.responseSchema`.

```tsx title="review-plan-panel.tsx"
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './review-plan'

function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<button
onClick={() => interrupt.resolveInterrupt({ approved: true })}
>
Approve plan
</button>
)
}

export function ReviewPlanPanel() {
const { interrupts, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
interrupts: [reviewPlan],
})

return (
<>
<button onClick={() => sendMessage('Review the release plan')}>
Start review
</button>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'generic') return null
if (!('definitionId' in interrupt)) return null
if (interrupt.definitionId !== reviewPlan.id) return null
return <ReviewCard key={interrupt.id} interrupt={interrupt} />
})}
</>
)
}
```

`onInterruptResolution` does not run in the `chat()` call that paused. It
runs once at the start of the next `chat()` call, after the client answers.

```
setup
onConfig (phase is init)
onInterruptResolution (phase is still init)
onStart
then stop, or continue the agent loop
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`useChat` sends `parentRunId` and `resume` on that second request. Each
generic resume item includes the original request in `metadata`. If `resume`
is present and `parentRunId` is missing, the server throws.

Use `resumedInterrupts.for(definition)` for one typed definition. Use
`resumedInterrupts.all()` for every registered definition. Use
`resumedInterrupts.all(definitionA, definitionB)` to read a typed subset.

The hook can return `toolResume: 'continue'`, `'cancel'`, or `'stop'`. Results
from all middleware combine by the most restrictive rule: `stop` wins over
`cancel`, and `cancel` wins over `continue`.

This hook cannot change prompts, tools, or messages. Store the answer on a
capability, then return those fields from `onConfig` when
`ctx.phase === 'beforeModel'`.

| Hook | Can change |
| --- | --- |
| `onInterruptBoundary` | Nothing. It can only pause. |
| `onInterruptResolution` | Pending-tool policy (`toolResume`) |
| `onConfig` | `messages`, `systemPrompts`, `tools`, `modelOptions`, `metadata` |

The full resume order, plus an example that writes a user note into the
system prompt, is in [Apply Answers](../interrupts/apply-answers).

### onBeforeToolCall

Called before each tool executes. The first middleware that returns a non-void decision short-circuits — remaining middleware are skipped for that tool call.
Expand Down Expand Up @@ -721,9 +895,32 @@ If you drop `withCounter` from the array, `chat()` reports a compile-time error
`createChatMiddleware()` builds the array through chained `.use()` calls and enforces **provider-before-consumer ordering at compile time**: each `.use()` requires that the middleware's `requires` are already covered by capabilities provided by earlier `.use()` calls.

```typescript
import { chat, createChatMiddleware } from "@tanstack/ai";
import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { withCounter, countsChunks } from "./counter-middleware";

const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;

const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});

const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
});

const middleware = createChatMiddleware()
.use(withCounter) // provides "counter"
Expand Down
34 changes: 26 additions & 8 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,27 +188,43 @@
{
"label": "Overview",
"to": "interrupts/overview",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
},
{
"label": "Tool Approval",
"to": "interrupts/tool-approval",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
},
{
"label": "Multiple Interrupts",
"to": "interrupts/multiple",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
},
{
"label": "Generic Interrupts",
"to": "interrupts/generic",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
},
{
"label": "Lifecycle Boundaries",
"to": "interrupts/boundaries",
"addedAt": "2026-08-13"
},
{
"label": "Apply Answers",
"to": "interrupts/apply-answers",
"addedAt": "2026-08-13",
"updatedAt": "2026-08-14"
},
{
"label": "Migration",
"to": "interrupts/migration",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
}
]
},
Expand Down Expand Up @@ -245,7 +261,8 @@
{
"label": "Chat Persistence",
"to": "persistence/chat-persistence",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-13"
},
{
"label": "Client Persistence",
Expand Down Expand Up @@ -308,7 +325,8 @@
{
"label": "Store Reference",
"to": "persistence/store-reference",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
},
{
"label": "How Persistence Works",
Expand Down Expand Up @@ -480,7 +498,7 @@
"label": "Middleware",
"to": "advanced/middleware",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-21"
"updatedAt": "2026-08-14"
},
{
"label": "Built-in Middleware",
Expand Down
Loading
Loading