|
| 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