@tanstack/ai 0.53.0 → 0.55.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 (68) hide show
  1. package/README.md +14 -13
  2. package/dist/esm/activities/chat/index.js +22 -4
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.d.ts +21 -1
  5. package/dist/esm/activities/chat/messages.js +50 -1
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/stream/processor.d.ts +17 -0
  8. package/dist/esm/activities/chat/stream/processor.js +27 -0
  9. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  10. package/dist/esm/activities/generateLiveVideo/adapter.d.ts +69 -0
  11. package/dist/esm/activities/generateLiveVideo/adapter.js +23 -0
  12. package/dist/esm/activities/generateLiveVideo/adapter.js.map +1 -0
  13. package/dist/esm/activities/generateLiveVideo/index.d.ts +99 -0
  14. package/dist/esm/activities/generateLiveVideo/index.js +162 -0
  15. package/dist/esm/activities/generateLiveVideo/index.js.map +1 -0
  16. package/dist/esm/activities/generateVideo/index.js +3 -1
  17. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  18. package/dist/esm/activities/generateWorld/adapter.d.ts +69 -0
  19. package/dist/esm/activities/generateWorld/adapter.js +23 -0
  20. package/dist/esm/activities/generateWorld/adapter.js.map +1 -0
  21. package/dist/esm/activities/generateWorld/index.d.ts +99 -0
  22. package/dist/esm/activities/generateWorld/index.js +162 -0
  23. package/dist/esm/activities/generateWorld/index.js.map +1 -0
  24. package/dist/esm/activities/index.d.ts +8 -2
  25. package/dist/esm/activities/index.js +11 -7
  26. package/dist/esm/activities/middleware/types.d.ts +1 -1
  27. package/dist/esm/client.d.ts +4 -2
  28. package/dist/esm/client.js +3 -1
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/index.d.ts +4 -2
  31. package/dist/esm/index.js +3 -1
  32. package/dist/esm/middlewares/otel.js +3 -1
  33. package/dist/esm/middlewares/otel.js.map +1 -1
  34. package/dist/esm/types.d.ts +112 -0
  35. package/package.json +3 -3
  36. package/skills/ai-core/adapter-configuration/SKILL.md +91 -43
  37. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +39 -21
  38. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +5 -0
  39. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +14 -6
  40. package/skills/ai-core/adapter-configuration/references/grok-adapter.md +33 -25
  41. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +7 -2
  42. package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +25 -12
  43. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +19 -9
  44. package/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +34 -21
  45. package/skills/ai-core/ag-ui-protocol/SKILL.md +16 -10
  46. package/skills/ai-core/chat-experience/SKILL.md +228 -108
  47. package/skills/ai-core/client-persistence/SKILL.md +21 -9
  48. package/skills/ai-core/custom-backend-integration/SKILL.md +86 -52
  49. package/skills/ai-core/debug-logging/SKILL.md +100 -18
  50. package/skills/ai-core/locks/SKILL.md +35 -7
  51. package/skills/ai-core/media-generation/SKILL.md +136 -61
  52. package/skills/ai-core/middleware/SKILL.md +174 -69
  53. package/skills/ai-core/structured-outputs/SKILL.md +98 -49
  54. package/skills/ai-core/tool-calling/SKILL.md +245 -158
  55. package/src/activities/chat/index.ts +29 -7
  56. package/src/activities/chat/messages.ts +60 -0
  57. package/src/activities/chat/stream/processor.ts +31 -0
  58. package/src/activities/generateLiveVideo/adapter.ts +99 -0
  59. package/src/activities/generateLiveVideo/index.ts +339 -0
  60. package/src/activities/generateVideo/index.ts +3 -4
  61. package/src/activities/generateWorld/adapter.ts +96 -0
  62. package/src/activities/generateWorld/index.ts +339 -0
  63. package/src/activities/index.ts +44 -0
  64. package/src/activities/middleware/types.ts +2 -0
  65. package/src/client.ts +8 -0
  66. package/src/index.ts +8 -0
  67. package/src/middlewares/otel.ts +2 -0
  68. 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'
@@ -316,7 +316,7 @@ function RecipeBuilder() {
316
316
  .filter((p) => p.type === 'text')
317
317
  .map((p) => p.content)
318
318
  .join('')
319
- return <UserBubble key={m.id} text={text} />
319
+ return <p key={m.id}>{text}</p>
320
320
  }
321
321
  if (m.role === 'assistant') {
322
322
  // `data` is `Recipe` because the schema generic flows from
@@ -338,8 +338,8 @@ function RecipeBuilder() {
338
338
  function RecipeCard({ part }: { part: RecipePart }) {
339
339
  // `data` lands on complete, `partial` fills in while streaming.
340
340
  // Both are typed against the schema. No casts.
341
- const recipe = part.data ?? part.partial ?? ({} as Partial<Recipe>)
342
- return <h3>{recipe.title ?? 'Plating up…'}</h3>
341
+ const recipe = part.data ?? part.partial
342
+ return <h3>{recipe?.title ?? 'Plating up…'}</h3>
343
343
  }
344
344
  ```
345
345
 
@@ -398,12 +398,19 @@ const ReportSchema = z.object({
398
398
  oneLiner: z.string(),
399
399
  })
400
400
 
401
- const { final } = useChat({
402
- connection: fetchServerSentEvents('/api/repo-report'),
403
- outputSchema: ReportSchema,
404
- })
401
+ function RepoReport() {
402
+ const { final, sendMessage } = useChat({
403
+ connection: fetchServerSentEvents('/api/repo-report'),
404
+ outputSchema: ReportSchema,
405
+ })
405
406
 
406
- 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
+ }
407
414
  ```
408
415
 
409
416
  - Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
@@ -419,28 +426,51 @@ final?.name
419
426
 
420
427
  Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
421
428
 
422
- ```tsx
423
- // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
424
- const last = messages.at(-1)
425
- last?.parts.map((part) => {
426
- if (part.type === 'text') return null // ❌ hides the structured JSON
427
- // ...
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()),
428
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
+ }
429
455
  ```
430
456
 
431
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.
432
458
 
433
- ```tsx
434
- // CORRECT — find the structured-output part directly; let actual TextParts render
435
- last?.parts.map((part, i) => {
436
- if (part.type === 'thinking')
437
- return <ReasoningView key={i} text={part.content} />
438
- if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
439
- if (part.type === 'structured-output')
440
- return <RecipeCard key={i} part={part} />
441
- if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
442
- return null
443
- })
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
+ }
444
474
  ```
445
475
 
446
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.
@@ -456,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part.
456
486
 
457
487
  To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
458
488
 
459
- ```tsx
460
- // WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
461
- {final && <RecipeCard recipe={final} />}
462
-
463
- // CORRECT for history — walk messages, render each structured-output part
464
- {messages.map((m) =>
465
- m.role === 'assistant'
466
- ? m.parts.find((p) => p.type === 'structured-output')
467
- ? <RecipeCard key={m.id} part={...} />
468
- : null
469
- : null
470
- )}
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
+ }
471
507
  ```
472
508
 
473
509
  Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
@@ -476,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output
476
512
 
477
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.
478
514
 
479
- ```typescript
515
+ ```typescript group=person-stream
480
516
  // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
481
517
  for await (const chunk of stream) {
482
518
  if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
@@ -500,8 +536,9 @@ Source: maintainer interview
500
536
 
501
537
  The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
502
538
 
503
- ```typescript
539
+ ```typescript ignore
504
540
  // WRONG -- do not set provider-specific response format
541
+ // (this does not compile: modelOptions has no response-format field)
505
542
  chat({
506
543
  adapter,
507
544
  messages,
@@ -509,11 +546,17 @@ chat({
509
546
  responseFormat: { type: 'json_schema', json_schema: mySchema },
510
547
  },
511
548
  })
549
+ ```
512
550
 
551
+ ```typescript
513
552
  // CORRECT -- just pass outputSchema, the adapter handles the rest
514
- chat({
515
- adapter,
516
- 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' }],
517
560
  outputSchema: z.object({ name: z.string(), age: z.number() }),
518
561
  })
519
562
  ```
@@ -529,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType,
529
572
  Valibot). Always check what the project uses and match it.
530
573
 
531
574
  ```typescript
532
- // WRONG -- raw schema object, no schema-library type inference
533
- 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({
534
584
  adapter,
535
585
  messages,
536
586
  outputSchema: {
@@ -545,9 +595,7 @@ chat({
545
595
  })
546
596
 
547
597
  // CORRECT -- use the project's schema library (e.g. Zod)
548
- import { z } from 'zod'
549
-
550
- chat({
598
+ const person = await chat({
551
599
  adapter,
552
600
  messages,
553
601
  outputSchema: z.object({
@@ -555,6 +603,7 @@ chat({
555
603
  age: z.number(),
556
604
  }),
557
605
  })
606
+ person.name // string
558
607
  ```
559
608
 
560
609
  Using the project's schema library gives you TypeScript type inference and