Skip to content

Commit 0acb9a9

Browse files
committed
feat: multi-capability assistants (defineAssistant + useAssistant)
Declare an assistant's capabilities once on the server and consume them from one typed client hook, over a single endpoint. Server (`@tanstack/ai/assistant`) - `defineAssistant(config)`: an inert registry of per-capability callbacks (chat, image, audio, speech, video, transcription, summarize), each invoking an existing activity. Exposes one `handler(request)` that parses the AG-UI request, routes by a `capability` discriminator, and streams the result back over the existing SSE wire. Nothing is constructed until a request arrives. Client core (`@tanstack/ai-client/assistant`) - `AssistantClient` composes one `ChatClient` + one `GenerationClient` per declared capability behind a single connection. `AssistantSystem<TDef>` maps declared capabilities to fully-typed surfaces, inferred from the definition — no call-site generics. Framework hooks - `useAssistant` for React / Solid / Vue and `createAssistant` for Svelte (`@tanstack/ai-<fw>/assistant`). Each capability entry is the corresponding primitive hook's own return (`useChat` / `useGeneration`). Chat type inference - `chat()`'s return carries an optional type-only phantom (`ChatResultMeta<TTools, TSchema, TStream>`), so `assistant.chat` infers typed `partial`/`final` from an `outputSchema` and types message tool-call parts from the callback's tools — no client re-declaration. Runtime unchanged. Client-executed tools still pass through `chat: { tools }` for their browser implementations (their code can't cross the wire). Docs (`docs/assistant`): a 3-page section — Overview (what an assistant is + when to reach for one vs. single hooks), Scenarios (sequential pipeline + CMS content workflow), and Multiple Assistants (specialized per-surface assistants). Plus an `ai-core/assistant` agent skill, E2E coverage (chat verified end-to-end; image one-shot skipped pending an aimock media fixture), and a changeset.
1 parent 5fcaf90 commit 0acb9a9

54 files changed

Lines changed: 4378 additions & 9 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-client': minor
4+
'@tanstack/ai-react': minor
5+
'@tanstack/ai-solid': minor
6+
'@tanstack/ai-vue': minor
7+
'@tanstack/ai-svelte': minor
8+
---
9+
10+
Add defineAssistant (server) and useAssistant (client) for multi-capability assistants.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
title: Multiple Assistants
3+
id: multiple-assistants
4+
order: 3
5+
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."
6+
keywords:
7+
- tanstack ai
8+
- assistant
9+
- defineAssistant
10+
- useAssistant
11+
- multiple assistants
12+
- system prompt
13+
- routes
14+
---
15+
16+
**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.
17+
18+
A typical app ends up with a few:
19+
20+
| Surface | Capabilities | Why its own assistant |
21+
|---|---|---|
22+
| Customer **support** page | `chat` only | Tight, support-flavored system prompt; no image/speech to expose |
23+
| **Content studio** page | `chat` + `image` + `speech` | A creative workflow that chains capabilities |
24+
| Internal **dashboard** | `chat` + `summarize` | Different auth boundary; different instructions |
25+
26+
## Two assistants, two routes
27+
28+
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:
29+
30+
```ts
31+
// api/assistants.ts
32+
import { chat, generateImage, generateSpeech } from '@tanstack/ai'
33+
import { defineAssistant } from '@tanstack/ai/assistant'
34+
import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
35+
36+
// One place to change the model / provider options for every assistant.
37+
const textModel = () => openaiText('gpt-5.5')
38+
39+
// Support: chat only, with a support-flavored system prompt.
40+
export const supportAssistant = defineAssistant({
41+
chat: (req) =>
42+
chat({
43+
adapter: textModel(),
44+
messages: req.messages,
45+
systemPrompts: [
46+
'You are a customer-support agent for Acme. Be concise and friendly.',
47+
],
48+
}),
49+
})
50+
51+
// Studio: chat + image + speech, for a creative workflow.
52+
export const studioAssistant = defineAssistant({
53+
chat: (req) =>
54+
chat({
55+
adapter: textModel(),
56+
messages: req.messages,
57+
systemPrompts: ['You are a creative copywriter.'],
58+
}),
59+
image: (req) => {
60+
if (typeof req.prompt !== 'string') {
61+
throw new Error('image prompt must be a string')
62+
}
63+
return generateImage({
64+
adapter: openaiImage('gpt-image-2'),
65+
prompt: req.prompt,
66+
})
67+
},
68+
speech: (req) =>
69+
generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
70+
})
71+
72+
// Two routes — one per assistant.
73+
export const supportPOST = (request: Request) => supportAssistant.handler(request)
74+
export const studioPOST = (request: Request) => studioAssistant.handler(request)
75+
```
76+
77+
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.
78+
79+
## Two pages, two clients
80+
81+
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:
82+
83+
```tsx
84+
// pages/SupportPage.tsx
85+
import { fetchServerSentEvents } from '@tanstack/ai-react'
86+
import { useAssistant } from '@tanstack/ai-react/assistant'
87+
import { chat } from '@tanstack/ai'
88+
import { defineAssistant } from '@tanstack/ai/assistant'
89+
import { openaiText } from '@tanstack/ai-openai'
90+
91+
const textModel = () => openaiText('gpt-5.5')
92+
93+
// The same object your /api/support route exports.
94+
const supportAssistant = defineAssistant({
95+
chat: (req) =>
96+
chat({
97+
adapter: textModel(),
98+
messages: req.messages,
99+
systemPrompts: [
100+
'You are a customer-support agent for Acme. Be concise and friendly.',
101+
],
102+
}),
103+
})
104+
105+
function SupportPage() {
106+
const assistant = useAssistant(supportAssistant, {
107+
connection: fetchServerSentEvents('/api/support'),
108+
})
109+
110+
return (
111+
<div>
112+
<button onClick={() => assistant.chat.sendMessage('My order is late.')}>
113+
Ask support
114+
</button>
115+
{assistant.chat.messages.map((message) => (
116+
<p key={message.id}>
117+
{message.parts.find((part) => part.type === 'text')?.content}
118+
</p>
119+
))}
120+
</div>
121+
)
122+
}
123+
```
124+
125+
The studio page points at `/api/studio` and gets `assistant.chat`, `assistant.image`, and `assistant.speech`:
126+
127+
```tsx
128+
// pages/StudioPage.tsx
129+
import { fetchServerSentEvents } from '@tanstack/ai-react'
130+
import { useAssistant } from '@tanstack/ai-react/assistant'
131+
import { chat, generateImage, generateSpeech } from '@tanstack/ai'
132+
import { defineAssistant } from '@tanstack/ai/assistant'
133+
import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai'
134+
135+
const textModel = () => openaiText('gpt-5.5')
136+
137+
// The same object your /api/studio route exports.
138+
const studioAssistant = defineAssistant({
139+
chat: (req) =>
140+
chat({
141+
adapter: textModel(),
142+
messages: req.messages,
143+
systemPrompts: ['You are a creative copywriter.'],
144+
}),
145+
image: (req) => {
146+
if (typeof req.prompt !== 'string') {
147+
throw new Error('image prompt must be a string')
148+
}
149+
return generateImage({
150+
adapter: openaiImage('gpt-image-2'),
151+
prompt: req.prompt,
152+
})
153+
},
154+
speech: (req) =>
155+
generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }),
156+
})
157+
158+
function StudioPage() {
159+
const assistant = useAssistant(studioAssistant, {
160+
connection: fetchServerSentEvents('/api/studio'),
161+
})
162+
163+
return (
164+
<div>
165+
<button onClick={() => assistant.chat.sendMessage('Tagline for a fox plushie?')}>
166+
Write copy
167+
</button>
168+
<button
169+
onClick={() => assistant.image.generate({ prompt: 'a fox plushie' })}
170+
>
171+
Generate art
172+
</button>
173+
{assistant.image.result?.images[0]?.url && (
174+
<img src={assistant.image.result.images[0].url} alt="" />
175+
)}
176+
</div>
177+
)
178+
}
179+
```
180+
181+
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.
182+
183+
## Split, or one broad assistant?
184+
185+
Both are valid. Choose by how the capabilities actually relate:
186+
187+
**Split into multiple assistants when:**
188+
189+
- **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.
190+
- **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.
191+
- **Different auth or rate-limit boundaries.** Separate routes let you guard `/api/support` and `/api/studio` independently.
192+
193+
**Use one broad assistant when:**
194+
195+
- **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.
196+
- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one.
197+
198+
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.
199+
200+
## Next
201+
202+
- [Assistant Overview](./overview) — capabilities, typing, and when to reach for an assistant at all.
203+
- [Scenarios](./scenarios) — chaining capabilities within a single assistant.

0 commit comments

Comments
 (0)