diff --git a/.changeset/assistant-multi-capability.md b/.changeset/assistant-multi-capability.md
deleted file mode 100644
index c02ea564b..000000000
--- a/.changeset/assistant-multi-capability.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 defineAssistant (server) and useAssistant (client) for multi-capability assistants.
diff --git a/.changeset/transaction-verbs.md b/.changeset/transaction-verbs.md
new file mode 100644
index 000000000..18e39df9a
--- /dev/null
+++ b/.changeset/transaction-verbs.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 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/assistant/multiple-assistants.md b/docs/assistant/multiple-assistants.md
deleted file mode 100644
index ae865ba10..000000000
--- a/docs/assistant/multiple-assistants.md
+++ /dev/null
@@ -1,280 +0,0 @@
----
-title: Multiple Assistants
-id: multiple-assistants
-order: 3
-description: "Give each product surface its own assistant — a support page with chat only, a content studio with chat + image + speech — each with its own route, its own useAssistant, and its own system prompt. Learn when to split into several assistants versus one broad one."
-keywords:
- - tanstack ai
- - assistant
- - defineAssistant
- - useAssistant
- - multiple assistants
- - system prompt
- - routes
----
-
-**One `defineAssistant` per product surface.** An assistant bundles a *fixed* set of capabilities behind one endpoint with one system prompt. When different pages or features want different capability sets or different instructions, don't stretch a single assistant to cover them all — define one per surface. Each gets its own route and its own `useAssistant` on its own page.
-
-A typical app ends up with a few:
-
-| Surface | Capabilities | Why its own assistant |
-|---|---|---|
-| Customer **support** page | `chat` only | Tight, support-flavored system prompt; no image/speech to expose |
-| **Content studio** page | `chat` + `image` + `speech` | A creative workflow that chains capabilities |
-| Internal **dashboard** | `chat` + `summarize` | Different auth boundary; different instructions |
-
-## Two assistants, two routes
-
-Give each assistant 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 both assistants stay in sync without duplicating it:
-
-```ts
-// api/assistants.ts
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-
-// One place to change the model / provider options for every assistant.
-const textModel = () => openaiText('gpt-5.5')
-
-// Support: chat only, with a support-flavored system prompt.
-export const supportAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: textModel(),
- messages: req.messages,
- systemPrompts: [
- 'You are a customer-support agent for Acme. Be concise and friendly.',
- ],
- }),
-})
-
-// Studio: chat + image + speech, for a creative workflow.
-export const studioAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: textModel(),
- messages: req.messages,
- systemPrompts: ['You are a creative copywriter.'],
- }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
- speech: (req) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
-})
-
-// Two routes — one per assistant.
-export const supportPOST = (request: Request) => supportAssistant.handler(request)
-export const studioPOST = (request: Request) => studioAssistant.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 `useAssistant` against *its own* assistant and *its own* connection. The support page only ever sees `assistant.chat`, because that's all `supportAssistant` declared:
-
-```tsx
-// pages/SupportPage.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiText } from '@tanstack/ai-openai'
-
-const textModel = () => openaiText('gpt-5.5')
-
-// The same object your /api/support route exports.
-const supportAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: textModel(),
- messages: req.messages,
- systemPrompts: [
- 'You are a customer-support agent for Acme. Be concise and friendly.',
- ],
- }),
-})
-
-function SupportPage() {
- const assistant = useAssistant(supportAssistant, {
- connection: fetchServerSentEvents('/api/support'),
- })
-
- return (
-
- )
-}
-```
-
-Each page's `assistant` object is typed to exactly its assistant's capabilities — `SupportPage` has no `assistant.image` to misuse, and there's no way to accidentally call the support route with an image request.
-
-## Two assistants on one page
-
-Multiple assistants aren't only for *separate* pages. You can co-locate two **specialized** assistants in the same component — each keeping its own route, connection, auth boundary, and type surface — and compose them in one UI. The glue is the resolved `await` returns: every method hands its result straight back, so one assistant's output flows into the other's input without any reactive-state juggling.
-
-Here a campaign builder pairs a **structured-output copy** assistant (its own `/api/copy` route) with a **media** assistant (its own `/api/media` route):
-
-```tsx
-// pages/CampaignBuilder.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const TaglineSchema = z.object({ headline: z.string(), subhead: z.string() })
-
-// Copy: structured-output chat, its own /api/copy route.
-const copyAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: TaglineSchema,
- stream: true,
- }),
-})
-
-// Media: image + speech, its own /api/media route.
-const mediaAssistant = defineAssistant({
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({ adapter: openaiImage('gpt-image-2'), prompt: req.prompt })
- },
- speech: (req) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
-})
-
-function CampaignBuilder() {
- const copy = useAssistant(copyAssistant, {
- connection: fetchServerSentEvents('/api/copy'),
- })
- const media = useAssistant(mediaAssistant, {
- connection: fetchServerSentEvents('/api/media'),
- })
-
- async function build(brief: string) {
- // One assistant drafts the copy...
- const tagline = await copy.chat.sendMessage(`Write a tagline for: ${brief}`)
- if (!tagline) return
- // ...the other turns it into art and audio. Two routes, one workflow.
- await media.image.generate({ prompt: tagline.headline })
- await media.speech.generate({ text: tagline.subhead })
- }
-
- return (
-
- )
-}
-```
-
-Each assistant keeps its own connection, route, auth boundary, and type surface: `copy` has no `.image` and `media` has no `.chat`, so neither can misuse the other's capabilities or route. Yet they compose cleanly in one component, because each method resolves to its result — `copy.chat.sendMessage` hands back the validated `{ headline, subhead }` (its callback declared `outputSchema`), which threads directly into `media.image.generate` and `media.speech.generate`. The reactive fields (`copy.chat.partial`, `media.image.result`) are still there for rendering.
-
-Contrast this with a single broad assistant that declared all three capabilities behind **one** route: that's the right choice when the capabilities truly share a connection and boundary — see [Scenarios](./scenarios) for that one-assistant chaining shape. Reach for two assistants on one page when the capabilities need to appear together but have **different boundaries** — separate routes, auth, or models — that you don't want to collapse into one endpoint.
-
-## Split, or one broad assistant?
-
-Both are valid. Choose by how the capabilities actually relate:
-
-**Split into multiple assistants when:**
-
-- **Different product surfaces.** A support widget and a content studio are different features with different users — separate assistants keep their prompts, capabilities, and routes independent.
-- **Different capability sets.** If support never needs image generation, don't expose it there. A narrower assistant 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 assistant when:**
-
-- **The capabilities are always used together** on the same page or in the same workflow — that's exactly the [pipeline / content-workflow](./scenarios) shape, where one capability feeds the next.
-- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one.
-
-**Co-locate multiple assistants on one page when:** the capabilities need to appear together in the same UI but have **different boundaries** — separate routes, auth, or models. This is the middle ground between the two options above: separate assistants (so each keeps its own boundary and type surface), composed in one component via their resolved `await` returns (see [Two assistants on one page](#two-assistants-on-one-page)).
-
-The rule of thumb: **split by surface, not by capability count.** A single page that happens to use three capabilities is one assistant; three pages that each use chat are three assistants. And when one page needs two *different* boundaries side by side, that's two assistants on one page.
-
-## Next
-
-- [Assistant Overview](./overview) — capabilities, typing, and when to reach for an assistant at all.
-- [Scenarios](./scenarios) — chaining capabilities within a single assistant.
diff --git a/docs/assistant/overview.md b/docs/assistant/overview.md
deleted file mode 100644
index 4663c843a..000000000
--- a/docs/assistant/overview.md
+++ /dev/null
@@ -1,355 +0,0 @@
----
-title: Assistant Overview
-id: assistant-overview
-order: 1
-description: "An assistant bundles several AI capabilities — chat, image, speech, and more — behind ONE endpoint and hands you ONE typed client. Learn when to reach for an assistant instead of the single-capability hooks, and how to define and drive one."
-keywords:
- - tanstack ai
- - assistant
- - defineAssistant
- - useAssistant
- - multi-capability
- - chat
- - image generation
- - text-to-speech
----
-
-**An assistant bundles multiple AI capabilities — `chat`, `image`, `speech`, and more — behind a single request handler on the server, and gives you back a single typed client on the browser.** You declare the capabilities once with `defineAssistant()`; `useAssistant()` returns one `assistant` object keyed by exactly the capabilities you declared, all sharing one connection.
-
-`defineAssistant()` lives on the `/assistant` subpath of `@tanstack/ai`, and `useAssistant()` on the `/assistant` subpath of `@tanstack/ai-react` (Solid, Vue, and Svelte follow the same pattern) — not the package root.
-
-```ts
-// api/assistant.ts
-import { chat, generateImage } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiText } from '@tanstack/ai-openai'
-
-export const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- image: (req) => {
- // `req.prompt` arrives as `unknown` — narrow it before use.
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
-})
-
-export const POST = (request: Request) => blogAssistant.handler(request)
-```
-
-That one route now serves both chat and image generation. On the client, one hook drives both:
-
-```tsx
-// components/Assistant.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, generateImage } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiText } from '@tanstack/ai-openai'
-
-// The same object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
-})
-
-function Assistant() {
- const assistant = useAssistant(blogAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- })
-
- return (
-
- )
-}
-```
-
-`assistant.chat` behaves exactly like [`useChat`](../chat/streaming) — streaming, tool calls, and message parts all work the same way. Each one-shot capability (`assistant.image`, `assistant.speech`, ...) behaves like its matching single hook ([`useGenerateImage`](../media/image-generation#hook-api), and so on): a `.generate(input)` method plus reactive `.result`, `.isLoading`, and `.error`.
-
-Both `assistant..generate(input)` and `assistant.chat.sendMessage(...)` **resolve to their result**, so you can chain them with a single `await` — `generate` resolves to the generation result (or `null`), and `sendMessage` resolves to the structured `final` (when the chat callback set `outputSchema`) or the messages array otherwise (`const result = await assistant.image.generate(...)`). The reactive `.result` / `.messages` / `.partial` / `.final` remain for rendering. → Chaining these awaited returns is exactly where an assistant pays off; see [Scenarios](./scenarios).
-
-## When should you reach for an assistant?
-
-An assistant is not "a better `useChat`." It earns its place only when you have **two or more capabilities that live on the same surface** — a page, a workflow, a pipeline. If you need exactly one capability, 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** image **and** speech | An assistant | One connection, one client object, one route to deploy and secure |
-| One capability feeds the next (generate an image → write copy about it → narrate it) | An assistant | Typed handoffs between capabilities on the same `assistant` object |
-| A multi-step content pipeline behind one endpoint | An assistant | The whole workflow is one deployable unit with one auth boundary |
-| A page that only sends chat messages | [`useChat`](../chat/streaming) | No second capability to share — the assistant layer adds nothing |
-| A page that only generates images | [`useGenerateImage`](../media/generation-hooks) | Same — a single hook is the whole story |
-
-**The honest tradeoff:** an assistant centralizes routing and typing across capabilities, 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 assistant — give each its own (see [Multiple Assistants](./multiple-assistants)). Reach for an assistant when capabilities *share* something (a connection, a page, or each other's output); keep them separate when they don't.
-
-## Defining capabilities (server)
-
-Each capability is a plain callback: `(req) => `. The `chat` capability returns the stream `chat()` produces; one-shot capabilities (`image`, `speech`, `audio`, `video`, `transcription`, `summarize`) return either a `Promise` of their result or — with `stream: true` — an `AsyncIterable`. Every key is optional; the client only ever sees the capabilities you declare.
-
-`defineAssistant()` itself is inert: none of these callbacks run, and no adapter is constructed, until a request actually reaches `blogAssistant.handler`. The handler discriminates each incoming request by capability — routed there by the client — parses it into the matching request shape (`AssistantChatRequest`, `AssistantImageRequest`, `AssistantSpeechRequest`, ...), and streams whatever the callback returns back over Server-Sent Events.
-
-Because one-shot request fields such as `req.prompt` arrive as `unknown` from the wire, narrow them with a `typeof` check (as above) before handing them to the adapter — never assume the shape.
-
-## Typed chat: tools and structured output
-
-The `chat` capability inherits `useChat`'s full type inference, driven entirely by what the server callback passes to `chat()` — you don't re-declare anything on the client.
-
-**Callback tools auto-type `assistant.chat.messages`.** Pass `tools` to `chat()` in the server callback and the tool-call/result parts on `assistant.chat.messages` are typed from those tools, with nothing to register on the client:
-
-```tsx
-// components/WeatherChat.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, toolDefinition } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-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 object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- tools: [getWeather],
- }),
-})
-
-function WeatherChat() {
- const assistant = useAssistant(blogAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- })
-
- const weatherCall = assistant.chat.messages
- .at(-1)
- ?.parts.find(
- (part) => part.type === 'tool-call' && part.name === 'get_weather',
- )
-
- return (
-
- )
-}
-```
-
-No `chat: { tools }` option was passed to `useAssistant` — `weatherCall.output` is still narrowed to `{ tempF: number; conditions: string }`, inferred entirely from `tools: [getWeather]` on the server callback.
-
-**`chat: { tools }` is optional — you need it 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 via `chat: { tools }`. Types still come from the server callback either way:
-
-```tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, toolDefinition } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-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 object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- tools: [showToastDef], // definition only — the client executes it
- }),
-})
-
-function useAssistantWithClientTool() {
- return useAssistant(blogAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- chat: { tools: [showToast] },
- })
-}
-```
-
-**`outputSchema` → typed `assistant.chat.partial` / `.final`.** If the `chat` callback passes an `outputSchema` to `chat()`, `assistant.chat` 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 { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const BlogOutlineSchema = z.object({
- title: z.string(),
- sections: z.array(z.string()),
-})
-
-// The same object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: BlogOutlineSchema,
- stream: true,
- }),
-})
-
-function BlogOutlineForm() {
- const assistant = useAssistant(blogAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- })
-
- return (
-
- )
-}
-```
-
-`partial` and `final` only exist on the type when the `chat` callback declares `outputSchema` — omit it and `assistant.chat` 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 capability accepts an optional `onResult` transform, keyed by the
-capability name on `useAssistant`'s options — the same shape as the standalone
-generation hooks. It runs on the raw backend result before it lands on
-`assistant..result`, and **its return type becomes that
-capability'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 { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, generateImage } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiText } from '@tanstack/ai-openai'
-
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
-})
-
-function CoverArt() {
- const assistant = useAssistant(blogAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- // `result` is the raw `ImageGenerationResult`; the return type flows
- // through to `assistant.image.result`.
- image: { onResult: (result) => result.images[0]?.url ?? null },
- })
-
- return (
-
-
- {/* `assistant.image.result` is now `string | null`, not the raw result */}
- {assistant.image.result && }
-
- )
-}
-```
-
-Return `undefined` (or nothing) from `onResult` to keep the raw result. Each
-capability's option also takes `forwardedProps` to merge extra fields into that
-capability's request body.
-
-## Which page do I read?
-
-| You want to… | Read |
-|---|---|
-| Chain capabilities end-to-end — run one after another and thread each result into the next (a pipeline, a content workflow) | [Scenarios](./scenarios) |
-| Give different pages or features their own capability sets, routes, and system prompts | [Multiple Assistants](./multiple-assistants) |
-| Understand the underlying chat surface (streaming, tools, message parts) | [Chat: Streaming](../chat/streaming) |
-| Drive a single capability without an assistant | [Media & Generation Hooks](../media/generation-hooks) |
diff --git a/docs/assistant/scenarios.md b/docs/assistant/scenarios.md
deleted file mode 100644
index 28ac3171f..000000000
--- a/docs/assistant/scenarios.md
+++ /dev/null
@@ -1,252 +0,0 @@
----
-title: Assistant Scenarios
-id: assistant-scenarios
-order: 2
-description: "Journey-style recipes for chaining assistant capabilities: a sequential pipeline (image → chat → speech) and a one-endpoint content workflow (draft → hero image → audio version). See why an assistant beats wiring separate hooks when steps feed each other."
-keywords:
- - tanstack ai
- - assistant
- - useAssistant
- - pipeline
- - content workflow
- - chaining capabilities
- - multimodal
----
-
-**These are point A → point B recipes.** Each one takes a concrete goal and walks the full flow across an assistant's capabilities. The thread running through both: because every capability hangs off the same `assistant` object, the output of one step is right there — typed — for the next step to consume. Wiring the same flow with separate `useChat` / `useGenerateImage` / `useGenerateSpeech` hooks means three connections, three client objects, and manually shuttling values between them.
-
-If you only need one capability, you don't need any of this — reach for the single hook. See [When should you reach for an assistant?](./overview#when-should-you-reach-for-an-assistant) on the overview.
-
-## Recipe 1 — Sequential pipeline
-
-**Goal:** generate an image, have the model write copy *about* that image, then narrate the copy aloud — each step awaiting the last and threading its result forward.
-
-Declare all three capabilities in one assistant on the server:
-
-```ts
-// api/assistant.ts
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-
-export const studioAssistant = defineAssistant({
- chat: (req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
- speech: (req) =>
- generateSpeech({
- adapter: openaiSpeech('tts-1'),
- text: req.text,
- voice: req.voice,
- }),
-})
-
-export const POST = (request: Request) => studioAssistant.handler(request)
-```
-
-On the client, the whole pipeline is one function. Each `await` resolves when that step finishes, and its result is on the same `assistant` object for the next step to read:
-
-```tsx
-// components/Pipeline.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-
-// The same object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const studioAssistant = defineAssistant({
- chat: (req) =>
- chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
- speech: (req) =>
- generateSpeech({
- adapter: openaiSpeech('tts-1'),
- text: req.text,
- voice: req.voice,
- }),
-})
-
-function Pipeline() {
- const assistant = useAssistant(studioAssistant, {
- connection: fetchServerSentEvents('/api/assistant'),
- })
-
- async function run(subject: string) {
- // 1. Generate an image — the result comes straight back from the await.
- const image = await assistant.image.generate({ prompt: subject })
- const imageUrl = image?.images[0]?.url
- if (!imageUrl) return
-
- // 2. Hand the image to chat and ask for copy about it.
- // sendMessage resolves to the messages (no outputSchema here).
- const messages = await assistant.chat.sendMessage({
- content: [
- { type: 'image', source: { type: 'url', value: imageUrl } },
- { type: 'text', content: 'Write a short, punchy caption for this image.' },
- ],
- })
-
- // 3. Pull the reply text out of the returned messages and narrate it.
- const caption = messages
- .at(-1)
- ?.parts.find((part) => part.type === 'text')?.content
- if (!caption) return
-
- await assistant.speech.generate({ text: caption })
- }
-
- return (
-
- )
-}
-```
-
-**Why an assistant here:** each step's `await` hands back its own result — `const image = await assistant.image.generate(...)`, `const messages = await assistant.chat.sendMessage(...)` — and you thread that returned value straight into the next call. No reactive-state reads between steps: the image URL feeds chat as [multimodal input](../media/image-generation#image-conditioned-generation), and the chat reply (pulled from the returned `messages`) feeds speech — all typed, all on one connection. With three separate hooks you'd hand-carry each value across three independent clients and manage three connections. The reactive `assistant.image.result` / `assistant.chat.messages` are still there for *rendering* (see the JSX below); the pipeline just doesn't need them. See [Text-to-Speech](../media/text-to-speech#playing-audio-in-the-browser) for turning `assistant.speech.result?.audio` into playable audio.
-
-## Recipe 2 — Content workflow (CMS)
-
-**Goal:** a "content" assistant powering a mini CMS. From one prompt it (a) drafts a blog post as a **typed object** (`title` + `body`) via structured-output chat, (b) generates a hero image from the drafted title, and (c) produces an audio version of the body via speech — all behind **one endpoint**.
-
-The server declares the structured-output `chat`, plus `image` and `speech`:
-
-```ts
-// api/content.ts
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const BlogPostSchema = z.object({
- title: z.string(),
- body: z.string(),
-})
-
-export const contentAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: BlogPostSchema,
- stream: true,
- }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
- speech: (req) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
-})
-
-export const POST = (request: Request) => contentAssistant.handler(request)
-```
-
-The client orchestrates the workflow off the typed `final` object — `assistant.chat.final` is narrowed to `{ title: string; body: string } | null` because the `chat` callback declared `outputSchema`:
-
-```tsx
-// components/ContentStudio.tsx
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
-import { z } from 'zod'
-
-const BlogPostSchema = z.object({
- title: z.string(),
- body: z.string(),
-})
-
-// The same object your server route exports — share it from one module in a
-// real app; repeated here so this snippet type-checks on its own.
-const contentAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: BlogPostSchema,
- stream: true,
- }),
- image: (req) => {
- if (typeof req.prompt !== 'string') {
- throw new Error('image prompt must be a string')
- }
- return generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: req.prompt,
- })
- },
- speech: (req) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
-})
-
-function ContentStudio() {
- const assistant = useAssistant(contentAssistant, {
- connection: fetchServerSentEvents('/api/content'),
- })
-
- async function publish(topic: string) {
- // (a) Draft the post as a typed object — sendMessage resolves to the
- // validated `final` because the chat callback declared outputSchema.
- const draft = await assistant.chat.sendMessage(
- `Write a short blog post about ${topic}.`,
- ) // { title: string; body: string } | null
- if (!draft) return
-
- // (b) Generate a hero image from the drafted title.
- await assistant.image.generate({ prompt: `Hero image for: ${draft.title}` })
-
- // (c) Produce an audio version of the body.
- await assistant.speech.generate({ text: draft.body })
- }
-
- return (
-
-
- {/* Live progress while the draft streams in */}
-
- )
-}
-```
-
-**Why an assistant here:** `sendMessage` resolves to the validated structured `final` — `const draft = await assistant.chat.sendMessage(...)` hands you the typed `{ title, body }` object directly, which you thread into the next calls (`draft.title` becomes the image prompt, `draft.body` becomes the speech text). These are ordinary typed property reads on the awaited return, not values you serialized out of one hook and back into another, and not a reactive-state read after the await. The entire workflow ships as a single endpoint (`/api/content`) with one auth boundary and one connection, which is exactly the shape a content-management feature wants. Because the `chat` callback declares `outputSchema`, you also get `assistant.chat.partial` for a live preview while the draft streams — see [Structured Outputs: Streaming UIs](../structured-outputs/streaming).
-
-## Next
-
-- [Multiple Assistants](./multiple-assistants) — when different pages or features each need their own assistant.
-- [Assistant Overview](./overview) — the full capability + typing reference.
-- [Client Tools](../tools/client-tools) — run tool implementations in the browser inside `assistant.chat`.
diff --git a/docs/config.json b/docs/config.json
index 65dc9e66d..79022d30f 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -309,23 +309,23 @@
]
},
{
- "label": "Assistant",
+ "label": "Transaction",
"children": [
{
"label": "Overview",
- "to": "assistant/overview",
- "addedAt": "2026-07-13",
+ "to": "transaction/overview",
+ "addedAt": "2026-07-14",
"updatedAt": "2026-07-14"
},
{
- "label": "Scenarios",
- "to": "assistant/scenarios",
+ "label": "Transactions",
+ "to": "transaction/transactions",
"addedAt": "2026-07-14",
"updatedAt": "2026-07-14"
},
{
- "label": "Multiple Assistants",
- "to": "assistant/multiple-assistants",
+ "label": "Scenarios",
+ "to": "transaction/scenarios",
"addedAt": "2026-07-14",
"updatedAt": "2026-07-14"
}
diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md
new file mode 100644
index 000000000..cca573a69
--- /dev/null
+++ b/docs/transaction/overview.md
@@ -0,0 +1,435 @@
+---
+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
new file mode 100644
index 000000000..f3cb6fda0
--- /dev/null
+++ b/docs/transaction/scenarios.md
@@ -0,0 +1,203 @@
+---
+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
new file mode 100644
index 000000000..6e2d0cdcc
--- /dev/null
+++ b/docs/transaction/transactions.md
@@ -0,0 +1,273 @@
+---
+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 (
+
+ {writingStep === 'active'
+ ? draftChars > 0
+ ? `Drafting… ${draftChars} characters so far.`
+ : 'Drafting the article…'
+ : 'Illustrating and recording voice-over…'}
+
+
+
+ ) : (
+
+
+
+
+ Your finished post streams in step-by-step from a single server
+ function.
+
+
+
+ )}
+
+
+ )
+}
+
+function StepRow({
+ label,
+ icon,
+ state,
+ detail,
+}: {
+ label: string
+ icon: ReactNode
+ state: StepState
+ detail?: string
+}) {
+ const cls =
+ state === 'active'
+ ? 'text-sky-700 font-medium'
+ : state === 'done'
+ ? 'text-stone-500'
+ : state === 'failed'
+ ? 'text-amber-600'
+ : 'text-stone-300'
+ return (
+
+ {state === 'active' ? (
+
+ ) : state === 'done' ? (
+
+ ) : state === 'failed' ? (
+
+ ) : (
+ icon
+ )}
+ {label}
+ {detail && (
+
+ {detail}
+
+ )}
+
+ )
+}
diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx
index 2519c7047..19e3d74c7 100644
--- a/examples/ts-react-chat/src/routes/blog-studio.tsx
+++ b/examples/ts-react-chat/src/routes/blog-studio.tsx
@@ -1,10 +1,6 @@
-import type { FormEvent, ReactNode } from 'react'
-import { createFileRoute } from '@tanstack/react-router'
-import { chat, generateImage, generateSpeech } from '@tanstack/ai'
-import { defineAssistant } from '@tanstack/ai/assistant'
+import { Link, createFileRoute } from '@tanstack/react-router'
import { fetchServerSentEvents } from '@tanstack/ai-react'
-import { useAssistant } from '@tanstack/ai-react/assistant'
-import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
+import { useTransaction } from '@tanstack/ai-react/transaction'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
@@ -15,36 +11,13 @@ import {
Newspaper,
PenLine,
Sparkles,
+ Square,
Volume2,
Wand2,
} from 'lucide-react'
-import { BlogPostSchema } from './api.blog-studio'
-
-// Client-side assistant definition. `defineAssistant` is INERT in the browser
-// — these callbacks never run; `useAssistant` only reads the declared
-// capability names and their return types off this value to build the fully
-// typed client. Every request is sent to the `/api/blog-studio` route, which
-// owns the real callbacks. Mirroring the three capabilities (and the chat
-// `outputSchema`) here keeps the client types in sync with the server.
-//
-// Single-provider openai adapters are used directly (not the multi-provider
-// factories) so the browser bundle doesn't pull in every provider SDK.
-const blogAssistant = defineAssistant({
- chat: (req) =>
- chat({
- adapter: openaiText('gpt-5.5'),
- messages: req.messages,
- outputSchema: BlogPostSchema,
- stream: true,
- }),
- image: (req) =>
- generateImage({
- adapter: openaiImage('gpt-image-2'),
- prompt: typeof req.prompt === 'string' ? req.prompt : '',
- }),
- speech: (req) =>
- generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
-})
+import { blogTxnDef, forNarration, heroPromptFor } from '../lib/blog-studio'
+import type { ImageGenerationResult, TTSResult } from '@tanstack/ai'
+import type { FormEvent, ReactNode } from 'react'
export const Route = createFileRoute('/blog-studio')({
component: BlogStudio,
@@ -52,11 +25,12 @@ export const Route = createFileRoute('/blog-studio')({
type StepState = 'pending' | 'active' | 'done' | 'failed'
-// The one-shot generation status maps directly onto a step state.
-function statusToStep(
- status: 'idle' | 'generating' | 'success' | 'error',
+// 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 === 'generating'
+ return status === 'running'
? 'active'
: status === 'success'
? 'done'
@@ -65,100 +39,115 @@ function statusToStep(
: 'pending'
}
-// 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).
-function forNarration(markdown: string, max = 4000): string {
- const plain = markdown
- .replace(/^#{1,6}\s+/gm, '') // headings
- .replace(/^\s*[-*+]\s+/gm, '') // list bullets
- .replace(/^\s*>\s?/gm, '') // blockquotes
- .replace(/\*\*(.*?)\*\*/g, '$1') // bold
- .replace(/\*(.*?)\*/g, '$1') // italic
- .replace(/`([^`]+)`/g, '$1') // inline code
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → link text
- .replace(/\n{3,}/g, '\n\n')
- .trim()
- if (plain.length <= max) return plain
- const clipped = plain.slice(0, max)
- const boundary = Math.max(
- clipped.lastIndexOf('. '),
- clipped.lastIndexOf('\n'),
+// The image result carries either a URL or base64 data, provider-dependent.
+function imageUrlOf(result: ImageGenerationResult | null): string | null {
+ const image = result?.images[0]
+ if (!image) return null
+ return (
+ image.url ??
+ (image.b64Json ? `data:image/png;base64,${image.b64Json}` : null)
)
- return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim()
+}
+
+// The TTS result is base64 audio + a content type → a ready-to-play data URL.
+function audioSrcOf(result: TTSResult | null): string | null {
+ if (!result) return 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 assistant = useAssistant(blogAssistant, {
+ const txn = useTransaction(blogTxnDef, {
connection: fetchServerSentEvents('/api/blog-studio'),
- // Transform the raw TTS result into a ready-to-play data URL at the hook.
- // `result` is typed as the raw `TTSResult`; `assistant.speech.result`
- // becomes `{ src: string } | null` — no component state, no cleanup.
- speech: {
- onResult: (result) => ({
- src: `data:${result.contentType ?? 'audio/mpeg'};base64,${result.audio}`,
- }),
- },
})
- // Everything below is derived from the assistant's reactive state — no
- // useState / useEffect. The chat carries loading/partial/final; each one-shot
- // carries result/status/error.
- const { chat: post, image, speech } = assistant
- const draft = post.partial
- const finished = post.final
- const title = finished?.title ?? draft.title
- const subtitle = finished?.subtitle ?? draft.subtitle
- const body = finished?.body ?? draft.body ?? ''
+ // 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 heroImage = image.result?.images[0]
- const imageUrl =
- heroImage?.url ??
- (heroImage?.b64Json ? `data:image/png;base64,${heroImage.b64Json}` : null)
- // `speech.result` is the `onResult` transform's output — `{ src } | null`.
- const audioSrc = speech.result?.src ?? null
+ 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,
+ )
- const isRunning = post.isLoading || image.isLoading || speech.isLoading
- const hasRun = post.messages.length > 0 || isRunning
- const writingStep: StepState = post.error
- ? 'failed'
- : post.isLoading
- ? 'active'
- : hasRun
- ? 'done'
- : 'pending'
- const showArticle = Boolean(finished) || Boolean(title) || Boolean(body)
+ // 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')
+
+ const isRunning = blogPost.isLoading
+ const hasRun = blogPost.status !== 'idle'
+
+ // 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)
- async function run(e: FormEvent) {
+ // 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 showArticle = Boolean(shownTitle || shownBody)
+
+ 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'
+
+ function run(e: FormEvent) {
e.preventDefault()
const topic = String(
new FormData(e.currentTarget).get('topic') ?? '',
).trim()
if (!topic || isRunning) return
- // Reset the surfaces so a re-run starts clean (clears prior draft/results).
- post.clear()
- image.reset()
- speech.reset()
-
- // 1. Draft the post. `sendMessage` resolves to the schema-validated result;
- // re-validate before chaining so a failed run doesn't proceed with a
- // half-finished draft.
- const parsed = BlogPostSchema.safeParse(
- await post.sendMessage(`Write a blog post about: ${topic}`),
- )
- if (!parsed.success) return
- const draftedPost = parsed.data
+ // 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()
- // 2. Illustrate and narrate in parallel — fire and forget; the surfaces'
- // reactive result/status/error drive the UI. `generate()` never rejects.
- void image.generate({
- prompt:
- `A striking editorial hero image for a blog post titled ` +
- `"${draftedPost.title}". ${draftedPost.subtitle}. Modern, clean, ` +
- `cinematic, high quality, no text.`,
- })
- void speech.generate({ text: forNarration(draftedPost.body) })
+ // 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 })
}
return (
@@ -175,9 +164,19 @@ function BlogStudio() {
Turn a topic into a finished post
- One assistant writes the article, then illustrates it and records a
- voice-over in parallel — chained from a single prompt over one
- endpoint.
+ 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.
+
+
+ Prefer plain fetch + JSON? See the{' '}
+
+ non-transaction version
+
+ .
+
+ Touch up
+
+ {/* The same verbs the transaction composed, driven directly by
+ the user — inputs derived from the current post. */}
+
+
+
+ )}
+
+ {blogPost.error && (
- {post.error.message}
+ {blogPost.error.message}
)}
- {/* Right: the post */}
+ {/* Right: the post. Appears as soon as the drafting sub-run streams a
+ title/body — the body fills in live — then the hero image and
+ voice-over slot in as their sub-runs finish. */}
{showArticle ? (
{/* Hero image */}
- {/* Body */}
+ {/* Body — rendered as Markdown live while the draft streams in.
+ The client batches the streamed structured-output updates
+ (see TransactionClient), so this re-parses at a bounded rate
+ rather than once per token. */}