diff --git a/.changeset/assistant-multi-capability.md b/.changeset/assistant-multi-capability.md
new file mode 100644
index 000000000..c02ea564b
--- /dev/null
+++ b/.changeset/assistant-multi-capability.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 defineAssistant (server) and useAssistant (client) for multi-capability assistants.
diff --git a/docs/assistant/multiple-assistants.md b/docs/assistant/multiple-assistants.md
new file mode 100644
index 000000000..ae865ba10
--- /dev/null
+++ b/docs/assistant/multiple-assistants.md
@@ -0,0 +1,280 @@
+---
+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
new file mode 100644
index 000000000..4663c843a
--- /dev/null
+++ b/docs/assistant/overview.md
@@ -0,0 +1,355 @@
+---
+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
new file mode 100644
index 000000000..28ac3171f
--- /dev/null
+++ b/docs/assistant/scenarios.md
@@ -0,0 +1,252 @@
+---
+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 */}
+
+ )
+}
+```
+
+`assistant` is typed from the `assistant` value passed in — **no generics** at
+the call site. Only capabilities declared in `defineAssistant` appear on
+`assistant`; referencing an undeclared key is a compile error. `assistant.chat`
+is the full `useChat` return (`messages`, `sendMessage`, `isLoading`,
+`error`, `status`, `stop`, `clear`, `addToolResult`, …); `assistant.image` /
+`assistant.audio` / `assistant.speech` / `assistant.video` /
+`assistant.transcription` / `assistant.summarize` are each the full
+`useGeneration`-style return (`generate`, `result`, `isLoading`, `error`,
+`status`, `stop`, `reset`).
+
+Vue/Solid/Svelte have identical patterns with different hook imports
+(e.g. `import { useAssistant } from '@tanstack/ai-solid/assistant'`).
+
+## Core Patterns
+
+### 1. Chat tools auto-type from the server callback; `chat: { tools }` is only for client-executed runtime
+
+Tools passed to `chat({ tools: [...] })` inside the server callback
+automatically type `assistant.chat.messages`' tool-call/result parts —
+narrowed by tool name, input, and output — with **no** client-side
+re-declaration needed for typing:
+
+```typescript
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { useAssistant } from '@tanstack/ai-react/assistant'
+import { blogAssistant } from '../lib/assistant' // chat callback passed tools: [getWeather]
+
+const assistant = useAssistant(blogAssistant, {
+ connection: fetchServerSentEvents('/api/assistant'),
+})
+
+// assistant.chat.messages parts are already narrowed by tool name — inferred
+// from the server callback's `tools: [getWeather]`, not from anything passed
+// here.
+```
+
+`chat: { tools }` on `useAssistant` still exists, but only for one reason:
+a client-**executed** tool's `.client()` implementation runs in the browser,
+so its code can't cross the wire — the server callback only ever sees the
+tool's _definition_ (for the model and for typing). Pass the client
+implementation there to register its runtime:
+
+```typescript
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { useAssistant } from '@tanstack/ai-react/assistant'
+import { blogAssistant } from '../lib/assistant' // chat callback passed tools: [showToastDef]
+import { showToast } from '../lib/tools' // showToastDef.client((input) => ...)
+
+const assistant = useAssistant(blogAssistant, {
+ connection: fetchServerSentEvents('/api/assistant'),
+ chat: { tools: [showToast] }, // runtime only — types already came from the callback
+})
+```
+
+`chat.forwardedProps` is also available on the same option, merged into
+every chat request alongside the reserved `capability` field.
+
+Each **one-shot** capability accepts an optional transform too, keyed by the
+capability name (`image`, `speech`, `audio`, `video`, `transcription`,
+`summarize`): `image: { onResult, forwardedProps }`. `onResult` runs on the
+raw backend result and its return type becomes `assistant..result`
+— mirroring the standalone generation hooks. Return nothing to keep the raw
+result.
+
+```typescript
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { useAssistant } from '@tanstack/ai-react/assistant'
+import { blogAssistant } from '../lib/assistant'
+
+const assistant = useAssistant(blogAssistant, {
+ connection: fetchServerSentEvents('/api/assistant'),
+ image: { onResult: (result) => result.images[0]?.url ?? null },
+})
+
+assistant.image.result // string | null — the transform's return type
+```
+
+### 2. Structured output via `outputSchema` in the chat callback
+
+If the `chat` callback passes `outputSchema` to `chat()`, `assistant.chat`
+picks up typed `partial` (progressive `DeepPartial`) and `final` (validated
+terminal object) fields — the same conditional shape `useChat({
+outputSchema })` returns. Omit `outputSchema` and neither field is present on
+the type.
+
+```typescript
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { useAssistant } from '@tanstack/ai-react/assistant'
+import { blogAssistant } from '../lib/assistant' // chat callback passed outputSchema: BlogOutlineSchema
+
+const assistant = useAssistant(blogAssistant, {
+ connection: fetchServerSentEvents('/api/assistant'),
+})
+
+assistant.chat.partial.title // string | undefined — fills in as JSON streams
+assistant.chat.final // full schema type | null — set once the run completes
+```
+
+### 3. Manual chaining across capabilities
+
+There is no auto-chaining or shared "artifact workspace" — `useAssistant`
+is a pure composition layer. To use one capability's result as input to
+another, read the result value and pass it explicitly:
+
+```typescript
+await assistant.image.generate({ prompt: 'a lighthouse at dusk' })
+
+// after assistant.image.result is populated:
+if (assistant.image.result) {
+ assistant.chat.sendMessage({
+ content: [
+ {
+ type: 'image',
+ source: { type: 'url', value: assistant.image.result.images[0].url },
+ },
+ { type: 'text', content: 'Write a short caption for this image.' },
+ ],
+ })
+}
+```
+
+### 4. Only declared capabilities are constructed
+
+`defineAssistant({ chat, image })` produces a client `assistant` with exactly
+`assistant.chat` and `assistant.image` — no `assistant.audio`, `assistant.video`, etc.
+Add or remove capability keys on the server definition to change what the
+client can call; there's nothing else to keep in sync.
+
+### 5. Sharing one connection across capabilities
+
+All capabilities declared in one `defineAssistant` call share a single
+`connection` passed to `useAssistant` — one endpoint, one adapter. Each
+underlying sub-client (a `ChatClient` for `chat`, a `GenerationClient` per
+one-shot capability) tags its own requests with the capability name, so the
+single `handler` on the server can route correctly. This mirrors the shared
+`connection` pattern already used by `useChat` / `useGenerateImage`
+individually — see ai-core/chat-experience and ai-core/media-generation.
+
+## Common Mistakes
+
+### a. HIGH: Constructing adapters outside the capability callback
+
+```typescript
+// WRONG — adapter constructed eagerly at module load, defeats "inert" guarantee
+const textAdapter = openaiText('gpt-5.5')
+const blogAssistant = defineAssistant({
+ chat: (req) => chat({ adapter: textAdapter, messages: req.messages }),
+})
+
+// CORRECT — construct inside the callback, per request
+const blogAssistant = defineAssistant({
+ chat: (req) =>
+ chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }),
+})
+```
+
+Adapter construction is cheap (it wraps config; the provider HTTP client is
+created lazily), but constructing outside the callback breaks the
+"`defineAssistant` is inert, safe to import into the client bundle"
+guarantee — it now runs provider setup at import time.
+
+### b. HIGH: Writing a custom handler that branches on capability manually
+
+```typescript
+// WRONG — reimplementing what `handler` already does
+export const POST = async (request: Request) => {
+ const body = await request.json()
+ if (body.capability === 'chat') {
+ return toServerSentEventsResponse(
+ chat({ adapter, messages: body.messages }),
+ )
+ }
+ // ...manual branching for every capability
+}
+
+// CORRECT — defineAssistant's handler already parses, routes, and serializes
+export const POST = (request: Request) => blogAssistant.handler(request)
+```
+
+### c. HIGH: Passing `model` or top-level generation options to `useAssistant`
+
+There is no `model`/`prompt` option on `useAssistant` itself. Model choice and
+generation parameters belong inside the server callback
+(`openaiImage('gpt-image-2')`, `openaiSpeech('tts-1')`, …). The client-side
+options `useAssistant` accepts besides `connection` are `threadId`/`id`,
+`chat: { tools, forwardedProps }` (`tools` here registers **client-executed**
+tools' runtime implementations only — typing already comes from the server
+callback), and a per-one-shot-capability `{ onResult, forwardedProps }` (a
+result transform + extra request-body fields — not model/prompt).
+
+```typescript
+// WRONG — no model/prompt options on useAssistant
+useAssistant(blogAssistant, { connection, model: 'gpt-5.5', prompt: '...' })
+
+// CORRECT — model/prompt live in the server callback; useAssistant takes
+// connection, threadId/id, chat.{tools,forwardedProps}, and per-capability
+// { onResult, forwardedProps }
+useAssistant(blogAssistant, {
+ connection: fetchServerSentEvents('/api/assistant'),
+})
+```
+
+### d. MEDIUM: Expecting `defineAssistant` to auto-chain results
+
+```typescript
+// WRONG — assuming the assistant remembers assistant.image.result automatically
+assistant.chat.sendMessage('use the image I just generated')
+
+// CORRECT — thread the result value through explicitly (see Core Pattern 2)
+assistant.chat.sendMessage({
+ content: [
+ {
+ type: 'image',
+ source: { type: 'url', value: assistant.image.result.images[0].url },
+ },
+ { type: 'text', content: 'Use this image.' },
+ ],
+})
+```
+
+Chaining is manual by design (v1 non-goal: no shared artifact workspace or
+auto-resolution of prior outputs).
+
+### e. MEDIUM: Declaring a capability on the server but never checking it client-side
+
+Every capability declared in `defineAssistant` is unconditionally present on
+`assistant` — there's no need to guard with `assistant.image?.generate`. If a
+capability is optional per-deployment, omit the key from the
+`defineAssistant` config entirely rather than declaring it and ignoring it
+on the client.
+
+## Cross-References
+
+- See also: **ai-core/chat-experience/SKILL.md** -- `assistant.chat` is the
+ same `useChat` surface; streaming, tool rendering, and multimodal
+ messages all apply unchanged.
+- See also: **ai-core/media-generation/SKILL.md** -- `assistant.image` /
+ `assistant.audio` / `assistant.speech` / `assistant.video` /
+ `assistant.transcription` / `assistant.summarize` are each the same
+ `useGeneration`-style surface documented there.
+- See also: **ai-core/tool-calling/SKILL.md** -- Tools passed to the `chat`
+ capability callback follow the same server/client tool patterns as a
+ standalone `chat()` route.
+- See also: **ai-core/custom-backend-integration/SKILL.md** -- The shared
+ `connection` passed to `useAssistant` is the same `ConnectConnectionAdapter`
+ used by `useChat` / `useGenerate*`.
diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md
index 3ae4408dc..2aa0606a5 100644
--- a/packages/ai/skills/ai-core/chat-experience/SKILL.md
+++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md
@@ -620,3 +620,7 @@ If not handled, the UI appears to hang with no feedback.
- See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools
- See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features
- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events
+- See also: **ai-core/assistant/SKILL.md** -- To compose chat with other
+ capabilities (image/audio/speech/video/transcription/summarize) behind a
+ single `defineAssistant` handler + `useAssistant` hook instead of wiring up
+ `chat()` and `useChat` alone
diff --git a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
index 37275b6f7..3cb3fb1a3 100644
--- a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
+++ b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
@@ -461,3 +461,7 @@ Source: `docs/protocol/http-stream-protocol.md`
- See also: **ai-core/ag-ui-protocol/SKILL.md** -- Understanding the AG-UI protocol helps build compatible custom servers
- See also: **ai-core/chat-experience/SKILL.md** -- Full chat setup patterns including server-side `chat()` and `toServerSentEventsResponse()`
- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events on the server side
+- See also: **ai-core/assistant/SKILL.md** -- If the backend needs to compose
+ chat with other capabilities (image/audio/speech/video/transcription/
+ summarize) behind one endpoint, `defineAssistant` + `useAssistant` may fit
+ better than a custom connection adapter wrapping `useChat` alone
diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md
index 268a85f74..b0e6aaefc 100644
--- a/packages/ai/skills/ai-core/media-generation/SKILL.md
+++ b/packages/ai/skills/ai-core/media-generation/SKILL.md
@@ -876,3 +876,7 @@ and piping into a custom logger.
returns unexpected output or fails mid-stream, toggle `debug: true` on
any `generate*()` call to see request metadata, raw provider chunks, and
errors. Covers per-category toggling and piping into pino/winston.
+- See also: **ai-core/assistant/SKILL.md** -- To combine multiple media
+ activities (image/audio/speech/video/transcription) with chat behind one
+ `defineAssistant` handler + `useAssistant` hook instead of wiring up each
+ `generate*()` activity and its own hook separately.
diff --git a/packages/ai/src/activities/assistant/index.ts b/packages/ai/src/activities/assistant/index.ts
new file mode 100644
index 000000000..bb2422ac9
--- /dev/null
+++ b/packages/ai/src/activities/assistant/index.ts
@@ -0,0 +1,131 @@
+import { chatParamsFromRequestBody } from '../../utilities/chat-params.js'
+import { toServerSentEventsResponse } from '../../stream-to-response.js'
+import { streamGenerationResult } from '../stream-generation-result.js'
+import type {
+ AssistantChatRequest,
+ AssistantConfig,
+ AssistantDefinition,
+} from './types.js'
+
+export type * from './types.js'
+
+function isAsyncIterable(value: unknown): value is AsyncIterable {
+ return (
+ value != null && typeof value === 'object' && Symbol.asyncIterator in value
+ )
+}
+
+/**
+ * Define a server-side assistant: a registry of per-capability callbacks
+ * exposing a single request `handler`. Inert — no adapters, connections,
+ * or other resources are constructed until a request actually arrives.
+ */
+export function defineAssistant(
+ config: T,
+): AssistantDefinition {
+ const capabilities = Object.keys(config) as Array
+
+ const handler = async (request: Request): Promise => {
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return new Response('Invalid JSON body', { status: 400 })
+ }
+
+ const bodyRecord = body as Record
+ const forwardedProps: Record =
+ (bodyRecord.forwardedProps as Record | undefined) ??
+ (bodyRecord.data as Record | undefined) ??
+ {}
+ const capability = forwardedProps.capability
+ // Gate on OWN, DECLARED capabilities only — `capabilities` is
+ // `Object.keys(config)`, so this excludes inherited `Object.prototype`
+ // members (`toString`, `valueOf`, `hasOwnProperty`, `constructor`, …)
+ // that `capability in config` / `Reflect.get(config, capability)` would
+ // otherwise treat as valid callbacks.
+ const isDeclaredCapability =
+ typeof capability === 'string' &&
+ (capabilities as Array).includes(capability)
+
+ // `Reflect.get` returns `any`, so this is a single narrowing cast (not
+ // `as unknown as`) — a per-key cast from `AssistantConfig` directly
+ // fails structurally because callback params are contravariant per key.
+ const callback = isDeclaredCapability
+ ? (Reflect.get(config, capability) as ((req: any) => unknown) | undefined)
+ : undefined
+
+ if (!isDeclaredCapability || !callback) {
+ return new Response(
+ `Unknown assistant capability: ${String(capability)}`,
+ { status: 400 },
+ )
+ }
+
+ if (capability === 'chat') {
+ // chatParamsFromRequestBody rejects with an AGUIError (not a thrown
+ // Response) when the body doesn't conform to AG-UI's RunAgentInput
+ // shape. Surface that as a 400, mirroring the e2e `api.chat.ts` route.
+ let params
+ try {
+ params = await chatParamsFromRequestBody(body)
+ } catch (error) {
+ if (error instanceof Response) return error
+ return new Response(
+ error instanceof Error ? error.message : 'Invalid request body',
+ { status: 400 },
+ )
+ }
+ const chatReq: AssistantChatRequest = { ...params, request }
+ const result = callback(chatReq)
+ if (!isAsyncIterable(result)) {
+ return new Response(
+ 'assistant chat capability must return a streaming ChatStream',
+ { status: 400 },
+ )
+ }
+ return toServerSentEventsResponse(result as AsyncIterable)
+ }
+
+ // One-shot capabilities: validate the AG-UI envelope (same as `chat`),
+ // then pull the generation input out of forwardedProps.
+ let params
+ try {
+ params = await chatParamsFromRequestBody(body)
+ } catch (error) {
+ if (error instanceof Response) return error
+ return new Response(
+ error instanceof Error ? error.message : 'Invalid request body',
+ { status: 400 },
+ )
+ }
+ const { capability: _omit, ...input } = params.forwardedProps
+ const oneShotReq = {
+ ...input,
+ threadId: params.threadId,
+ runId: params.runId,
+ ...(params.parentRunId !== undefined && {
+ parentRunId: params.parentRunId,
+ }),
+ state: params.state,
+ aguiContext: params.aguiContext,
+ forwardedProps: params.forwardedProps,
+ request,
+ }
+ const result = callback(oneShotReq)
+
+ if (isAsyncIterable(result)) {
+ // User opted into stream:true; the activity already emits generation:result.
+ return toServerSentEventsResponse(result as AsyncIterable)
+ }
+ // Non-streaming activity promise → wrap so the client's GenerationClient sees it.
+ return toServerSentEventsResponse(
+ streamGenerationResult(() => Promise.resolve(result), {
+ threadId: params.threadId,
+ runId: params.runId,
+ }),
+ )
+ }
+
+ return { capabilities, handler, '~caps': config }
+}
diff --git a/packages/ai/src/activities/assistant/types.ts b/packages/ai/src/activities/assistant/types.ts
new file mode 100644
index 000000000..3f3efb05a
--- /dev/null
+++ b/packages/ai/src/activities/assistant/types.ts
@@ -0,0 +1,118 @@
+// packages/ai/src/activities/assistant/types.ts
+import type { Context as AGUIContext } from '@ag-ui/core'
+import type {
+ AudioGenerationResult,
+ ChatStream,
+ ImageGenerationResult,
+ JSONSchema,
+ ModelMessage,
+ StreamChunk,
+ StructuredOutputStream,
+ SummarizationResult,
+ TTSResult,
+ TranscriptionResult,
+ UIMessage,
+ VideoJobResult,
+} from '../../types.js'
+
+/** The parsed request handed to the `chat` capability callback. */
+export interface AssistantChatRequest {
+ messages: Array
+ threadId: string
+ runId: string
+ parentRunId?: string
+ /** Client-declared tools (name/description/JSON-schema) from the AG-UI body. */
+ tools: Array<{ name: string; description: string; parameters: JSONSchema }>
+ forwardedProps: Record
+ state: unknown
+ aguiContext: Array
+ /** Raw request, for escape hatches (headers, auth). */
+ request: Request
+}
+
+/** Base for one-shot capability requests: parsed generation input + raw request. */
+interface AssistantOneShotBase {
+ threadId: string
+ runId: string
+ parentRunId?: string
+ state: unknown
+ aguiContext: Array
+ forwardedProps: Record
+ request: Request
+}
+
+export interface AssistantImageRequest extends AssistantOneShotBase {
+ prompt: unknown
+ numberOfImages?: number
+ size?: unknown
+ modelOptions?: Record
+}
+export interface AssistantAudioRequest extends AssistantOneShotBase {
+ prompt: unknown
+ duration?: number
+ modelOptions?: Record
+}
+export interface AssistantSpeechRequest extends AssistantOneShotBase {
+ text: string
+ voice?: string
+ format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
+ speed?: number
+ modelOptions?: Record
+}
+export interface AssistantVideoRequest extends AssistantOneShotBase {
+ prompt: unknown
+ size?: unknown
+ duration?: unknown
+ modelOptions?: Record
+}
+export interface AssistantTranscriptionRequest extends AssistantOneShotBase {
+ audio: string | File | Blob | ArrayBuffer
+ language?: string
+ prompt?: string
+ responseFormat?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'
+ modelOptions?: Record
+}
+export interface AssistantSummarizeRequest extends AssistantOneShotBase {
+ text: string
+ maxLength?: number
+ style?: 'bullet-points' | 'paragraph' | 'concise'
+ focus?: Array
+ modelOptions?: Record
+}
+
+/** A capability callback returns either a stream or a promise of the activity result. */
+type MaybeStream = AsyncIterable | Promise
+
+/** The shape a user passes to `defineAssistant`. Every key optional. */
+export interface AssistantConfig {
+ chat?: (
+ req: AssistantChatRequest,
+ ) =>
+ | ChatStream
+ | StructuredOutputStream
+ | Promise
+ | Promise