@tanstack/ai 0.52.3 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +14 -13
  2. package/dist/esm/activities/chat/index.js +5 -3
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/generateLiveVideo/adapter.d.ts +69 -0
  5. package/dist/esm/activities/generateLiveVideo/adapter.js +23 -0
  6. package/dist/esm/activities/generateLiveVideo/adapter.js.map +1 -0
  7. package/dist/esm/activities/generateLiveVideo/index.d.ts +99 -0
  8. package/dist/esm/activities/generateLiveVideo/index.js +162 -0
  9. package/dist/esm/activities/generateLiveVideo/index.js.map +1 -0
  10. package/dist/esm/activities/generateVideo/index.js +3 -1
  11. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  12. package/dist/esm/activities/generateWorld/adapter.d.ts +69 -0
  13. package/dist/esm/activities/generateWorld/adapter.js +23 -0
  14. package/dist/esm/activities/generateWorld/adapter.js.map +1 -0
  15. package/dist/esm/activities/generateWorld/index.d.ts +99 -0
  16. package/dist/esm/activities/generateWorld/index.js +162 -0
  17. package/dist/esm/activities/generateWorld/index.js.map +1 -0
  18. package/dist/esm/activities/index.d.ts +8 -2
  19. package/dist/esm/activities/index.js +11 -7
  20. package/dist/esm/activities/middleware/types.d.ts +1 -1
  21. package/dist/esm/activities/summarize/chat-stream-summarize.js +2 -1
  22. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  23. package/dist/esm/byok/define-provider.d.ts +6 -0
  24. package/dist/esm/byok/define-provider.js +2 -1
  25. package/dist/esm/byok/define-provider.js.map +1 -1
  26. package/dist/esm/byok/get-key.d.ts +7 -0
  27. package/dist/esm/byok/get-key.js +8 -1
  28. package/dist/esm/byok/get-key.js.map +1 -1
  29. package/dist/esm/byok/server.d.ts +1 -1
  30. package/dist/esm/byok/server.js +2 -2
  31. package/dist/esm/client.d.ts +4 -2
  32. package/dist/esm/client.js +3 -1
  33. package/dist/esm/client.js.map +1 -1
  34. package/dist/esm/index.d.ts +4 -2
  35. package/dist/esm/index.js +3 -1
  36. package/dist/esm/middlewares/otel.js +3 -1
  37. package/dist/esm/middlewares/otel.js.map +1 -1
  38. package/dist/esm/types.d.ts +112 -0
  39. package/package.json +2 -2
  40. package/skills/ai-core/adapter-configuration/SKILL.md +103 -54
  41. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +39 -21
  42. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +5 -0
  43. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +14 -6
  44. package/skills/ai-core/adapter-configuration/references/grok-adapter.md +33 -25
  45. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +7 -2
  46. package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +25 -12
  47. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +19 -9
  48. package/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +34 -21
  49. package/skills/ai-core/ag-ui-protocol/SKILL.md +16 -10
  50. package/skills/ai-core/chat-experience/SKILL.md +228 -108
  51. package/skills/ai-core/client-persistence/SKILL.md +21 -9
  52. package/skills/ai-core/custom-backend-integration/SKILL.md +86 -52
  53. package/skills/ai-core/debug-logging/SKILL.md +100 -18
  54. package/skills/ai-core/locks/SKILL.md +35 -7
  55. package/skills/ai-core/media-generation/SKILL.md +114 -49
  56. package/skills/ai-core/middleware/SKILL.md +174 -69
  57. package/skills/ai-core/structured-outputs/SKILL.md +99 -49
  58. package/skills/ai-core/tool-calling/SKILL.md +245 -158
  59. package/src/activities/chat/index.ts +6 -7
  60. package/src/activities/generateLiveVideo/adapter.ts +99 -0
  61. package/src/activities/generateLiveVideo/index.ts +339 -0
  62. package/src/activities/generateVideo/index.ts +3 -4
  63. package/src/activities/generateWorld/adapter.ts +96 -0
  64. package/src/activities/generateWorld/index.ts +339 -0
  65. package/src/activities/index.ts +44 -0
  66. package/src/activities/middleware/types.ts +2 -0
  67. package/src/activities/summarize/chat-stream-summarize.ts +2 -0
  68. package/src/byok/define-provider.ts +7 -0
  69. package/src/byok/get-key.ts +18 -0
  70. package/src/byok/server.ts +1 -1
  71. package/src/client.ts +8 -0
  72. package/src/index.ts +8 -0
  73. package/src/middlewares/otel.ts +2 -0
  74. package/src/types.ts +128 -0
@@ -24,26 +24,31 @@ sources:
24
24
  ```typescript
25
25
  import { chat, toServerSentEventsResponse } from '@tanstack/ai'
26
26
  import { openaiText } from '@tanstack/ai-openai'
27
+ import { trackAnalytics, reportError } from './analytics'
27
28
 
28
- const stream = chat({
29
- adapter: openaiText('gpt-5.2'),
30
- messages,
31
- middleware: [
32
- {
33
- onStart: (ctx) => {
34
- console.log('Chat started:', ctx.model)
35
- },
36
- onFinish: (ctx, info) => {
37
- trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
38
- },
39
- onError: (ctx, info) => {
40
- reportError(info.error)
29
+ export async function POST(request: Request) {
30
+ const { messages } = await request.json()
31
+
32
+ const stream = chat({
33
+ adapter: openaiText('gpt-5.5'),
34
+ messages,
35
+ middleware: [
36
+ {
37
+ onStart: (ctx) => {
38
+ console.log('Chat started:', ctx.model)
39
+ },
40
+ onFinish: (ctx, info) => {
41
+ trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
42
+ },
43
+ onError: (ctx, info) => {
44
+ reportError(info.error)
45
+ },
41
46
  },
42
- },
43
- ],
44
- })
47
+ ],
48
+ })
45
49
 
46
- return toServerSentEventsResponse(stream)
50
+ return toServerSentEventsResponse(stream)
51
+ }
47
52
  ```
48
53
 
49
54
  ## Hooks Reference
@@ -119,19 +124,30 @@ specific config changes that should not affect the agent-loop adapter calls.
119
124
  **Signature:**
120
125
 
121
126
  ```ts
122
- onStructuredOutputConfig?: (
123
- ctx: ChatMiddlewareContext,
124
- config: StructuredOutputMiddlewareConfig,
125
- ) =>
126
- | void
127
- | null
128
- | Partial<StructuredOutputMiddlewareConfig>
129
- | Promise<void | null | Partial<StructuredOutputMiddlewareConfig>>
127
+ import type {
128
+ ChatMiddlewareContext,
129
+ StructuredOutputMiddlewareConfig,
130
+ } from '@tanstack/ai'
131
+
132
+ // Excerpt of the `ChatMiddleware` interface exported by '@tanstack/ai'
133
+ interface ChatMiddleware {
134
+ onStructuredOutputConfig?: (
135
+ ctx: ChatMiddlewareContext,
136
+ config: StructuredOutputMiddlewareConfig,
137
+ ) =>
138
+ | void
139
+ | null
140
+ | Partial<StructuredOutputMiddlewareConfig>
141
+ | Promise<void | null | Partial<StructuredOutputMiddlewareConfig>>
142
+ }
130
143
  ```
131
144
 
132
145
  **`StructuredOutputMiddlewareConfig` shape:**
133
146
 
134
147
  ```ts
148
+ import type { ChatMiddlewareConfig, JSONSchema } from '@tanstack/ai'
149
+
150
+ // As exported by '@tanstack/ai'
135
151
  interface StructuredOutputMiddlewareConfig extends Omit<
136
152
  ChatMiddlewareConfig,
137
153
  'tools'
@@ -203,13 +219,17 @@ const analytics: ChatMiddleware = {
203
219
  },
204
220
  }
205
221
 
206
- const stream = chat({
207
- adapter: openaiText('gpt-5.2'),
208
- messages,
209
- middleware: [analytics],
210
- })
222
+ export async function POST(request: Request) {
223
+ const { messages } = await request.json()
224
+
225
+ const stream = chat({
226
+ adapter: openaiText('gpt-5.5'),
227
+ messages,
228
+ middleware: [analytics],
229
+ })
211
230
 
212
- return toServerSentEventsResponse(stream)
231
+ return toServerSentEventsResponse(stream)
232
+ }
213
233
  ```
214
234
 
215
235
  ### Pattern 2: Tool Interception Middleware
@@ -278,11 +298,14 @@ native-combined schema.
278
298
 
279
299
  ```typescript
280
300
  import type { ChatMiddleware } from '@tanstack/ai'
301
+ import { trace } from '@opentelemetry/api'
281
302
 
282
303
  const tracing: ChatMiddleware = {
283
304
  name: 'tracing',
284
305
  onChunk(ctx, chunk) {
285
- span.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
306
+ trace
307
+ .getActiveSpan()
308
+ ?.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
286
309
  },
287
310
  }
288
311
  ```
@@ -296,6 +319,7 @@ the native-combined path, it observes the structured stream with
296
319
 
297
320
  ```typescript
298
321
  import type { ChatMiddleware } from '@tanstack/ai'
322
+ import { sharedDefs } from './defs'
299
323
 
300
324
  const injectDefs: ChatMiddleware = {
301
325
  name: 'inject-defs',
@@ -317,9 +341,27 @@ Middleware executes in array order (left-to-right). Ordering matters for hooks t
317
341
  pipe or short-circuit:
318
342
 
319
343
  ```typescript
320
- import { chat, type ChatMiddleware } from '@tanstack/ai'
344
+ import {
345
+ chat,
346
+ toolDefinition,
347
+ toServerSentEventsResponse,
348
+ type ChatMiddleware,
349
+ } from '@tanstack/ai'
321
350
  import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
322
351
  import { openaiText } from '@tanstack/ai-openai'
352
+ import { z } from 'zod'
353
+
354
+ const weatherTool = toolDefinition({
355
+ name: 'getWeather',
356
+ description: 'Get the current weather for a city',
357
+ inputSchema: z.object({ city: z.string() }),
358
+ }).server(async ({ city }) => ({ city, tempC: 21 }))
359
+
360
+ const stockTool = toolDefinition({
361
+ name: 'getStock',
362
+ description: 'Get the latest price for a ticker symbol',
363
+ inputSchema: z.object({ symbol: z.string() }),
364
+ }).server(async ({ symbol }) => ({ symbol, price: 123.45 }))
323
365
 
324
366
  const logging: ChatMiddleware = {
325
367
  name: 'logging',
@@ -347,16 +389,22 @@ const configTransform: ChatMiddleware = {
347
389
  },
348
390
  }
349
391
 
350
- const stream = chat({
351
- adapter: openaiText('gpt-5.2'),
352
- messages,
353
- tools: [weatherTool, stockTool],
354
- middleware: [
355
- logging, // Runs first
356
- configTransform, // Transforms config second
357
- toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
358
- ],
359
- })
392
+ export async function POST(request: Request) {
393
+ const { messages } = await request.json()
394
+
395
+ const stream = chat({
396
+ adapter: openaiText('gpt-5.5'),
397
+ messages,
398
+ tools: [weatherTool, stockTool],
399
+ middleware: [
400
+ logging, // Runs first
401
+ configTransform, // Transforms config second
402
+ toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
403
+ ],
404
+ })
405
+
406
+ return toServerSentEventsResponse(stream)
407
+ }
360
408
  ```
361
409
 
362
410
  **Composition rules by hook:**
@@ -378,7 +426,21 @@ Not a built-in. Cap fan-out with `onBeforeToolCall` skip + `onShouldContinue`.
378
426
  See `docs/chat/agentic-cycle.md` ("Tool-call budgets").
379
427
 
380
428
  ```typescript
381
- import { chat, maxIterations, type ChatMiddleware } from '@tanstack/ai'
429
+ import {
430
+ chat,
431
+ maxIterations,
432
+ toolDefinition,
433
+ toServerSentEventsResponse,
434
+ type ChatMiddleware,
435
+ } from '@tanstack/ai'
436
+ import { openaiText } from '@tanstack/ai-openai'
437
+ import { z } from 'zod'
438
+
439
+ const weatherTool = toolDefinition({
440
+ name: 'getWeather',
441
+ description: 'Get the current weather for a city',
442
+ inputSchema: z.object({ city: z.string() }),
443
+ }).server(async ({ city }) => ({ city, tempC: 21 }))
382
444
 
383
445
  function toolCallBudget(opts: {
384
446
  max?: number
@@ -409,13 +471,19 @@ function toolCallBudget(opts: {
409
471
  }
410
472
  }
411
473
 
412
- chat({
413
- adapter,
414
- messages,
415
- tools: [weatherTool],
416
- agentLoopStrategy: maxIterations(20),
417
- middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
418
- })
474
+ export async function POST(request: Request) {
475
+ const { messages } = await request.json()
476
+
477
+ const stream = chat({
478
+ adapter: openaiText('gpt-5.5'),
479
+ messages,
480
+ tools: [weatherTool],
481
+ agentLoopStrategy: maxIterations(20),
482
+ middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
483
+ })
484
+
485
+ return toServerSentEventsResponse(stream)
486
+ }
419
487
  ```
420
488
 
421
489
  ## Built-in: toolCacheMiddleware
@@ -423,21 +491,35 @@ chat({
423
491
  Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`:
424
492
 
425
493
  ```typescript
426
- import { chat } from '@tanstack/ai'
494
+ import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
427
495
  import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
496
+ import { openaiText } from '@tanstack/ai-openai'
497
+ import { z } from 'zod'
428
498
 
429
- const stream = chat({
430
- adapter,
431
- messages,
432
- tools: [weatherTool],
433
- middleware: [
434
- toolCacheMiddleware({
435
- ttl: 60_000, // Cache entries expire after 60 seconds
436
- maxSize: 50, // Max 50 entries (LRU eviction)
437
- toolNames: ['getWeather'], // Only cache specific tools
438
- }),
439
- ],
440
- })
499
+ const weatherTool = toolDefinition({
500
+ name: 'getWeather',
501
+ description: 'Get the current weather for a city',
502
+ inputSchema: z.object({ city: z.string() }),
503
+ }).server(async ({ city }) => ({ city, tempC: 21 }))
504
+
505
+ export async function POST(request: Request) {
506
+ const { messages } = await request.json()
507
+
508
+ const stream = chat({
509
+ adapter: openaiText('gpt-5.5'),
510
+ messages,
511
+ tools: [weatherTool],
512
+ middleware: [
513
+ toolCacheMiddleware({
514
+ ttl: 60_000, // Cache entries expire after 60 seconds
515
+ maxSize: 50, // Max 50 entries (LRU eviction)
516
+ toolNames: ['getWeather'], // Only cache specific tools
517
+ }),
518
+ ],
519
+ })
520
+
521
+ return toServerSentEventsResponse(stream)
522
+ }
441
523
  ```
442
524
 
443
525
  Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all),
@@ -550,7 +632,12 @@ implement, and what `@tanstack/ai-sandbox`'s run driver resolves per run — its
550
632
  `snapshot()` method alongside `append`, `read`, and `close`:
551
633
 
552
634
  ```ts
553
- snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>>
635
+ import type { StreamChunk } from '@tanstack/ai'
636
+
637
+ // Excerpt of the `StreamDurability` interface exported by '@tanstack/ai'
638
+ interface StreamDurability<TOffset extends string = string> {
639
+ snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>>
640
+ }
554
641
  ```
555
642
 
556
643
  It returns everything stored for a run right now, in append order, then
@@ -695,11 +782,15 @@ Source: docs/sandbox/observability.md
695
782
  ### a. MEDIUM: Trying to modify StreamChunks in middleware
696
783
 
697
784
  ```typescript
785
+ import type { ChatMiddleware } from '@tanstack/ai'
786
+
698
787
  // WRONG -- mutating the chunk object directly
699
788
  const broken: ChatMiddleware = {
700
789
  name: 'broken',
701
790
  onChunk: (ctx, chunk) => {
702
- chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
791
+ if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
792
+ chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
793
+ }
703
794
  },
704
795
  }
705
796
 
@@ -736,6 +827,9 @@ middleware had decided to reject. A throw from either fails the whole stream. Th
736
827
  is where an unhandled error actually costs you a response:
737
828
 
738
829
  ```typescript
830
+ import type { ChatMiddleware } from '@tanstack/ai'
831
+ import { logChunk, requireEnv } from './logging'
832
+
739
833
  // WRONG -- an unhandled error in onChunk kills the entire streaming response
740
834
  const fragile: ChatMiddleware = {
741
835
  name: 'fragile-chunk-logger',
@@ -745,7 +839,12 @@ const fragile: ChatMiddleware = {
745
839
  },
746
840
  onConfig: (ctx, config) => {
747
841
  // Same for a config transform that reads an env var that is not set
748
- return { model: requireEnv('MODEL_OVERRIDE') }
842
+ return {
843
+ modelOptions: {
844
+ ...config.modelOptions,
845
+ temperature: Number(requireEnv('TEMPERATURE')),
846
+ },
847
+ }
749
848
  },
750
849
  }
751
850
 
@@ -761,9 +860,15 @@ const resilient: ChatMiddleware = {
761
860
  // Return void to pass through
762
861
  },
763
862
  onConfig: (ctx, config) => {
764
- const override = process.env.MODEL_OVERRIDE
863
+ const temperature = process.env.TEMPERATURE
765
864
  // Decide, do not throw: no override means no transform.
766
- return override === undefined ? undefined : { model: override }
865
+ if (temperature === undefined) return undefined
866
+ return {
867
+ modelOptions: {
868
+ ...config.modelOptions,
869
+ temperature: Number(temperature),
870
+ },
871
+ }
767
872
  },
768
873
  onFinish: (ctx, info) => {
769
874
  // Already guarded by core — but prefer ctx.defer() anyway, so a slow
@@ -139,7 +139,7 @@ const company = await chat({
139
139
 
140
140
  // Full type safety on nested properties
141
141
  console.log(company.headquarters.city)
142
- console.log(company.employees[0].role)
142
+ console.log(company.employees[0]?.role)
143
143
  console.log(company.financials?.revenue)
144
144
  ```
145
145
 
@@ -147,7 +147,7 @@ console.log(company.financials?.revenue)
147
147
 
148
148
  Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
149
149
 
150
- ```typescript
150
+ ```typescript group=person-stream
151
151
  import { chat } from '@tanstack/ai'
152
152
  import { openaiText } from '@tanstack/ai-openai'
153
153
  import { z } from 'zod'
@@ -192,6 +192,7 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
192
192
  | `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) |
193
193
  | `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API |
194
194
  | `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` |
195
+ | `@tanstack/ai-cloudflare` | Native `structuredOutputStream` without tools; with tools, a separate finalization call (Workers AI models answer the tool turn in prose) |
195
196
  | `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
196
197
  | `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
197
198
  | `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |
@@ -315,7 +316,7 @@ function RecipeBuilder() {
315
316
  .filter((p) => p.type === 'text')
316
317
  .map((p) => p.content)
317
318
  .join('')
318
- return <UserBubble key={m.id} text={text} />
319
+ return <p key={m.id}>{text}</p>
319
320
  }
320
321
  if (m.role === 'assistant') {
321
322
  // `data` is `Recipe` because the schema generic flows from
@@ -337,8 +338,8 @@ function RecipeBuilder() {
337
338
  function RecipeCard({ part }: { part: RecipePart }) {
338
339
  // `data` lands on complete, `partial` fills in while streaming.
339
340
  // Both are typed against the schema. No casts.
340
- const recipe = part.data ?? part.partial ?? ({} as Partial<Recipe>)
341
- return <h3>{recipe.title ?? 'Plating up…'}</h3>
341
+ const recipe = part.data ?? part.partial
342
+ return <h3>{recipe?.title ?? 'Plating up…'}</h3>
342
343
  }
343
344
  ```
344
345
 
@@ -397,12 +398,19 @@ const ReportSchema = z.object({
397
398
  oneLiner: z.string(),
398
399
  })
399
400
 
400
- const { final } = useChat({
401
- connection: fetchServerSentEvents('/api/repo-report'),
402
- outputSchema: ReportSchema,
403
- })
401
+ function RepoReport() {
402
+ const { final, sendMessage } = useChat({
403
+ connection: fetchServerSentEvents('/api/repo-report'),
404
+ outputSchema: ReportSchema,
405
+ })
404
406
 
405
- final?.name
407
+ return (
408
+ <div>
409
+ <button onClick={() => sendMessage('Describe this repo')}>Report</button>
410
+ {final && <h2>{final.name}</h2>}
411
+ </div>
412
+ )
413
+ }
406
414
  ```
407
415
 
408
416
  - Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
@@ -418,28 +426,51 @@ final?.name
418
426
 
419
427
  Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
420
428
 
421
- ```tsx
422
- // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
423
- const last = messages.at(-1)
424
- last?.parts.map((part) => {
425
- if (part.type === 'text') return null // ❌ hides the structured JSON
426
- // ...
429
+ ```tsx group=recipe-renderer
430
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
431
+ import { z } from 'zod'
432
+ import { ReasoningView, ToolCallView, RecipeCard } from './views'
433
+
434
+ const RecipeSchema = z.object({
435
+ title: z.string(),
436
+ steps: z.array(z.string()),
427
437
  })
438
+
439
+ function useRecipeChat() {
440
+ return useChat({
441
+ connection: fetchServerSentEvents('/api/recipes'),
442
+ outputSchema: RecipeSchema,
443
+ })
444
+ }
445
+
446
+ function ObsoleteRenderer() {
447
+ const { messages } = useRecipeChat()
448
+ const last = messages.at(-1)
449
+ // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
450
+ return last?.parts.map((part, i) => {
451
+ if (part.type === 'text') return null // ❌ hides the structured JSON
452
+ return <pre key={i}>{JSON.stringify(part)}</pre>
453
+ })
454
+ }
428
455
  ```
429
456
 
430
457
  That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
431
458
 
432
- ```tsx
433
- // CORRECT — find the structured-output part directly; let actual TextParts render
434
- last?.parts.map((part, i) => {
435
- if (part.type === 'thinking')
436
- return <ReasoningView key={i} text={part.content} />
437
- if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
438
- if (part.type === 'structured-output')
439
- return <RecipeCard key={i} part={part} />
440
- if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
441
- return null
442
- })
459
+ ```tsx group=recipe-renderer
460
+ function RecipeRenderer() {
461
+ const { messages } = useRecipeChat()
462
+ const last = messages.at(-1)
463
+ // CORRECT — find the structured-output part directly; let actual TextParts render
464
+ return last?.parts.map((part, i) => {
465
+ if (part.type === 'thinking')
466
+ return <ReasoningView key={i} text={part.content} />
467
+ if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
468
+ if (part.type === 'structured-output')
469
+ return <RecipeCard key={i} part={part} />
470
+ if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
471
+ return null
472
+ })
473
+ }
443
474
  ```
444
475
 
445
476
  If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
@@ -455,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part.
455
486
 
456
487
  To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
457
488
 
458
- ```tsx
459
- // WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
460
- {final && <RecipeCard recipe={final} />}
461
-
462
- // CORRECT for history — walk messages, render each structured-output part
463
- {messages.map((m) =>
464
- m.role === 'assistant'
465
- ? m.parts.find((p) => p.type === 'structured-output')
466
- ? <RecipeCard key={m.id} part={...} />
467
- : null
468
- : null
469
- )}
489
+ ```tsx group=recipe-renderer
490
+ function RecipeHistory() {
491
+ const { messages, final } = useRecipeChat()
492
+
493
+ return (
494
+ <>
495
+ {/* WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view */}
496
+ {final && <h3>{final.title}</h3>}
497
+
498
+ {/* CORRECT for history — walk messages, render each structured-output part */}
499
+ {messages.map((m) => {
500
+ if (m.role !== 'assistant') return null
501
+ const part = m.parts.find((p) => p.type === 'structured-output')
502
+ return part ? <RecipeCard key={m.id} part={part} /> : null
503
+ })}
504
+ </>
505
+ )
506
+ }
470
507
  ```
471
508
 
472
509
  Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
@@ -475,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output
475
512
 
476
513
  When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
477
514
 
478
- ```typescript
515
+ ```typescript group=person-stream
479
516
  // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
480
517
  for await (const chunk of stream) {
481
518
  if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
@@ -499,8 +536,9 @@ Source: maintainer interview
499
536
 
500
537
  The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
501
538
 
502
- ```typescript
539
+ ```typescript ignore
503
540
  // WRONG -- do not set provider-specific response format
541
+ // (this does not compile: modelOptions has no response-format field)
504
542
  chat({
505
543
  adapter,
506
544
  messages,
@@ -508,11 +546,17 @@ chat({
508
546
  responseFormat: { type: 'json_schema', json_schema: mySchema },
509
547
  },
510
548
  })
549
+ ```
511
550
 
551
+ ```typescript
512
552
  // CORRECT -- just pass outputSchema, the adapter handles the rest
513
- chat({
514
- adapter,
515
- messages,
553
+ import { chat } from '@tanstack/ai'
554
+ import { openaiText } from '@tanstack/ai-openai'
555
+ import { z } from 'zod'
556
+
557
+ const person = await chat({
558
+ adapter: openaiText('gpt-5.2'),
559
+ messages: [{ role: 'user', content: 'John Doe, 30' }],
516
560
  outputSchema: z.object({ name: z.string(), age: z.number() }),
517
561
  })
518
562
  ```
@@ -528,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType,
528
572
  Valibot). Always check what the project uses and match it.
529
573
 
530
574
  ```typescript
531
- // WRONG -- raw schema object, no schema-library type inference
532
- chat({
575
+ import { chat } from '@tanstack/ai'
576
+ import { openaiText } from '@tanstack/ai-openai'
577
+ import { z } from 'zod'
578
+
579
+ const adapter = openaiText('gpt-5.2')
580
+ const messages = [{ role: 'user' as const, content: 'John Doe, 30' }]
581
+
582
+ // WRONG -- raw schema object, no schema-library type inference (result is unknown)
583
+ const untyped = await chat({
533
584
  adapter,
534
585
  messages,
535
586
  outputSchema: {
@@ -544,9 +595,7 @@ chat({
544
595
  })
545
596
 
546
597
  // CORRECT -- use the project's schema library (e.g. Zod)
547
- import { z } from 'zod'
548
-
549
- chat({
598
+ const person = await chat({
550
599
  adapter,
551
600
  messages,
552
601
  outputSchema: z.object({
@@ -554,6 +603,7 @@ chat({
554
603
  age: z.number(),
555
604
  }),
556
605
  })
606
+ person.name // string
557
607
  ```
558
608
 
559
609
  Using the project's schema library gives you TypeScript type inference and