diff --git a/.changeset/plugin-api.md b/.changeset/plugin-api.md
new file mode 100644
index 000000000..3d30da75b
--- /dev/null
+++ b/.changeset/plugin-api.md
@@ -0,0 +1,10 @@
+---
+'@tanstack/ai': minor
+'@tanstack/ai-client': minor
+'@tanstack/ai-react': minor
+'@tanstack/ai-solid': minor
+'@tanstack/ai-vue': minor
+'@tanstack/ai-svelte': minor
+---
+
+Add definePlugin (server) and usePlugin/createPlugin (client): declare app-defined plugins — chatPlugin for conversational surfaces, generationPlugin for one-shot work with schema-validated inputs, plus media factories (imagePlugin, videoPlugin, etc.) — behind one endpoint and drive them from one fully typed client hook. Server-side composition is coming via a future workflowPlugin.
diff --git a/.changeset/transaction-verbs.md b/.changeset/transaction-verbs.md
deleted file mode 100644
index 18e39df9a..000000000
--- a/.changeset/transaction-verbs.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-'@tanstack/ai': minor
-'@tanstack/ai-client': minor
-'@tanstack/ai-react': minor
-'@tanstack/ai-solid': minor
-'@tanstack/ai-vue': minor
-'@tanstack/ai-svelte': minor
----
-
-Add defineTransaction (server) and useTransaction/createTransaction (client): declare app-defined verbs — chatVerb for conversational surfaces, verb for one-shot work with schema-validated inputs — behind one endpoint and drive them from one fully typed client hook. A verb's execute can compose sibling verbs via ctx.call into a server-composed transaction: one request runs the whole pipeline, every sub-run streams back live, and a single stop()/disconnect aborts everything. clientTransaction binds a type-only client stub to a server definition without mirroring inert verb callbacks in the browser.
diff --git a/docs/config.json b/docs/config.json
index 79022d30f..4398dccef 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -309,25 +309,25 @@
]
},
{
- "label": "Transaction",
+ "label": "Plugin",
"children": [
{
"label": "Overview",
- "to": "transaction/overview",
+ "to": "plugin/overview",
"addedAt": "2026-07-14",
- "updatedAt": "2026-07-14"
+ "updatedAt": "2026-07-15"
},
{
- "label": "Transactions",
- "to": "transaction/transactions",
+ "label": "Orchestration",
+ "to": "plugin/plugins",
"addedAt": "2026-07-14",
- "updatedAt": "2026-07-14"
+ "updatedAt": "2026-07-15"
},
{
"label": "Scenarios",
- "to": "transaction/scenarios",
+ "to": "plugin/scenarios",
"addedAt": "2026-07-14",
- "updatedAt": "2026-07-14"
+ "updatedAt": "2026-07-15"
}
]
},
diff --git a/docs/plugin/overview.md b/docs/plugin/overview.md
new file mode 100644
index 000000000..28582ced6
--- /dev/null
+++ b/docs/plugin/overview.md
@@ -0,0 +1,528 @@
+---
+title: Plugin Overview
+id: plugin-overview
+order: 1
+description: "A plugin endpoint declares plugins — app-named units of AI work like `drafting`, `heroImage`, or `narration` — behind ONE request handler, and hands you ONE typed client. Learn the plugin kinds (chat, generation, and the media factories), how the client infers everything from the server definition, and when to reach for a plugin endpoint instead of the single-capability hooks."
+keywords:
+ - tanstack ai
+ - plugin
+ - definePlugin
+ - usePlugin
+ - chatPlugin
+ - generationPlugin
+ - imagePlugin
+ - chat
+ - image generation
+ - text-to-speech
+---
+
+**A plugin endpoint declares a set of _plugins_ — app-named units of AI work — behind a single request handler on the server, and gives you back a single typed client in the browser.** You declare the plugins once with `definePlugin()`; `usePlugin()` returns one object keyed by exactly the plugins you declared, all sharing one connection.
+
+Plugin names are *your* domain language, not a fixed set of library nouns. A blog studio declares `drafting`, `heroImage`, and `narration`; a video tool might declare `storyboard`, `thumbnail`, and `voiceover`. The library doesn't care what you call them — it routes on the names you choose and types the client from them.
+
+`definePlugin()` lives on the `/plugin` subpath of `@tanstack/ai`, and `usePlugin()` on the `/plugin` subpath of `@tanstack/ai-react` (Solid and Vue follow the same pattern; Svelte exports `createPlugin` from `@tanstack/ai-svelte/plugin`) — not the package root.
+
+```ts group=overview-blog
+// lib/blog-studio.ts — define once; the API route only calls `.handler`
+import { chat, generateImage, generateSpeech } from '@tanstack/ai'
+import {
+ chatPlugin,
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+} from '@tanstack/ai/plugin'
+import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
+
+export const blogPlugin = definePlugin({
+ // A conversational plugin: message history in, streamed chat out.
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ systemPrompts: ['You are a seasoned staff writer.'],
+ }),
+ ),
+
+ // A one-shot media plugin: a hero image from a typed prompt.
+ heroImage: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ }),
+ ),
+
+ // A one-shot media plugin: narrate a piece of text.
+ narration: speechPlugin((req) =>
+ generateSpeech({
+ adapter: openaiSpeech('tts-1'),
+ text: req.input.text,
+ voice: 'alloy',
+ }),
+ ),
+})
+```
+
+```ts group=overview-blog
+// routes/api.blog-studio.ts — thin server route
+export const POST = (request: Request) => blogPlugin.handler(request)
+```
+
+That one route now serves conversational drafting, image generation, and narration. On the client, one hook drives all three:
+
+```tsx group=overview-client
+// routes/blog-studio.tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import type { UIMessage } from '@tanstack/ai-react'
+import { blogPlugin } from '../lib/blog-studio'
+
+function BlogStudio() {
+ const plugin = usePlugin(blogPlugin, {
+ connection: fetchServerSentEvents('/api/blog-studio'),
+ })
+
+ return (
+
+ )
+}
+```
+
+`plugin.drafting` behaves exactly like [`useChat`](../chat/streaming) — streaming, tool calls, and message parts all work the same way. Each one-shot plugin (`plugin.heroImage`, `plugin.narration`, ...) is a typed run/result surface: a `.run(input)` method — its `input` typed by the plugin's schema, its resolved value by the callback's return — plus reactive `.result`, `.isLoading`, `.error`, `.status`, `.stop()`, and `.reset()`.
+
+Both `plugin..run(input)` and `plugin..sendMessage(...)` **resolve to their result**, so you can chain them with a single `await` — `run` resolves to the plugin's result (or `null`), and `sendMessage` resolves to the structured `final` (when the chat callback set `outputSchema`) or the messages array otherwise. Because each plugin is an independent surface, the client sequences a multi-step pipeline itself — see [Orchestration](./plugins).
+
+## Sharing the definition with the client
+
+`usePlugin` takes the **`definePlugin` value directly** — the same value the server route calls `.handler` on. There is no separate client stub to declare and keep in sync.
+
+```ts group=overview-share
+// lib/blog-studio.ts
+import { chat } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+
+// One value, imported by BOTH the API route (calls `.handler`) and the
+// page (passes it to `usePlugin`).
+export const blogPlugin = definePlugin({
+ drafting: chatPlugin((req) =>
+ chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
+ ),
+})
+```
+
+This works because `definePlugin` returns a value that carries, at runtime, the declared plugin **names** and their **kinds** (`chat` vs `one-shot`). `usePlugin` reads those to build the right client surface per plugin — a `ChatClient` for chat plugins, a `GenerationClient` for generation plugins — for whatever names you chose, with no kinds map to hand-maintain. The input/result/tool/schema *types* come from the value's type at compile time.
+
+**Importing the definition into the browser is safe — it never leaks credentials.** `definePlugin()` is inert: none of the callbacks run and no adapter is constructed until a request actually reaches `handler` on the server. API keys are read from the server environment when `handler` runs; they are never present in code. Importing the value ships the (inert) adapter *code* to the client bundle, not any secret, and the callbacks never fire client-side.
+
+The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/lib/blog-studio.ts) defines the plugin in one shared lib module and imports it from both the route and the page.
+
+## The plugin kinds
+
+### `chatPlugin(callback)` — conversational
+
+The callback receives the parsed chat request (`messages`, `threadId`, `runId`, client-declared `tools`, `forwardedProps`, and the raw `request`) and returns the stream `chat()` produces. On the client it becomes the full chat surface: `sendMessage`, `messages`, tool calls, approvals, structured output.
+
+### `generationPlugin({ input, execute })` — one-shot
+
+`input` is a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, ...) describing the input; the handler validates every incoming request against it **at runtime** before `execute` runs (a mismatch gets a `400` with the validation issues — the callback never sees a malformed input). `execute` receives the validated `req` (with `input`, `threadId`, `runId`, an abort `signal`, and the raw `request`) and returns a `Promise` of the result, or an `AsyncIterable` of stream chunks. On the client it becomes the `run`/`result` surface.
+
+You can declare **several plugins of the same kind** — including several chat plugins. Each gets its own conversation state, system prompt, and even model:
+
+```ts group=overview-support
+// api/support.ts
+import { chat } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+
+export const supportPlugin = definePlugin({
+ // The main conversation the user sees.
+ primaryChat: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ systemPrompts: [
+ 'You are a customer-support agent for Acme. Be concise and friendly.',
+ ],
+ }),
+ ),
+ // A second, independent chat surface: condense the ticket for handoff.
+ summaryChat: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ systemPrompts: [
+ 'Summarize the conversation you are given as a terse handoff note.',
+ ],
+ }),
+ ),
+})
+
+export const POST = (request: Request) => supportPlugin.handler(request)
+```
+
+On the client, `plugin.primaryChat` and `plugin.summaryChat` are two fully independent chat surfaces — separate `messages`, separate `sendMessage` — behind the same endpoint.
+
+The handler discriminates each incoming request by a `plugin` field the client sends, routes it to the matching callback, and streams the result back over Server-Sent Events. Undeclared plugin names get a `400` before any callback runs.
+
+## Media factories
+
+The six **media factories** are `generationPlugin` with the input schema and result type pre-bound for one media activity — so `plugin..run(input)` is typed input → output end to end without you writing a schema:
+
+| Factory | Input | Result |
+|---|---|---|
+| `imagePlugin` | `{ prompt, numberOfImages?, size?, modelOptions? }` | `ImageGenerationResult` |
+| `videoPlugin` | `{ prompt, size?, duration?, modelOptions? }` | `VideoJobResult` |
+| `audioPlugin` | `{ prompt, duration?, modelOptions? }` | `AudioGenerationResult` |
+| `speechPlugin` | `{ text, voice?, format?, speed?, modelOptions? }` | `TTSResult` |
+| `transcriptionPlugin` | `{ audio, language?, prompt?, responseFormat?, modelOptions? }` | `TranscriptionResult` |
+| `summarizePlugin` | `{ text, maxLength?, style?, focus?, modelOptions? }` | `SummarizationResult` |
+
+Each takes a single callback `(req) => result` — `req.input` is already typed to that media contract. Wire the callback to the matching `generate*` / `summarize` activity:
+
+```ts group=overview-media
+// api/studio.ts
+import {
+ generateImage,
+ generateSpeech,
+ generateTranscription,
+ generateVideo,
+ summarize,
+} from '@tanstack/ai'
+import {
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+ summarizePlugin,
+ transcriptionPlugin,
+ videoPlugin,
+} from '@tanstack/ai/plugin'
+import {
+ openaiImage,
+ openaiSpeech,
+ openaiSummarize,
+ openaiTranscription,
+ openaiVideo,
+} from '@tanstack/ai-openai'
+
+export const studioPlugin = definePlugin({
+ poster: imagePlugin((req) =>
+ generateImage({ adapter: openaiImage('gpt-image-2'), prompt: req.input.prompt }),
+ ),
+ teaser: videoPlugin((req) =>
+ generateVideo({ adapter: openaiVideo('sora-2'), prompt: req.input.prompt }),
+ ),
+ voiceover: speechPlugin((req) =>
+ generateSpeech({
+ adapter: openaiSpeech('tts-1'),
+ text: req.input.text,
+ voice: req.input.voice ?? 'alloy',
+ }),
+ ),
+ captions: transcriptionPlugin((req) =>
+ generateTranscription({
+ adapter: openaiTranscription('whisper-1'),
+ audio: req.input.audio,
+ }),
+ ),
+ recap: summarizePlugin((req) =>
+ summarize({ adapter: openaiSummarize('gpt-5.5'), text: req.input.text }),
+ ),
+})
+
+export const POST = (request: Request) => studioPlugin.handler(request)
+```
+
+`audioPlugin` follows the same shape for music / sound-effect generation via a provider that supports it. When you need a shape the factories don't cover, drop down to `generationPlugin({ input, execute })` and pass your own schema — the factories are just that call with the media contract filled in.
+
+On the client, each is the same typed run/result surface as any generation plugin:
+
+```tsx group=overview-media-client
+// components/Poster.tsx
+import { generateImage } from '@tanstack/ai'
+import { definePlugin, imagePlugin } from '@tanstack/ai/plugin'
+import { openaiImage } from '@tanstack/ai-openai'
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+
+// The same definition your server route exports — share it from one module in
+// a real app; repeated here so this snippet type-checks on its own.
+const studioPlugin = definePlugin({
+ poster: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ }),
+ ),
+})
+
+function Poster() {
+ const plugin = usePlugin(studioPlugin, {
+ connection: fetchServerSentEvents('/api/studio'),
+ })
+
+ return (
+
+ )
+}
+```
+
+## Typed chat plugins: tools and structured output
+
+A chat plugin inherits `useChat`'s full type inference, driven entirely by what its server callback passes to `chat()` — you don't re-declare anything on the client.
+
+**Callback tools auto-type the plugin's `messages`.** Pass `tools` to `chat()` in the callback and the tool-call/result parts on `plugin..messages` are typed from those tools, with nothing to register on the client:
+
+```tsx
+// components/WeatherChat.tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { chat, toolDefinition } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const getWeatherDef = toolDefinition({
+ name: 'get_weather',
+ description: 'Get the current weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+ outputSchema: z.object({ tempF: z.number(), conditions: z.string() }),
+})
+
+const getWeather = getWeatherDef.server(async ({ city }) => {
+ return { tempF: 72, conditions: `Sunny in ${city}` }
+})
+
+// The same definition your server route exports — share it from one module in
+// a real app; repeated here so this snippet type-checks on its own.
+const weatherPlugin = definePlugin({
+ primaryChat: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ tools: [getWeather],
+ }),
+ ),
+})
+
+function WeatherChat() {
+ const plugin = usePlugin(weatherPlugin, {
+ connection: fetchServerSentEvents('/api/weather'),
+ })
+
+ const weatherCall = plugin.primaryChat.messages
+ .at(-1)
+ ?.parts.find(
+ (part) => part.type === 'tool-call' && part.name === 'get_weather',
+ )
+
+ return (
+
+ )
+}
+```
+
+No tools option was passed to `usePlugin` — `weatherCall.output` is still narrowed to `{ tempF: number; conditions: string }`, inferred entirely from `tools: [getWeather]` on the server callback.
+
+**Per-plugin `tools` are only for client-executed tools.** A [client tool](../tools/client-tools)'s `.client()` implementation runs in the browser, so its code can't cross the wire. The server callback sees only the tool's *definition* (for typing and to tell the model it exists); the client registers the runtime implementation under the plugin's entry in the flat options. Types still come from the server callback either way:
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { chat, toolDefinition } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const showToastDef = toolDefinition({
+ name: 'show_toast',
+ description: 'Show a browser notification',
+ inputSchema: z.object({ message: z.string() }),
+})
+
+const showToast = showToastDef.client((input) => {
+ console.log(input.message)
+ return { ok: true }
+})
+
+// The same definition your server route exports — share it from one module in
+// a real app; repeated here so this snippet type-checks on its own.
+const toastPlugin = definePlugin({
+ primaryChat: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ tools: [showToastDef], // definition only — the client executes it
+ }),
+ ),
+})
+
+function useToastChat() {
+ return usePlugin(toastPlugin, {
+ connection: fetchServerSentEvents('/api/toast'),
+ // Per-plugin options are keyed by the plugin's declared name.
+ primaryChat: { tools: [showToast] },
+ })
+}
+```
+
+**`outputSchema` → typed `partial` / `final`.** If a chat plugin's callback passes an `outputSchema` to `chat()`, that plugin's surface picks up a progressively-parsed `partial` (`DeepPartial`) and a validated terminal `final` — the same inference [`useChat({ outputSchema })`](../structured-outputs/streaming) gives you:
+
+```tsx
+// components/BlogOutlineForm.tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { chat } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const BlogOutlineSchema = z.object({
+ title: z.string(),
+ sections: z.array(z.string()),
+})
+
+// The same definition your server route exports — share it from one module in
+// a real app; repeated here so this snippet type-checks on its own.
+const outlinePlugin = definePlugin({
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ outputSchema: BlogOutlineSchema,
+ stream: true,
+ }),
+ ),
+})
+
+function BlogOutlineForm() {
+ const plugin = usePlugin(outlinePlugin, {
+ connection: fetchServerSentEvents('/api/outline'),
+ })
+
+ return (
+
+ )
+}
+```
+
+`partial` and `final` only exist on the type when the callback declares `outputSchema` — omit it and the plugin's surface has no such fields, exactly like `useChat` without `outputSchema`. See [Structured Outputs](../structured-outputs/overview) for the full story.
+
+## Transforming one-shot results
+
+Each generation plugin's entry in the flat options accepts an `onResult` transform. It runs on the raw backend result before it lands on `plugin..result`, and **its return type becomes that plugin's `result` type**. Use it to reshape a result once, at the hook, instead of deriving it in every component that reads it:
+
+```tsx
+// components/CoverArt.tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { generateImage } from '@tanstack/ai'
+import { definePlugin, imagePlugin } from '@tanstack/ai/plugin'
+import { openaiImage } from '@tanstack/ai-openai'
+
+const coverPlugin = definePlugin({
+ heroImage: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ }),
+ ),
+})
+
+function CoverArt() {
+ const plugin = usePlugin(coverPlugin, {
+ connection: fetchServerSentEvents('/api/cover'),
+ // `result` is the raw `ImageGenerationResult`; the return type flows
+ // through to `plugin.heroImage.result`.
+ heroImage: { onResult: (result) => result.images[0]?.url ?? null },
+ })
+
+ return (
+
+
+ {/* `plugin.heroImage.result` is now `string | null`, not the raw result */}
+ {plugin.heroImage.result && }
+
+ )
+}
+```
+
+Return `undefined` (or nothing) from `onResult` to keep the raw result. Each plugin's entry also takes `forwardedProps` to merge extra fields into that plugin's request body. Options are **flat** — one entry per plugin name, alongside the reserved `connection` / `id` / `threadId` keys, which are excluded from the per-name map so a plugin name can never collide with them.
+
+## When should you reach for a plugin endpoint?
+
+A plugin endpoint is not "a better `useChat`." It earns its place when you have **two or more plugins that live on the same surface** — a page, a workflow, a pipeline. If you need exactly one chat box or one image button, the single hook is simpler and there is nothing to gain from the extra layer.
+
+| Your situation | Reach for | Why |
+|---|---|---|
+| One page needs chat **and** images **and** narration | A plugin endpoint | One connection, one typed client, one route to deploy and secure |
+| One plugin's output feeds the next (draft a post → illustrate it → narrate it) | A plugin endpoint, [orchestrated on the client](./plugins) | The client `await`s each plugin and sequences them; same endpoint, same types |
+| The user drives each step by hand (regenerate the image, re-narrate) | The same plugins, called directly from the client | `plugin.heroImage.run(...)` — same endpoint, same types |
+| A page that only sends chat messages | [`useChat`](../chat/streaming) | No second plugin to share — the plugin layer adds nothing |
+| A page that only generates images | [`useGenerateImage`](../media/generation-hooks) | Same — a single hook is the whole story |
+
+**The honest tradeoff:** a plugin endpoint centralizes routing and typing across plugins, but it also couples them into one definition and one endpoint. If two features never appear together and want different auth or system prompts, don't force them into one definition — give each surface its own (see [Scenarios](./scenarios)).
+
+## Which page do I read?
+
+| You want to… | Read |
+|---|---|
+| Sequence plugins into a pipeline — draft a post, then illustrate and narrate it — with client-side orchestration | [Orchestration](./plugins) |
+| Give different pages or features their own plugin sets, routes, and system prompts | [Scenarios](./scenarios) |
+| Understand the underlying chat surface (streaming, tools, message parts) | [Chat: Streaming](../chat/streaming) |
+| Drive a single capability without a plugin endpoint | [Media & Generation Hooks](../media/generation-hooks) |
diff --git a/docs/plugin/plugins.md b/docs/plugin/plugins.md
new file mode 100644
index 000000000..7402fe782
--- /dev/null
+++ b/docs/plugin/plugins.md
@@ -0,0 +1,252 @@
+---
+title: Orchestration
+id: plugin-orchestration
+order: 2
+description: "Sequence plugins into a pipeline from the client: draft a post, then — in parallel — illustrate and narrate it, each derived from the finished draft. One endpoint, one typed client, orchestrated in the browser with plain `await` and `Promise.all`. Each step drives its own reactive surface; each can be retried, cancelled, or re-run on its own."
+keywords:
+ - tanstack ai
+ - plugin
+ - orchestration
+ - pipeline
+ - definePlugin
+ - usePlugin
+ - client-side
+ - Promise.all
+---
+
+**A plugin endpoint hands you one typed client with an independent surface per plugin — so a multi-step pipeline is orchestrated on the _client_, with plain `await` and `Promise.all`.** The client calls each plugin, awaits its result, and feeds it into the next step. There is no server-side composition: each plugin is its own request, so each can be retried, cancelled, or re-run on its own, and the UI fills in as each step finishes.
+
+> **Server-side composition is coming.** A future `workflowPlugin` will let one client call run a whole pipeline as a *single* server request — one abort scope, intermediate values that never round-trip through the browser, live sub-run progress. Until then, orchestrate on the client as shown below; it's the right default for most pipelines anyway, because every step stays independently retryable.
+
+## The worked example: a blog studio
+
+One submission turns a topic into a finished post: draft the article as a typed object, then — in parallel — generate a hero image and record a voice-over, both derived from the validated draft. This is the [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) from the repository, condensed.
+
+### Server: three independent plugins behind one endpoint
+
+```ts group=orchestration
+// lib/blog-studio.ts
+import { chat, generateImage, generateSpeech } from '@tanstack/ai'
+import {
+ chatPlugin,
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+} from '@tanstack/ai/plugin'
+import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+export const BlogPostSchema = z.object({
+ title: z.string(),
+ subtitle: z.string(),
+ body: z.string().describe('The full post as Markdown'),
+})
+
+export const blogPlugin = definePlugin({
+ // Conversational plugin: writes the post as a typed object
+ // (structured output + streaming). `sendMessage` resolves to the validated
+ // `BlogPost`, so the client can derive the next steps' inputs from it.
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ systemPrompts: ['You are a seasoned staff writer.'],
+ outputSchema: BlogPostSchema,
+ stream: true,
+ threadId: req.threadId,
+ runId: req.runId,
+ }),
+ ),
+
+ // One-shot media plugin: a landscape hero image from a prompt.
+ heroImage: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ size: '1536x1024',
+ }),
+ ),
+
+ // One-shot media plugin: narrate a piece of text.
+ narration: speechPlugin((req) =>
+ generateSpeech({
+ adapter: openaiSpeech('tts-1'),
+ text: req.input.text,
+ voice: 'alloy',
+ }),
+ ),
+})
+```
+
+```ts group=orchestration
+// routes/api.blog-studio.ts
+export const POST = (request: Request) => blogPlugin.handler(request)
+```
+
+### Client: draft, then illustrate and narrate in parallel
+
+The page imports the `blogPlugin` value directly and sequences the steps itself. `drafting.sendMessage(...)` resolves to the validated `BlogPost` (because the callback declared `outputSchema`), so the hero-image prompt and the narration text are derived from the finished draft. The two media plugins then run in parallel:
+
+```tsx group=orchestration-client
+// routes/blog-studio.tsx
+import { chat, generateImage, generateSpeech } from '@tanstack/ai'
+import {
+ chatPlugin,
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+} from '@tanstack/ai/plugin'
+import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { z } from 'zod'
+
+const BlogPostSchema = z.object({
+ title: z.string(),
+ subtitle: z.string(),
+ body: z.string(),
+})
+
+// The same definition your server route exports — share it from one module in
+// a real app; repeated here so this snippet type-checks on its own.
+const blogPlugin = definePlugin({
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ outputSchema: BlogPostSchema,
+ stream: true,
+ }),
+ ),
+ heroImage: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ }),
+ ),
+ narration: speechPlugin((req) =>
+ generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.input.text }),
+ ),
+})
+
+function BlogStudio() {
+ const plugin = usePlugin(blogPlugin, {
+ connection: fetchServerSentEvents('/api/blog-studio'),
+ })
+ const { drafting, heroImage, narration } = plugin
+
+ // Client-side orchestration: draft the post, then illustrate and narrate in
+ // parallel — both derived from the completed draft.
+ async function orchestrate(topic: string) {
+ drafting.clear()
+ heroImage.reset()
+ narration.reset()
+
+ const draft = await drafting.sendMessage(`Write a blog post about: ${topic}`)
+ if (!draft) return // drafting failed or was stopped before completing
+
+ await Promise.all([
+ heroImage.run({
+ prompt: `Editorial hero image for "${draft.title}". ${draft.subtitle}.`,
+ }),
+ narration.run({ text: draft.body }),
+ ])
+ }
+
+ const isRunning = drafting.isLoading || heroImage.isLoading || narration.isLoading
+
+ return (
+
+
+ {isRunning && (
+
+ )}
+
+ {/* Each step drives its own reactive surface — the UI fills in as work
+ finishes, no shared pipeline state to thread through. */}
+
+
+ )
+}
+```
+
+`drafting.final` is typed as `BlogPost | null` — inferred from the callback's `outputSchema`, nothing re-declared on the client. `heroImage.result` is typed as `ImageGenerationResult | null`, from `imagePlugin`'s result contract. The `await drafting.sendMessage(...)` return value is the same validated `BlogPost`, so `draft.title` / `draft.body` are fully typed when you build the next steps' inputs.
+
+## Each step is independent
+
+Because there is no server-side pipeline, the three plugins are genuinely independent surfaces. That's the whole point of client-side orchestration:
+
+- **Retry a single step.** If narration fails but the draft and image are fine, call `narration.run(...)` again — the other steps keep their results. Nothing re-runs that you didn't ask to.
+- **Re-run on demand.** The same plugins the pipeline used can be driven directly by the user. A "Regenerate hero image" button is just `heroImage.run({ prompt })` again with a prompt derived from the current post — same endpoint, same types, no second definition.
+- **Reorder or skip.** The client decides the sequence. Draft-then-illustrate, illustrate-then-draft, or draft-only — it's ordinary control flow.
+
+## Cancellation
+
+Each surface has its own `stop()` (see the "Stop" button in the example above, which calls `drafting.stop()`, `heroImage.stop()`, and `narration.stop()` together). Because each step is its own request, cancelling is per-step — abort the drafting stream, or the image run, or all of them at once.
+
+Server-side, each plugin's `execute` / chat callback receives the request's `AbortSignal` (as `req.signal`). Pass it into the activities you run so a client `stop()` aborts the upstream provider call too, rather than orphaning it:
+
+```ts
+import { chat } from '@tanstack/ai'
+import { generationPlugin } from '@tanstack/ai/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const tagline = generationPlugin({
+ input: z.object({ product: z.string() }),
+ execute: async (req) => {
+ // `chat()` takes an AbortController; chain it off the request signal so a
+ // client disconnect / stop() aborts this provider call mid-flight.
+ const abortController = new AbortController()
+ req.signal.addEventListener('abort', () => abortController.abort(), {
+ once: true,
+ })
+ const text = await chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: [
+ { role: 'user', content: `Write a tagline for ${req.input.product}` },
+ ],
+ stream: false,
+ abortController,
+ })
+ return { tagline: text }
+ },
+})
+```
+
+## Error behavior
+
+Client-side orchestration means failures are **per step**, and you decide how to compose them:
+
+- A failed step sets that plugin's `.error` and leaves its `.result` at `null`; the other steps keep their results. The UI can show exactly which step failed and offer a retry for just that one.
+- Because you write the sequence, "fail the whole thing" vs. "best-effort" is your choice. `await drafting.sendMessage(...)` resolving to `null` (failed / stopped) is a natural place to bail before the parallel media steps — the example returns early. Wrap an individual `run` in `try`/`catch` to make it best-effort instead.
+- Top-level input to a generation plugin is validated against its schema **before** `execute` runs — a mismatch is a `400` with the validation issues, and the callback never sees a malformed input.
+
+## Next
+
+- [Plugin Overview](./overview) — the plugin kinds, client typing, tools, and structured output.
+- [Scenarios](./scenarios) — one definition per product surface, and choosing between a plugin endpoint, client orchestration, and plain `useChat`.
diff --git a/docs/plugin/scenarios.md b/docs/plugin/scenarios.md
new file mode 100644
index 000000000..ea94d90eb
--- /dev/null
+++ b/docs/plugin/scenarios.md
@@ -0,0 +1,202 @@
+---
+title: Scenarios
+id: plugin-scenarios
+order: 3
+description: "One plugin definition per product surface — a support page with a single chat plugin, a content studio with drafting + image + narration — each with its own route, its own usePlugin, and its own system prompts. Plus the decision guide: a single hook, a plugin endpoint, or client-orchestrated pipeline?"
+keywords:
+ - tanstack ai
+ - plugin
+ - definePlugin
+ - usePlugin
+ - multiple endpoints
+ - system prompt
+ - routes
+---
+
+**One `definePlugin` per product surface.** A plugin definition bundles a set of plugins behind one endpoint. When different pages or features want different plugin sets, different instructions, or different auth, don't stretch a single definition to cover them all — define one per surface. Each gets its own route and its own `usePlugin` on its own page.
+
+A typical app ends up with a few:
+
+| Surface | Plugins | Why its own definition |
+|---|---|---|
+| Customer **support** page | `supportChat` | Tight, support-flavored system prompt; nothing else to expose |
+| **Content studio** page | `drafting` + `heroImage` + `narration` | A creative workflow, orchestrated on the client |
+| Internal **dashboard** | `analystChat` + `weeklyDigest` | Different auth boundary; different instructions |
+
+## Two definitions, two routes
+
+Give each definition its own module and its own handler. A small helper keeps the shared adapter config — the model choice and any provider options — in one place, so the surfaces stay in sync without duplicating it:
+
+```ts
+// api/plugins.ts
+import { chat, generateImage, generateSpeech } from '@tanstack/ai'
+import {
+ chatPlugin,
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+} from '@tanstack/ai/plugin'
+import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
+
+// One place to change the model / provider options for every surface.
+const textModel = () => openaiText('gpt-5.5')
+
+// Support: a single chat plugin, with a support-flavored system prompt.
+export const supportPlugin = definePlugin({
+ supportChat: chatPlugin((req) =>
+ chat({
+ adapter: textModel(),
+ messages: req.messages,
+ systemPrompts: [
+ 'You are a customer-support agent for Acme. Be concise and friendly.',
+ ],
+ }),
+ ),
+})
+
+// Studio: drafting + image + narration, for a creative workflow.
+export const studioPlugin = definePlugin({
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: textModel(),
+ messages: req.messages,
+ systemPrompts: ['You are a creative copywriter.'],
+ }),
+ ),
+ heroImage: imagePlugin((req) =>
+ generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: req.input.prompt,
+ }),
+ ),
+ narration: speechPlugin((req) =>
+ generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.input.text }),
+ ),
+})
+
+// Two routes — one per definition.
+export const supportPOST = (request: Request) => supportPlugin.handler(request)
+export const studioPOST = (request: Request) => studioPlugin.handler(request)
+```
+
+In a real app these are two route files — `/api/support` and `/api/studio` — each exporting its own `POST`. The `supportPOST` / `studioPOST` names above just let both live in one snippet.
+
+## Two pages, two clients
+
+Each page calls `usePlugin` against *its own* definition value and *its own* connection. The support page only ever sees `plugin.supportChat`, because that's all `supportPlugin` declared:
+
+```tsx
+// pages/SupportPage.tsx
+import { chat } from '@tanstack/ai'
+import { chatPlugin, definePlugin } from '@tanstack/ai/plugin'
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { usePlugin } from '@tanstack/ai-react/plugin'
+import { openaiText } from '@tanstack/ai-openai'
+
+// The same definition your server route exports — share it from one module
+// in a real app; repeated here so this snippet type-checks on its own.
+const supportPlugin = definePlugin({
+ supportChat: chatPlugin((req) =>
+ chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
+ ),
+})
+
+function SupportPage() {
+ const plugin = usePlugin(supportPlugin, {
+ connection: fetchServerSentEvents('/api/support'),
+ })
+
+ return (
+
+ )
+}
+```
+
+Each page's `plugin` object is typed to exactly its definition's plugins — `SupportPage` has no `plugin.heroImage` to misuse, and there's no way to accidentally call the support route with an image request. Because `usePlugin` takes the `definePlugin` value directly, there is no separate client stub to keep in sync (see [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client)).
+
+## Split, or one broad definition?
+
+Both are valid. Choose by how the plugins actually relate:
+
+**Split into multiple definitions when:**
+
+- **Different product surfaces.** A support widget and a content studio are different features with different users — separate definitions keep their prompts, plugins, and routes independent.
+- **Different plugin sets.** If support never needs image generation, don't expose it there. A narrower definition is a narrower attack surface and a simpler client type.
+- **Different auth or rate-limit boundaries.** Separate routes let you guard `/api/support` and `/api/studio` independently.
+
+**Use one broad definition when:**
+
+- **The plugins are always used together** on the same page or in the same workflow — especially when one plugin's output feeds the next. Siblings must live in the same definition to share one endpoint and one typed client.
+- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one.
+
+The rule of thumb: **split by surface, not by plugin count.** A single page that happens to use four plugins is one definition; three pages that each use chat are three definitions.
+
+## A single hook, a plugin endpoint, or a client-orchestrated pipeline?
+
+The same feature can be built three ways. Pick by *how many kinds of AI work the surface has* and *who sequences them*:
+
+**Just `useChat` (or a single generation hook)** when there's exactly one kind of AI work on the page. A chat box is a chat box — the plugin layer adds a definition and a routing discriminator and nothing else. Reach for `definePlugin` only once a second plugin shows up.
+
+**A plugin endpoint, driven step by step** when the *user* orchestrates: each step is its own gesture — draft in the chat, then click "illustrate", then click "narrate", regenerate any step at will. Each step is its own request and can be retried independently. This is the loosest coupling: steps can be abandoned halfway, reordered, or repeated.
+
+**A plugin endpoint with a client-orchestrated pipeline** when the steps form a sequence but you kick it off with one action: the client `await`s `drafting.sendMessage(...)`, then fires `heroImage.run(...)` and `narration.run(...)` in `Promise.all`, deriving each input from the finished draft. One endpoint, one typed client, orchestrated in the browser — each step still independently retryable. See [Orchestration](./plugins).
+
+| | `useChat` / single hook | Plugin endpoint, user-driven | Plugin endpoint, client-orchestrated |
+|---|---|---|---|
+| Orchestrated by | — (single step) | The user, click by click | The client, in one handler |
+| Requests | One per interaction | One per step | One per step (sequenced in code) |
+| Cancel | Per request | Per step | Per step; or `stop()` each surface |
+| Failure | Per request | Per step; earlier steps keep their results | Per step; you compose fail-fast vs. best-effort |
+| Best for | A page with one AI feature | Exploratory, editable workflows | One-action pipelines with a definite finished artifact |
+
+> A future `workflowPlugin` will add a fourth option — running the whole pipeline as a *single* server request (one abort scope, intermediate values that stay on the server, live sub-run progress). Until it lands, orchestrate on the client.
+
+These compose: a blog studio declares `drafting`, `heroImage`, and `narration` in one definition — the client runs them as a pipeline for the first pass *and* lets the user drive the same plugins directly for "regenerate" touch-ups. One definition and one endpoint for both.
+
+## Next
+
+- [Plugin Overview](./overview) — the plugin kinds, client typing, and when to reach for a plugin endpoint at all.
+- [Orchestration](./plugins) — sequencing plugins into a pipeline from the client.
+- [Client Tools](../tools/client-tools) — run tool implementations in the browser inside a chat plugin.
diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md
deleted file mode 100644
index cca573a69..000000000
--- a/docs/transaction/overview.md
+++ /dev/null
@@ -1,435 +0,0 @@
----
-title: Transaction Overview
-id: transaction-overview
-order: 1
-description: "A transaction endpoint declares verbs — app-named units of AI work like `drafting`, `heroImage`, or `narration` — behind ONE request handler, and hands you ONE typed client. Learn the two kinds of verbs, how the client infers everything from the server definition, and when to reach for a transaction endpoint instead of the single-capability hooks."
-keywords:
- - tanstack ai
- - transaction
- - defineTransaction
- - clientTransaction
- - useTransaction
- - verbs
- - chat
- - image generation
- - text-to-speech
----
-
-**A transaction endpoint declares _verbs_ — app-named units of AI work — behind a single request handler on the server, and gives you back a single typed client in the browser.** You declare the verbs once with `defineTransaction()`; `useTransaction()` returns one object keyed by exactly the verbs you declared, all sharing one connection. And because a verb can compose its siblings server-side, one client call can run a whole multi-step pipeline as a single request — a [transaction](./transactions).
-
-Verb names are *your* domain language, not a fixed set of library nouns. A blog studio declares `drafting`, `heroImage`, `narration`, and `blogPost`; a video tool might declare `storyboard`, `thumbnail`, and `voiceover`. The library doesn't care what you call them — it routes on the names you choose and types the client from them.
-
-`defineTransaction()` lives on the `/transaction` subpath of `@tanstack/ai`, and `useTransaction()` on the `/transaction` subpath of `@tanstack/ai-react` (Solid and Vue follow the same pattern; Svelte exports `createTransaction` from `@tanstack/ai-svelte/transaction`) — not the package root.
-
-```ts group=overview-1
-// lib/blog-studio.ts — define once; the API route only calls `.handler`
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { chatVerb, clientTransaction, defineTransaction, verb } from '@tanstack/ai/transaction'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-export const blogTransaction = defineTransaction({
- // A conversational verb: message history in, streamed chat out.
- drafting: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- systemPrompts: ['You are a seasoned staff writer.'],
- }),
- ),
-
- // A one-shot verb: schema-validated input in, typed result out.
- heroImage: verb({
- input: z.object({ prompt: z.string() }),
- execute: ({ input }) =>
- generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: input.prompt,
- }),
- }),
-
- narration: verb({
- input: z.object({ text: z.string() }),
- execute: ({ input }) =>
- generateSpeech({
- adapter: openaiSpeech('tts-1'),
- text: input.text,
- voice: 'alloy',
- }),
- }),
-})
-
-export const blogTxnDef = clientTransaction({
- drafting: 'chat',
- heroImage: 'one-shot',
- narration: 'one-shot',
-})
-```
-
-```ts group=overview-1
-// routes/api.blog-studio.ts — thin server route
-export const POST = (request: Request) => blogTransaction.handler(request)
-```
-
-That one route now serves conversational drafting, image generation, and narration. On the client, one hook drives all three:
-
-```tsx group=overview-client
-// routes/blog-studio.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import type { UIMessage } from '@tanstack/ai-react'
-import { blogTxnDef } from '../lib/blog-studio'
-
-function BlogStudio() {
- const txn = useTransaction(blogTxnDef, {
- connection: fetchServerSentEvents('/api/blog-studio'),
- })
-
- return (
-
- )
-}
-```
-
-`txn.drafting` behaves exactly like [`useChat`](../chat/streaming) — streaming, tool calls, and message parts all work the same way. Each one-shot verb (`txn.heroImage`, `txn.narration`, ...) is a typed run/result surface: a `.run(input)` method — its `input` typed by the verb's schema, its resolved value by `execute`'s return — plus reactive `.result`, `.isLoading`, `.error`, `.status`, `.stop()`, `.reset()`, and live [`.subRuns`](./transactions#watching-sub-runs-live).
-
-Both `txn..run(input)` and `txn..sendMessage(...)` **resolve to their result**, so you can chain them with a single `await` — `run` resolves to the verb's result (or `null`), and `sendMessage` resolves to the structured `final` (when the chat callback set `outputSchema`) or the messages array otherwise. When steps should be composed *server-side* instead — one request, one abort scope — declare a verb that calls its siblings with `ctx.call`; that's a [transaction](./transactions).
-
-## The two kinds of verbs
-
-**`chatVerb(callback)` — conversational.** The callback receives the parsed chat request (`messages`, `threadId`, `runId`, client-declared `tools`, `forwardedProps`, and the raw `request`) and returns the stream `chat()` produces. On the client it becomes the full chat surface: `sendMessage`, `messages`, tool calls, approvals, structured output.
-
-**`verb({ input, execute })` — one-shot.** `input` is a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, ...) describing the input; the handler validates every incoming request against it **at runtime** before `execute` runs (a mismatch gets a `400` with the validation issues — the callback never sees a malformed input). `execute` receives the validated `req` (with `input`, `threadId`, `runId`, an abort `signal`, and the raw `request`) plus a composition context `ctx`, and returns a `Promise` of the result. On the client it becomes the `run`/`result` surface.
-
-You can declare **several verbs of the same kind** — including several chat verbs. Each gets its own conversation state, system prompt, and even model:
-
-```ts group=overview-2
-// api/support.ts
-import { chat } from '@tanstack/ai'
-import { chatVerb, defineTransaction } from '@tanstack/ai/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-
-export const supportTransaction = defineTransaction({
- // The main conversation the user sees.
- primaryChat: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- systemPrompts: [
- 'You are a customer-support agent for Acme. Be concise and friendly.',
- ],
- }),
- ),
- // A second, independent chat surface: condense the ticket for handoff.
- summaryChat: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- systemPrompts: [
- 'Summarize the conversation you are given as a terse handoff note.',
- ],
- }),
- ),
-})
-
-export const POST = (request: Request) => supportTransaction.handler(request)
-```
-
-On the client, `txn.primaryChat` and `txn.summaryChat` are two fully independent chat surfaces — separate `messages`, separate `sendMessage` — behind the same endpoint.
-
-`defineTransaction()` itself is **inert**: none of these callbacks run, and no adapter is constructed, until a request actually reaches `handler`. The handler discriminates each incoming request by a `verb` field the client sends, routes it to the matching callback, and streams the result back over Server-Sent Events. Undeclared verbs get a `400` before any callback runs.
-
-## Sharing the definition with the client
-
-`useTransaction` needs the definition's **type** to build the typed client — verb names and kinds at runtime, input/result/tool/schema types at compile time.
-
-The recommended pattern is to **`defineTransaction` once in a shared module** and export a **`blogTxnDef`** client stub beside it via `clientTransaction({ kinds })`. The page imports `blogTxnDef`; the API route imports `blogTransaction` and calls `.handler`. The kinds map is checked exhaustively against the server definition, so drift fails at compile time.
-
-```ts group=overview-sharing
-import { clientTransaction, defineTransaction } from '@tanstack/ai/transaction'
-
-// lib/blog-studio.ts
-export const blogTransaction = defineTransaction({ /* … */ })
-
-export const blogTxnDef = clientTransaction({
- drafting: 'chat',
- heroImage: 'one-shot',
- narration: 'one-shot',
- blogPost: 'one-shot',
-})
-```
-
-When the definition is built per-request inside a handler (for example, to pick adapters from routing props), export a `ReturnType` type and bind on the client with `import type` — the generic is erased from the bundle:
-
-```tsx
-import type { blogTransaction } from './api/blog-studio'
-import { clientTransaction } from '@tanstack/ai/transaction'
-
-const blogTxnDef = clientTransaction({
- drafting: 'chat',
- heroImage: 'one-shot',
- narration: 'one-shot',
- blogPost: 'one-shot',
-})
-```
-
-When the definition truly has no provider imports, you can also pass one shared `defineTransaction(...)` value to both `handler` and `useTransaction` directly. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/lib/blog-studio.ts) defines both exports in one lib module.
-
-## When should you reach for a transaction endpoint?
-
-A transaction endpoint is not "a better `useChat`." It earns its place when you have **two or more verbs that live on the same surface** — a page, a workflow, a pipeline — or when the server should compose steps into a single request. If you need exactly one chat box or one image button, the single hook is simpler and there is nothing to gain from the extra layer.
-
-| Your situation | Reach for | Why |
-|---|---|---|
-| One page needs chat **and** images **and** narration | A transaction endpoint | One connection, one typed client, one route to deploy and secure |
-| One verb's output feeds the next (draft a post → illustrate it → narrate it) as a single unit of work | A [transaction](./transactions) | Server-side composition via `ctx.call`: one request, live sub-run streaming, one abort scope |
-| The user drives each step by hand (regenerate the image, re-narrate) | The same verbs, called directly from the client | `txn.heroImage.run(...)` — same endpoint, same types |
-| A page that only sends chat messages | [`useChat`](../chat/streaming) | No second verb to share — the transaction layer adds nothing |
-| A page that only generates images | [`useGenerateImage`](../media/generation-hooks) | Same — a single hook is the whole story |
-
-**The honest tradeoff:** a transaction endpoint centralizes routing and typing across verbs, but it also couples them into one definition and one endpoint. If two features never appear together and want different auth or system prompts, don't force them into one definition — give each surface its own (see [Scenarios](./scenarios)).
-
-## Typed chat verbs: tools and structured output
-
-A chat verb inherits `useChat`'s full type inference, driven entirely by what its server callback passes to `chat()` — you don't re-declare anything on the client.
-
-**Callback tools auto-type the verb's `messages`.** Pass `tools` to `chat()` in the callback and the tool-call/result parts on `txn..messages` are typed from those tools, with nothing to register on the client:
-
-```tsx
-// components/WeatherChat.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import { chat, toolDefinition } from '@tanstack/ai'
-import { chatVerb, defineTransaction } from '@tanstack/ai/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const getWeatherDef = toolDefinition({
- name: 'get_weather',
- description: 'Get the current weather for a city',
- inputSchema: z.object({ city: z.string() }),
- outputSchema: z.object({ tempF: z.number(), conditions: z.string() }),
-})
-
-const getWeather = getWeatherDef.server(async ({ city }) => {
- return { tempF: 72, conditions: `Sunny in ${city}` }
-})
-
-// The same definition your server route exports — share it from one module in
-// a real app; repeated here so this snippet type-checks on its own.
-const weatherTransaction = defineTransaction({
- primaryChat: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- tools: [getWeather],
- }),
- ),
-})
-
-function WeatherChat() {
- const txn = useTransaction(weatherTransaction, {
- connection: fetchServerSentEvents('/api/weather'),
- })
-
- const weatherCall = txn.primaryChat.messages
- .at(-1)
- ?.parts.find(
- (part) => part.type === 'tool-call' && part.name === 'get_weather',
- )
-
- return (
-
- )
-}
-```
-
-No tools option was passed to `useTransaction` — `weatherCall.output` is still narrowed to `{ tempF: number; conditions: string }`, inferred entirely from `tools: [getWeather]` on the server callback.
-
-**Per-verb `tools` are only for client-executed tools.** A [client tool](../tools/client-tools)'s `.client()` implementation runs in the browser, so its code can't cross the wire. The server callback sees only the tool's *definition* (for typing and to tell the model it exists); the client registers the runtime implementation under the verb's entry in the nested `verbs` option. Types still come from the server callback either way:
-
-```tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import { chat, toolDefinition } from '@tanstack/ai'
-import { chatVerb, defineTransaction } from '@tanstack/ai/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const showToastDef = toolDefinition({
- name: 'show_toast',
- description: 'Show a browser notification',
- inputSchema: z.object({ message: z.string() }),
-})
-
-const showToast = showToastDef.client((input) => {
- console.log(input.message)
- return { ok: true }
-})
-
-// The same definition your server route exports — share it from one module in
-// a real app; repeated here so this snippet type-checks on its own.
-const toastTransaction = defineTransaction({
- primaryChat: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- tools: [showToastDef], // definition only — the client executes it
- }),
- ),
-})
-
-function useToastChat() {
- return useTransaction(toastTransaction, {
- connection: fetchServerSentEvents('/api/toast'),
- verbs: {
- primaryChat: { tools: [showToast] },
- },
- })
-}
-```
-
-**`outputSchema` → typed `partial` / `final`.** If a chat verb's callback passes an `outputSchema` to `chat()`, that verb's surface picks up a progressively-parsed `partial` (`DeepPartial`) and a validated terminal `final` — the same inference [`useChat({ outputSchema })`](../structured-outputs/streaming) gives you:
-
-```tsx
-// components/BlogOutlineForm.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import { chat } from '@tanstack/ai'
-import { chatVerb, defineTransaction } from '@tanstack/ai/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const BlogOutlineSchema = z.object({
- title: z.string(),
- sections: z.array(z.string()),
-})
-
-// The same definition your server route exports — share it from one module in
-// a real app; repeated here so this snippet type-checks on its own.
-const outlineTransaction = defineTransaction({
- drafting: chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: BlogOutlineSchema,
- stream: true,
- }),
- ),
-})
-
-function BlogOutlineForm() {
- const txn = useTransaction(outlineTransaction, {
- connection: fetchServerSentEvents('/api/outline'),
- })
-
- return (
-
- )
-}
-```
-
-`partial` and `final` only exist on the type when the callback declares `outputSchema` — omit it and the verb's surface has no such fields, exactly like `useChat` without `outputSchema`. See [Structured Outputs](../structured-outputs/overview) for the full story.
-
-## Transforming one-shot results
-
-Each one-shot verb's entry in the nested `verbs` option accepts an `onResult` transform. It runs on the raw backend result before it lands on `txn..result`, and **its return type becomes that verb's `result` type**. Use it to reshape a result once, at the hook, instead of deriving it in every component that reads it:
-
-```tsx
-// components/CoverArt.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import { generateImage } from '@tanstack/ai'
-import { defineTransaction, verb } from '@tanstack/ai/transaction'
-import { openaiImage } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const coverTransaction = defineTransaction({
- heroImage: verb({
- input: z.object({ prompt: z.string() }),
- execute: ({ input }) =>
- generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: input.prompt,
- }),
- }),
-})
-
-function CoverArt() {
- const txn = useTransaction(coverTransaction, {
- connection: fetchServerSentEvents('/api/cover'),
- verbs: {
- // `result` is the raw `ImageGenerationResult`; the return type flows
- // through to `txn.heroImage.result`.
- heroImage: { onResult: (result) => result.images[0]?.url ?? null },
- },
- })
-
- return (
-
-
- {/* `txn.heroImage.result` is now `string | null`, not the raw result */}
- {txn.heroImage.result && }
-
- )
-}
-```
-
-Return `undefined` (or nothing) from `onResult` to keep the raw result. Each verb's entry also takes `forwardedProps` to merge extra fields into that verb's request body. The per-verb options are nested under `verbs` (rather than spread at the top level) so your verb names can never collide with `connection` / `id` / `threadId`.
-
-## Which page do I read?
-
-| You want to… | Read |
-|---|---|
-| Compose verbs server-side — one client call runs a whole pipeline as a single request with live progress and one abort scope | [Transactions](./transactions) |
-| Give different pages or features their own verb sets, routes, and system prompts — and decide between user-driven verbs, server-composed transactions, and plain `useChat` | [Scenarios](./scenarios) |
-| Understand the underlying chat surface (streaming, tools, message parts) | [Chat: Streaming](../chat/streaming) |
-| Drive a single capability without a transaction endpoint | [Media & Generation Hooks](../media/generation-hooks) |
diff --git a/docs/transaction/scenarios.md b/docs/transaction/scenarios.md
deleted file mode 100644
index f3cb6fda0..000000000
--- a/docs/transaction/scenarios.md
+++ /dev/null
@@ -1,203 +0,0 @@
----
-title: Scenarios
-id: transaction-scenarios
-order: 3
-description: "One transaction definition per product surface — a support page with a single chat verb, a content studio with drafting + image + narration — each with its own route, its own useTransaction, and its own system prompts. Plus the decision guide: user-driven verbs, server-composed transactions, or plain useChat?"
-keywords:
- - tanstack ai
- - transaction
- - defineTransaction
- - useTransaction
- - multiple endpoints
- - system prompt
- - routes
----
-
-**One `defineTransaction` per product surface.** A transaction definition bundles a set of verbs behind one endpoint. When different pages or features want different verb sets, different instructions, or different auth, don't stretch a single definition to cover them all — define one per surface. Each gets its own route and its own `useTransaction` on its own page.
-
-A typical app ends up with a few:
-
-| Surface | Verbs | Why its own definition |
-|---|---|---|
-| Customer **support** page | `supportChat` | Tight, support-flavored system prompt; nothing else to expose |
-| **Content studio** page | `drafting` + `heroImage` + `narration` + `blogPost` | A creative workflow, including a server-composed [transaction](./transactions) |
-| Internal **dashboard** | `analystChat` + `weeklyDigest` | Different auth boundary; different instructions |
-
-## Two definitions, two routes
-
-Give each definition its own module and its own handler. A small helper keeps the shared adapter config — the model choice and any provider options — in one place, so the surfaces stay in sync without duplicating it:
-
-```ts
-// api/transactions.ts
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-// One place to change the model / provider options for every surface.
-const textModel = () => openaiText('gpt-5.5')
-
-// Support: a single chat verb, with a support-flavored system prompt.
-export const supportTransaction = defineTransaction({
- supportChat: chatVerb((req) =>
- chat({
- adapter: textModel(),
- messages: req.messages,
- systemPrompts: [
- 'You are a customer-support agent for Acme. Be concise and friendly.',
- ],
- }),
- ),
-})
-
-// Studio: drafting + image + narration, for a creative workflow.
-export const studioTransaction = defineTransaction({
- drafting: chatVerb((req) =>
- chat({
- adapter: textModel(),
- messages: req.messages,
- systemPrompts: ['You are a creative copywriter.'],
- }),
- ),
- heroImage: verb({
- input: z.object({ prompt: z.string() }),
- execute: ({ input }) =>
- generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: input.prompt,
- }),
- }),
- narration: verb({
- input: z.object({ text: z.string() }),
- execute: ({ input }) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }),
- }),
-})
-
-// Two routes — one per definition.
-export const supportPOST = (request: Request) =>
- supportTransaction.handler(request)
-export const studioPOST = (request: Request) =>
- studioTransaction.handler(request)
-```
-
-In a real app these are two route files — `/api/support` and `/api/studio` — each exporting its own `POST`. The `supportPOST` / `studioPOST` names above just let both live in one snippet.
-
-## Two pages, two clients
-
-Each page calls `useTransaction` against *its own* definition and *its own* connection. The support page only ever sees `txn.supportChat`, because that's all `supportTransaction` declared:
-
-```tsx
-// pages/SupportPage.tsx
-import { chat } from '@tanstack/ai'
-import { chatVerb, defineTransaction } from '@tanstack/ai/transaction'
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-
-// The same definition your server route exports — share it from one module
-// in a real app; repeated here so this snippet type-checks on its own.
-const supportTransaction = defineTransaction({
- supportChat: chatVerb((req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- ),
-})
-
-function SupportPage() {
- const txn = useTransaction(supportTransaction, {
- connection: fetchServerSentEvents('/api/support'),
- })
-
- return (
-
- )
-}
-```
-
-Each page's `txn` object is typed to exactly its definition's verbs — `SupportPage` has no `txn.heroImage` to misuse, and there's no way to accidentally call the support route with an image request. (When a route builds its definition inside the handler, bind the client with `clientTransaction` as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client).)
-
-## Split, or one broad definition?
-
-Both are valid. Choose by how the verbs actually relate:
-
-**Split into multiple definitions when:**
-
-- **Different product surfaces.** A support widget and a content studio are different features with different users — separate definitions keep their prompts, verbs, and routes independent.
-- **Different verb sets.** If support never needs image generation, don't expose it there. A narrower definition is a narrower attack surface and a simpler client type.
-- **Different auth or rate-limit boundaries.** Separate routes let you guard `/api/support` and `/api/studio` independently.
-
-**Use one broad definition when:**
-
-- **The verbs are always used together** on the same page or in the same workflow — especially when a composing verb needs to `ctx.call` them: siblings must live in the same definition to be composed into a [transaction](./transactions).
-- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one.
-
-The rule of thumb: **split by surface, not by verb count.** A single page that happens to use four verbs is one definition; three pages that each use chat are three definitions.
-
-## User-driven verbs, server-composed transactions, or just `useChat`?
-
-The same pipeline can be built three ways. Pick by *who orchestrates* and *what the unit of work is*:
-
-**Just `useChat` (or a single generation hook)** when there's exactly one kind of AI work on the page. A chat box is a chat box — the transaction layer adds a definition, a routing discriminator, and nothing else. Reach for `defineTransaction` only once a second verb shows up.
-
-**User-driven verbs** when the *user* orchestrates: each step is its own gesture — draft in the chat, then click "illustrate", then click "narrate", regenerate any step at will. The client chains awaited returns (`const messages = await txn.drafting.sendMessage(...)`, then `await txn.heroImage.run(...)`), each step is its own request, and each can be retried independently. This is the loosest coupling: steps can be abandoned halfway, reordered, or repeated.
-
-**A server-composed transaction** when the pipeline is *one unit of work* from the user's point of view: one click should produce the finished artifact or fail as a whole. Declare a composing verb and let `ctx.call` run the steps server-side — one request, live sub-run progress, one abort scope, intermediate values that never round-trip through the browser, and a single typed result. See [Transactions](./transactions).
-
-| | `useChat` / single hook | User-driven verbs | Server-composed transaction |
-|---|---|---|---|
-| Orchestrated by | — (single step) | The client, step by step | The server, inside `execute` |
-| Requests | One per interaction | One per step | **One for the whole pipeline** |
-| Cancel | Per request | Per step | One `stop()` aborts everything |
-| Intermediate values | — | Round-trip through the browser | Stay on the server (streamed live as sub-runs) |
-| Failure | Per request | Per step; earlier steps keep their results | The run fails as a unit (unless `execute` catches) |
-| Best for | A page with one AI feature | Exploratory, editable workflows | One-click pipelines with a definite finished artifact |
-
-These compose: the blog studio in [Transactions](./transactions) declares `blogPost` (the one-click transaction) *alongside* `heroImage` and `narration` — the same one-shot verbs the transaction composes are also driven directly by the user for "regenerate" buttons. Server-composed for the first pass, user-driven for the touch-ups, one definition and one endpoint for both.
-
-## Next
-
-- [Transaction Overview](./overview) — the two verb kinds, client typing, and when to reach for a transaction endpoint at all.
-- [Transactions](./transactions) — composing verbs with `ctx.call`: sub-runs, abort semantics, and error behavior.
-- [Client Tools](../tools/client-tools) — run tool implementations in the browser inside a chat verb.
diff --git a/docs/transaction/transactions.md b/docs/transaction/transactions.md
deleted file mode 100644
index 6e2d0cdcc..000000000
--- a/docs/transaction/transactions.md
+++ /dev/null
@@ -1,273 +0,0 @@
----
-title: Transactions
-id: transaction-transactions
-order: 2
-description: "Compose verbs server-side with ctx.call: one client call runs a whole pipeline — draft a post, illustrate it, narrate it — as a SINGLE request, with every step streamed back live as a sub-run and one abort scope covering the lot. Learn the sub-run protocol, cancellation semantics, and error behavior."
-keywords:
- - tanstack ai
- - transaction
- - ctx.call
- - sub-runs
- - defineTransaction
- - clientTransaction
- - useTransaction
- - abort
- - cancellation
----
-
-**A transaction is a one-shot verb that composes its sibling verbs server-side.** Its `execute` receives a context `ctx`, and `ctx.call(siblingVerb, input)` runs that sibling *inside the same request* — the sub-run's stream is forwarded live to the client as tagged events, and the `await` resolves with the sibling's result for the next step to consume. One client call, one HTTP request, one abort scope for the entire pipeline.
-
-This is the closest thing to transactional semantics this domain allows: you can't roll back tokens a model already generated, but you *can* treat the pipeline as a single unit of work — one call starts it, live progress streams out of it, cancelling it halts the orchestration and aborts the in-flight provider work, and the UI unwinds its partial state.
-
-## The worked example: a blog studio
-
-One click turns a topic into a finished post: draft the article as a typed object, then — in parallel — generate a hero image and record a voice-over, both derived from the validated draft. This is the [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) from the repository, condensed.
-
-### Server: a `blogPost` verb that composes its siblings
-
-```ts group=transactions
-// lib/blog-studio.ts
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-export const BlogPostSchema = z.object({
- title: z.string(),
- subtitle: z.string(),
- body: z.string().describe('The full post as Markdown'),
-})
-
-// Conversational verb: writes the post as a typed object
-// (structured output + streaming).
-const drafting = chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- systemPrompts: ['You are a seasoned staff writer.'],
- outputSchema: BlogPostSchema,
- stream: true,
- threadId: req.threadId,
- runId: req.runId,
- }),
-)
-
-// One-shot verb: a hero image from a prompt.
-const heroImage = verb({
- input: z.object({ prompt: z.string() }),
- execute: ({ input }) =>
- generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: input.prompt,
- size: '1536x1024',
- }),
-})
-
-// One-shot verb: narrate a piece of text.
-const narration = verb({
- input: z.object({ text: z.string() }),
- execute: ({ input }) =>
- generateSpeech({
- adapter: openaiSpeech('tts-1'),
- text: input.text,
- voice: 'alloy',
- }),
-})
-
-// The transaction: one client call composes the three verbs above,
-// entirely server-side.
-const blogPost = verb({
- input: z.object({ topic: z.string() }),
- execute: async ({ input }, ctx) => {
- // 1. Draft the post. `ctx.call` on a chat verb resolves with the
- // accumulated text and the structured output; re-validate it so a
- // half-finished draft fails the run with a clear error.
- const draft = await ctx.call(drafting, [
- { role: 'user', content: `Write a blog post about: ${input.topic}` },
- ])
- const parsed = BlogPostSchema.safeParse(draft.structured)
- if (!parsed.success) {
- throw new Error(
- `Drafting did not produce a valid blog post: ${parsed.error.message}`,
- )
- }
- const post = parsed.data
-
- // 2. Illustrate and narrate in parallel, both derived from the
- // validated draft.
- const [hero, audio] = await Promise.all([
- ctx.call(heroImage, {
- prompt: `Editorial hero image for "${post.title}". ${post.subtitle}.`,
- }),
- ctx.call(narration, { text: post.body }),
- ])
-
- // 3. The return value becomes the run's final result on the client
- // (`txn.blogPost.result`).
- return { post, hero, audio }
- },
-})
-
-export const blogTransaction = defineTransaction({
- drafting,
- heroImage,
- narration,
- blogPost,
-})
-```
-
-```ts group=transactions
-// routes/api.blog-studio.ts
-export const POST = (request: Request) => blogTransaction.handler(request)
-```
-
-`ctx.call` has two shapes, both typed end to end:
-
-- **`ctx.call(oneShotVerb, input)`** validates `input` against the sibling's schema, runs its `execute`, and resolves with its result — typed as that verb's result type.
-- **`ctx.call(chatVerbObj, messages)`** runs the chat verb's callback to completion server-side and resolves with `{ text, structured }` — the accumulated assistant text plus the structured output when the callback declared an `outputSchema`. `structured` arrives as `unknown`; re-validate it with `safeParse` (as above) rather than trusting the shape.
-
-### Client: one call, live progress, typed result
-
-```tsx group=transactions
-// routes/blog-studio.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
-import type { TransactionSubRun } from '@tanstack/ai-client/transaction'
-import { blogTxnDef } from '../lib/blog-studio'
-
-function BlogStudio() {
- const txn = useTransaction(blogTxnDef, {
- connection: fetchServerSentEvents('/api/blog-studio'),
- })
-
- const { blogPost } = txn
- // One entry per `ctx.call` the server made, keyed by verb name.
- const draftingRun = blogPost.subRuns.find(
- (run: TransactionSubRun) => run.verb === 'drafting',
- )
- const heroRun = blogPost.subRuns.find(
- (run: TransactionSubRun) => run.verb === 'heroImage',
- )
- const narrationRun = blogPost.subRuns.find(
- (run: TransactionSubRun) => run.verb === 'narration',
- )
-
- return (
-
- )
-}
-```
-
-`blogPost.result` is typed as `{ post, hero, audio } | null` — inferred from `execute`'s return type on the server definition, nothing re-declared on the client. The lib module exports a `blogTxnDef` stub via `clientTransaction` so the page never duplicates verb callbacks (see [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client)).
-
-And because `heroImage` and `narration` are ordinary declared verbs, the *same* client can also drive them directly — `txn.heroImage.run({ prompt })` for a "regenerate hero image" button — without another endpoint or another definition. Server-composed and user-driven are two ways to call the same verbs.
-
-## Watching sub-runs live
-
-Everything a transaction does streams back over the single SSE response. Each `ctx.call` is wrapped in tagged `CUSTOM` events (the names live on `TRANSACTION_EVENTS`, exported from `@tanstack/ai/transaction`):
-
-| Event | When |
-|---|---|
-| `transaction:sub-run:started` | A `ctx.call` began; carries `{ runId, parentRunId, verb, index }` |
-| `transaction:sub-run:chunk` | A chunk of the sub-run's own stream, forwarded live |
-| `transaction:sub-run:result` | The sub-run completed; carries its `result` |
-| `transaction:sub-run:error` | The sub-run threw; carries its `message` |
-
-You rarely touch these directly — the client demultiplexes them into `txn..subRuns`, an array of live sub-run states in server start order:
-
-```ts
-interface TransactionSubRun {
- runId: string
- verb: string // the sub-verb's name in the definition
- index: number // 0-based server start order
- status: 'running' | 'success' | 'error'
- result: unknown // set once the sub-run completes
- text: string // accumulated streamed text, for chat-verb sub-runs
- error?: string
-}
-```
-
-For chat-verb sub-runs, `text` accumulates the streamed assistant text as it arrives — a live word count or progress indicator falls out for free. One caveat from the worked example: when the chat callback declares an `outputSchema`, the streamed text is the structured output's partial JSON, so show progress by length (`${run.text.length} chars drafted`) rather than rendering it verbatim. The array resets when a new run starts, and `subRuns` is empty for verbs that don't compose siblings.
-
-The verb's own return value travels as a terminal `generation:result` event and lands in `txn..result` — the same place it lands for a simple, non-composing verb.
-
-## One request, one abort scope
-
-Because the entire pipeline is a single HTTP request, cancellation is one gesture end to end:
-
-- **Client:** `txn.blogPost.stop()` aborts the fetch. The reactive surfaces unwind — `isLoading` drops, in-flight sub-runs stop updating.
-- **Server:** the aborted request trips `req.signal` (also available as `ctx.signal` — both are the request's `AbortSignal`). `ctx.call` refuses to start new sub-runs once the signal is aborted, and chat sub-runs stop consuming their stream.
-- **Providers:** pass the signal into the activities your verbs run so the upstream provider calls are cancelled too, not just orphaned:
-
-```ts
-import { chat } from '@tanstack/ai'
-import { verb } from '@tanstack/ai/transaction'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const tagline = verb({
- input: z.object({ product: z.string() }),
- execute: async (req) => {
- // `chat()` takes an AbortController; chain it off the request signal so
- // a client disconnect / stop() aborts this provider call mid-flight.
- const abortController = new AbortController()
- req.signal.addEventListener('abort', () => abortController.abort(), {
- once: true,
- })
- const text = await chat({
- adapter: openaiText('gpt-5.5'),
- messages: [
- { role: 'user', content: `Write a tagline for ${req.input.product}` },
- ],
- stream: false,
- abortController,
- })
- return { tagline: text }
- },
-})
-```
-
-The same applies inside a composing verb: `ctx.signal` is the one signal for the whole transaction, so every provider call at any depth hangs off the same scope. Cancel once, and the orchestration halts, in-flight sub-work aborts, and nothing keeps burning tokens in the background.
-
-## Error behavior
-
-A transaction fails as a unit:
-
-- If a sub-run throws (or a `ctx.call` input fails its sibling's schema validation), the server emits `transaction:sub-run:error` for that sub-run and the error propagates out of `ctx.call` into your `execute` — unless you catch it. An uncaught error fails the whole run: the stream terminates with a `RUN_ERROR` event instead of `RUN_FINISHED`.
-- On the client, the failed step shows up in `subRuns` with `status: 'error'` and its `error` message, `txn..error` is set, and `txn..result` stays `null`. Completed sub-runs keep their `success` status, so the UI can show exactly which step failed.
-- Want a step to be best-effort instead? Wrap that `ctx.call` in `try`/`catch` inside `execute` and return a partial result — the run then finishes normally. The example above deliberately does *not* do this: a post without its image is a failed post.
-- Validation failures of the *top-level* input (the object passed to `txn..run(...)`) never reach `execute` at all — the handler responds `400` with the schema issues.
-
-## Next
-
-- [Transaction Overview](./overview) — the two verb kinds, client typing, tools, and structured output.
-- [Scenarios](./scenarios) — one definition per product surface, and choosing between user-driven verbs, server-composed transactions, and plain `useChat`.
diff --git a/examples/ts-react-chat/src/lib/blog-studio.ts b/examples/ts-react-chat/src/lib/blog-studio.ts
index 3f9ad3afe..d5354731c 100644
--- a/examples/ts-react-chat/src/lib/blog-studio.ts
+++ b/examples/ts-react-chat/src/lib/blog-studio.ts
@@ -1,18 +1,17 @@
import { chat, generateImage, generateSpeech } from '@tanstack/ai'
import {
- chatVerb,
- clientTransaction,
- defineTransaction,
- verb,
-} from '@tanstack/ai/transaction'
+ chatPlugin,
+ definePlugin,
+ imagePlugin,
+ speechPlugin,
+} from '@tanstack/ai/plugin'
import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
/**
- * The shape a blog post is drafted into. The `drafting` chat verb declares
- * this as its `outputSchema`, so its structured output is schema-validated —
- * both when the client talks to it directly and when the `blogPost`
- * transaction runs it server-side via `ctx.call`.
+ * The shape a blog post is drafted into. The `drafting` chat plugin declares
+ * this as its `outputSchema`, so its structured output is schema-validated and
+ * the client's `drafting.sendMessage(topic)` resolves to a typed `BlogPost`.
*/
export const BlogPostSchema = z.object({
title: z.string().describe('A punchy, editorial blog post title'),
@@ -34,9 +33,9 @@ export const BLOG_STUDIO_SYSTEM_PROMPT =
'paragraphs. Be vivid and concrete; avoid filler and clichés.'
/**
- * Build the hero-image prompt from a drafted post. Used by the server-side
- * `blogPost` transaction and by the client's "Regenerate hero image" button,
- * so both produce the same style of illustration.
+ * Build the hero-image prompt from a drafted post. Called on the client to
+ * derive the `heroImage` plugin's input from the finished draft, so the
+ * initial illustration and the "Regenerate hero image" button match.
*/
export function heroPromptFor(post: BlogPost): string {
return (
@@ -49,8 +48,9 @@ export function heroPromptFor(post: BlogPost): string {
/**
* Prepare the post body for narration: strip Markdown so TTS doesn't read the
* syntax aloud, and cap the length at a sentence boundary (OpenAI TTS rejects
- * input over 4096 characters, and long posts easily exceed it). Used by the
- * server-side `blogPost` transaction and by the client's "Re-narrate" button.
+ * input over 4096 characters, and long posts easily exceed it). Called on the
+ * client to derive the `narration` plugin's input for both the initial
+ * voice-over and the "Re-narrate" button.
*/
export function forNarration(markdown: string, max = 4000): string {
const plain = markdown
@@ -72,92 +72,49 @@ export function forNarration(markdown: string, max = 4000): string {
return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim()
}
-// Conversational verb: writes the post as a typed object
-// (structured output + streaming).
-const drafting = chatVerb((req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- systemPrompts: [BLOG_STUDIO_SYSTEM_PROMPT],
- outputSchema: BlogPostSchema,
- stream: true,
- threadId: req.threadId,
- runId: req.runId,
- }),
-)
+/**
+ * The blog-studio plugin: three independent plugins behind one endpoint.
+ *
+ * - `drafting` is a chat plugin that streams the post as structured output
+ * (schema-validated against {@link BlogPostSchema}).
+ * - `heroImage` / `narration` are one-shot media plugins (image + TTS).
+ *
+ * There is no server-side composition: the client sequences these itself —
+ * `drafting.sendMessage(topic)`, then `heroImage.run(...)` and
+ * `narration.run(...)` in parallel (see the route). `definePlugin` is inert
+ * until `handler(request)` runs, so importing this module into the browser
+ * ships only the (inert) adapter code, never the API keys.
+ */
+export const blogPlugin = definePlugin({
+ // Conversational plugin: writes the post as a typed object
+ // (structured output + streaming).
+ drafting: chatPlugin((req) =>
+ chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: req.messages,
+ systemPrompts: [BLOG_STUDIO_SYSTEM_PROMPT],
+ outputSchema: BlogPostSchema,
+ stream: true,
+ threadId: req.threadId,
+ runId: req.runId,
+ }),
+ ),
-// One-shot verb: a landscape hero / OG image from a prompt.
-const heroImage = verb({
- input: z.object({ prompt: z.string() }),
- execute: ({ input }) =>
+ // One-shot media plugin: a landscape hero / OG image from a prompt.
+ heroImage: imagePlugin((req) =>
generateImage({
adapter: openaiImage('gpt-image-2'),
- prompt: input.prompt,
+ prompt: req.input.prompt,
size: '1536x1024',
}),
-})
+ ),
-// One-shot verb: narrate a piece of text.
-const narration = verb({
- input: z.object({ text: z.string() }),
- execute: ({ input }) =>
+ // One-shot media plugin: narrate a piece of text.
+ narration: speechPlugin((req) =>
generateSpeech({
adapter: openaiSpeech('tts-1'),
- text: input.text,
+ text: req.input.text,
voice: 'alloy',
}),
-})
-
-// The transaction: one client call composes the three verbs above,
-// entirely server-side. Each `ctx.call` streams back to the client as
-// a live, tagged sub-run of this single request — and the whole
-// pipeline shares one abort scope (client disconnect / stop() cancels
-// everything).
-const blogPost = verb({
- input: z.object({ topic: z.string() }),
- execute: async ({ input }, ctx) => {
- // 1. Draft the post. `ctx.call` on a chat verb resolves with the
- // accumulated text and the structured output; re-validate it so
- // a half-finished draft fails the run with a clear error.
- const draft = await ctx.call(drafting, [
- {
- role: 'user',
- content: `Write a blog post about: ${input.topic}`,
- },
- ])
- const parsed = BlogPostSchema.safeParse(draft.structured)
- if (!parsed.success) {
- throw new Error(
- `Drafting did not produce a valid blog post: ${parsed.error.message}`,
- )
- }
- const post = parsed.data
-
- // 2. Illustrate and narrate in parallel, both derived from the
- // validated draft.
- const [hero, audio] = await Promise.all([
- ctx.call(heroImage, { prompt: heroPromptFor(post) }),
- ctx.call(narration, { text: forNarration(post.body) }),
- ])
-
- // 3. The return value becomes the run's final result on the
- // client (`txn.blogPost.result`).
- return { post, hero, audio }
- },
-})
-
-/** Server-side transaction definition — wire to `blogTransaction.handler`. */
-export const blogTransaction = defineTransaction({
- drafting,
- heroImage,
- narration,
- blogPost,
-})
-
-/** Client-side type binding — pass to `useTransaction`. */
-export const blogTxnDef = clientTransaction({
- drafting: 'chat',
- heroImage: 'one-shot',
- narration: 'one-shot',
- blogPost: 'one-shot',
+ ),
})
diff --git a/examples/ts-react-chat/src/routes/api.blog-studio.ts b/examples/ts-react-chat/src/routes/api.blog-studio.ts
index 3b5481cbc..259034883 100644
--- a/examples/ts-react-chat/src/routes/api.blog-studio.ts
+++ b/examples/ts-react-chat/src/routes/api.blog-studio.ts
@@ -1,14 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
-import { blogTransaction } from '../lib/blog-studio'
+import { blogPlugin } from '../lib/blog-studio'
export const Route = createFileRoute('/api/blog-studio')({
server: {
handlers: {
- // One endpoint, four verbs. `defineTransaction` is inert until
+ // One endpoint, three plugins. `definePlugin` is inert until
// `handler(request)` runs, at which point it parses the AG-UI request,
- // routes by the `verb` discriminator the client sends, and streams the
+ // routes by the `plugin` discriminator the client sends, and streams the
// result back over SSE.
- POST: ({ request }) => blogTransaction.handler(request),
+ POST: ({ request }) => blogPlugin.handler(request),
},
},
})
diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx
index aa3e9fac6..799c15382 100644
--- a/examples/ts-react-chat/src/routes/blog-studio.tsx
+++ b/examples/ts-react-chat/src/routes/blog-studio.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useTransaction } from '@tanstack/ai-react/transaction'
+import { usePlugin } from '@tanstack/ai-react/plugin'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
@@ -15,7 +15,7 @@ import {
Volume2,
Wand2,
} from 'lucide-react'
-import { blogTxnDef, forNarration, heroPromptFor } from '../lib/blog-studio'
+import { blogPlugin, forNarration, heroPromptFor } from '../lib/blog-studio'
import type { ImageGenerationResult, TTSResult } from '@tanstack/ai'
import type { FormEvent, ReactNode } from 'react'
@@ -25,18 +25,17 @@ export const Route = createFileRoute('/blog-studio')({
type StepState = 'pending' | 'active' | 'done' | 'failed'
-// Map a live sub-run's status onto a step state. `undefined` means the
-// server hasn't started that sub-run yet.
-function subRunToStep(
- status: 'running' | 'success' | 'error' | undefined,
-): StepState {
- return status === 'running'
- ? 'active'
- : status === 'success'
- ? 'done'
- : status === 'error'
- ? 'failed'
- : 'pending'
+// Derive a step's UI state from a one-shot plugin surface (heroImage /
+// narration): active while running, failed on error, done once a result lands.
+function genStep(surface: {
+ isLoading: boolean
+ error: Error | undefined
+ result: unknown
+}): StepState {
+ if (surface.isLoading) return 'active'
+ if (surface.error) return 'failed'
+ if (surface.result) return 'done'
+ return 'pending'
}
// The image result carries either a URL or base64 data, provider-dependent.
@@ -55,99 +54,92 @@ function audioSrcOf(result: TTSResult | null): string | null {
return `data:${result.contentType ?? 'audio/mpeg'};base64,${result.audio}`
}
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null
-}
-
-// The drafting sub-run's live `partial` is typed `unknown` (progressively
-// parsed structured output). Narrow it to the blog-post fields without an
-// `as` cast — each field may be absent or half-written while streaming.
-function asBlogPostDraft(partial: unknown): {
- title?: string
- subtitle?: string
- body?: string
-} {
- if (!isRecord(partial)) return {}
- const str = (value: unknown): string | undefined =>
- typeof value === 'string' ? value : undefined
- return {
- title: str(partial.title),
- subtitle: str(partial.subtitle),
- body: str(partial.body),
- }
-}
-
function BlogStudio() {
- const txn = useTransaction(blogTxnDef, {
+ // The blog plugin exposes three independent surfaces behind one endpoint.
+ // Orchestration happens here on the client — there is no server composition.
+ const p = usePlugin(blogPlugin, {
connection: fetchServerSentEvents('/api/blog-studio'),
+ drafting: {},
+ heroImage: {},
+ narration: {},
})
- // Everything below is derived from the transaction's reactive state — no
- // useState / useEffect. `blogPost` is the one-click transaction; `heroImage`
- // and `narration` are the same verbs, driven directly by the user for
- // regeneration.
- const { blogPost } = txn
- const heroRerun = txn.heroImage
- const narrationRerun = txn.narration
+ const { drafting, heroImage, narration } = p
- const post = blogPost.result?.post ?? null
- // Prefer an individually regenerated hero / narration over the one the
- // transaction produced.
- const imageUrl = imageUrlOf(heroRerun.result ?? blogPost.result?.hero ?? null)
- const audioSrc = audioSrcOf(
- narrationRerun.result ?? blogPost.result?.audio ?? null,
- )
-
- // Live sub-run state, demultiplexed from the single SSE response: one entry
- // per `ctx.call` the server made, keyed by verb name.
- const subRuns = blogPost.subRuns
- const draftingRun = subRuns.find((run) => run.verb === 'drafting')
- const heroRun = subRuns.find((run) => run.verb === 'heroImage')
- const narrationRun = subRuns.find((run) => run.verb === 'narration')
+ // The finished, schema-validated draft (once the structured-output stream
+ // completes); `partial` is the live, progressively-parsed version.
+ const post = drafting.final
+ const partial = drafting.partial
- const isRunning = blogPost.isLoading
- const hasRun = blogPost.status !== 'idle'
+ const imageUrl = imageUrlOf(heroImage.result)
+ const audioSrc = audioSrcOf(narration.result)
- // While the `drafting` chat sub-run streams, the demux progressively parses
- // its structured output into `partial` — a live `{ title?, subtitle?, body? }`
- // that fills in as the JSON arrives. Render it directly for a streaming
- // preview before the transaction finishes and `blogPost.result` lands.
- const liveDraft = asBlogPostDraft(draftingRun?.partial)
- const draftedChars = liveDraft.body?.length ?? 0
- const writingStep = subRunToStep(draftingRun?.status)
+ // Any leg of the pipeline in flight.
+ const isRunning =
+ drafting.isLoading || heroImage.isLoading || narration.isLoading
// Prefer the finished post; fall back to the live streaming draft.
- const shownTitle = post?.title ?? liveDraft.title
- const shownSubtitle = post?.subtitle ?? liveDraft.subtitle
- const shownBody = post?.body ?? liveDraft.body
+ const shownTitle = post?.title ?? partial.title
+ const shownSubtitle = post?.subtitle ?? partial.subtitle
+ const shownBody = post?.body ?? partial.body
const showArticle = Boolean(shownTitle || shownBody)
+ const draftedChars = shownBody?.length ?? 0
- const heroBusy = heroRerun.isLoading || heroRun?.status === 'running'
- const heroFailed = heroRerun.status === 'error' || heroRun?.status === 'error'
- const narrationBusy =
- narrationRerun.isLoading || narrationRun?.status === 'running'
- const narrationFailed =
- narrationRerun.status === 'error' || narrationRun?.status === 'error'
+ const writingStep: StepState = drafting.isLoading
+ ? 'active'
+ : post
+ ? 'done'
+ : drafting.error
+ ? 'failed'
+ : 'pending'
+ const heroStep = genStep(heroImage)
+ const narrationStep = genStep(narration)
+
+ const heroBusy = heroImage.isLoading
+ const heroFailed = heroStep === 'failed'
+ const narrationBusy = narration.isLoading
+ const narrationFailed = narrationStep === 'failed'
+
+ // Has anything started? Drives the step list + empty state.
+ const hasRun =
+ isRunning ||
+ Boolean(post) ||
+ Boolean(heroImage.result) ||
+ Boolean(narration.result) ||
+ Boolean(shownBody)
+
+ // Client-side orchestration: draft the post, then illustrate and narrate in
+ // parallel — both derived from the completed draft. Each step drives its own
+ // reactive surface, so the UI fills in as work finishes.
+ async function orchestrate(topic: string) {
+ drafting.clear()
+ heroImage.reset()
+ narration.reset()
- function run(e: FormEvent) {
+ const draft = await drafting.sendMessage(
+ `Write a blog post about: ${topic}`,
+ )
+ if (!draft) return // drafting failed / was stopped before completing
+
+ await Promise.all([
+ heroImage.run({ prompt: heroPromptFor(draft) }),
+ narration.run({ text: forNarration(draft.body) }),
+ ])
+ }
+
+ function onSubmit(e: FormEvent) {
e.preventDefault()
const topic = String(
new FormData(e.currentTarget).get('topic') ?? '',
).trim()
if (!topic || isRunning) return
+ void orchestrate(topic)
+ }
- // Reset the surfaces so a re-run starts clean: the previous post, and any
- // individually regenerated hero/narration that would shadow the new
- // transaction's results.
- blogPost.reset()
- heroRerun.reset()
- narrationRerun.reset()
-
- // ONE call. The server composes drafting → (illustrate ∥ narrate) and
- // streams every sub-run live into `blogPost.subRuns`; the final
- // `{ post, hero, audio }` lands in `blogPost.result`. Fire and forget —
- // the reactive surfaces drive the UI.
- void blogPost.run({ topic })
+ function stopAll() {
+ drafting.stop()
+ heroImage.stop()
+ narration.stop()
}
return (
@@ -164,12 +156,12 @@ function BlogStudio() {
Turn a topic into a finished post
- One transaction writes the article, then illustrates it and records a
- voice-over in parallel — composed on the server from a single request,
- with every step streamed back live.
+ The client writes the article, then illustrates it and records a
+ voice-over in parallel — three independent plugins behind one
+ endpoint, orchestrated in the browser, each streamed back live.