Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da4134c
feat: add first-party generic interrupts
AlemTuzlak Aug 13, 2026
f9b51d9
ci: apply automated fixes
autofix-ci[bot] Aug 13, 2026
49c6182
feat: carry generic interrupt requests on resume.metadata
AlemTuzlak Aug 14, 2026
a006168
ci: apply automated fixes
autofix-ci[bot] Aug 14, 2026
661179f
fix: address generic interrupt review bugs
AlemTuzlak Aug 14, 2026
797c72e
fix: unblock generic interrupt CI typechecks
AlemTuzlak Aug 14, 2026
738b072
Merge branch 'main' into feat/generic-interrupts
AlemTuzlak Aug 14, 2026
70a16c7
fix: use one schema hash for first-party generic interrupts
AlemTuzlak Aug 14, 2026
5bcf683
fix: make generic middleware interrupt e2e pass
AlemTuzlak Aug 14, 2026
ab195ff
Merge remote-tracking branch 'origin/main' into feat/generic-interrupts
AlemTuzlak Aug 14, 2026
bf35c65
ci: apply automated fixes
autofix-ci[bot] Aug 14, 2026
ef030e9
fix: drop unused expectCollectRejects after main merge
AlemTuzlak Aug 14, 2026
19c3f5c
ci: raise E2E job timeout to 30 minutes
AlemTuzlak Aug 14, 2026
e7bc64d
fix: stamp foreign-interrupt bindings with the request runId
AlemTuzlak Aug 14, 2026
94c068d
docs: type generic interrupts without Extract casts
AlemTuzlak Aug 14, 2026
e7ece7f
Merge branch 'main' into feat/generic-interrupts
AlemTuzlak Aug 18, 2026
7ff4c66
Merge branch 'main' into feat/generic-interrupts
AlemTuzlak Aug 18, 2026
21c5ea9
fix(ai-client): keep legacy client-tool continuation after a native r…
AlemTuzlak Aug 18, 2026
d18c411
Merge branch 'main' into feat/generic-interrupts
tombeckenham Aug 20, 2026
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.
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
32 changes: 25 additions & 7 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,27 +189,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 @@ -253,7 +269,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 @@ -317,7 +334,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
Loading
Loading