@tanstack/ai 0.17.0 → 0.19.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 (43) hide show
  1. package/README.md +0 -4
  2. package/dist/esm/activities/chat/index.d.ts +12 -3
  3. package/dist/esm/activities/chat/index.js +75 -7
  4. package/dist/esm/activities/chat/index.js.map +1 -1
  5. package/dist/esm/activities/chat/messages.js +41 -2
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/middleware/compose.js +1 -1
  8. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  9. package/dist/esm/activities/chat/middleware/types.d.ts +12 -1
  10. package/dist/esm/activities/chat/stream/message-updaters.d.ts +35 -0
  11. package/dist/esm/activities/chat/stream/message-updaters.js +95 -0
  12. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  13. package/dist/esm/activities/chat/stream/processor.d.ts +1 -0
  14. package/dist/esm/activities/chat/stream/processor.js +90 -2
  15. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  16. package/dist/esm/activities/chat/tools/schema-converter.js +5 -0
  17. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  18. package/dist/esm/adapter-internals.d.ts +1 -0
  19. package/dist/esm/index.d.ts +3 -0
  20. package/dist/esm/index.js +8 -2
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/types.d.ts +86 -16
  23. package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
  24. package/dist/esm/utilities/ag-ui-wire.js +104 -0
  25. package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
  26. package/dist/esm/utilities/chat-params.d.ts +80 -0
  27. package/dist/esm/utilities/chat-params.js +96 -0
  28. package/dist/esm/utilities/chat-params.js.map +1 -0
  29. package/package.json +3 -3
  30. package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
  31. package/skills/ai-core/structured-outputs/SKILL.md +240 -47
  32. package/src/activities/chat/index.ts +144 -10
  33. package/src/activities/chat/messages.ts +70 -4
  34. package/src/activities/chat/middleware/compose.ts +1 -1
  35. package/src/activities/chat/middleware/types.ts +12 -1
  36. package/src/activities/chat/stream/message-updaters.ts +171 -0
  37. package/src/activities/chat/stream/processor.ts +137 -2
  38. package/src/activities/chat/tools/schema-converter.ts +14 -0
  39. package/src/adapter-internals.ts +1 -0
  40. package/src/index.ts +11 -0
  41. package/src/types.ts +104 -15
  42. package/src/utilities/ag-ui-wire.ts +201 -0
  43. package/src/utilities/chat-params.ts +199 -0
@@ -0,0 +1,96 @@
1
+ import { RunAgentInputSchema, AGUIError } from "@ag-ui/core";
2
+ const KNOWN_PART_TYPES = /* @__PURE__ */ new Set([
3
+ "text",
4
+ "image",
5
+ "audio",
6
+ "video",
7
+ "document",
8
+ "tool-call",
9
+ "tool-result",
10
+ "thinking"
11
+ ]);
12
+ function isValidParts(value) {
13
+ if (!Array.isArray(value)) return false;
14
+ for (const p of value) {
15
+ if (!p || typeof p !== "object") return false;
16
+ const type = p.type;
17
+ if (typeof type !== "string" || !KNOWN_PART_TYPES.has(type)) return false;
18
+ }
19
+ return true;
20
+ }
21
+ function chatParamsFromRequestBody(body) {
22
+ const parseResult = RunAgentInputSchema.safeParse(body);
23
+ if (!parseResult.success) {
24
+ return Promise.reject(
25
+ new AGUIError(
26
+ `Request body is not a valid AG-UI RunAgentInput. If you're upgrading from a previous @tanstack/ai-client release, see docs/migration/ag-ui-compliance.md. Validation errors: ${parseResult.error.message}`
27
+ )
28
+ );
29
+ }
30
+ const parsed = parseResult.data;
31
+ const rawMessages = body.messages ?? [];
32
+ const messages = parsed.messages.map((m, i) => {
33
+ const raw = rawMessages[i];
34
+ if (raw && typeof raw === "object" && "parts" in raw && isValidParts(raw.parts)) {
35
+ return { ...m, parts: raw.parts };
36
+ }
37
+ return m;
38
+ });
39
+ return Promise.resolve({
40
+ messages,
41
+ threadId: parsed.threadId,
42
+ runId: parsed.runId,
43
+ parentRunId: parsed.parentRunId,
44
+ tools: parsed.tools,
45
+ forwardedProps: parsed.forwardedProps ?? {},
46
+ state: parsed.state,
47
+ context: parsed.context
48
+ });
49
+ }
50
+ async function chatParamsFromRequest(req) {
51
+ let body;
52
+ try {
53
+ body = await req.json();
54
+ } catch (cause) {
55
+ const res = new Response(
56
+ "Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.",
57
+ { status: 400 }
58
+ );
59
+ res.cause = cause;
60
+ throw res;
61
+ }
62
+ try {
63
+ return await chatParamsFromRequestBody(body);
64
+ } catch (cause) {
65
+ const res = new Response(
66
+ "Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.",
67
+ { status: 400 }
68
+ );
69
+ res.cause = cause;
70
+ throw res;
71
+ }
72
+ }
73
+ function mergeAgentTools(serverTools, clientTools) {
74
+ const seen = new Set(serverTools.map((t) => t.name));
75
+ const merged = [...serverTools];
76
+ for (const ct of clientTools) {
77
+ if (seen.has(ct.name)) {
78
+ continue;
79
+ }
80
+ seen.add(ct.name);
81
+ merged.push({
82
+ name: ct.name,
83
+ description: ct.description,
84
+ inputSchema: ct.parameters
85
+ // No `execute` — runtime treats this as a client-side tool and
86
+ // emits ClientToolRequest events.
87
+ });
88
+ }
89
+ return merged;
90
+ }
91
+ export {
92
+ chatParamsFromRequest,
93
+ chatParamsFromRequestBody,
94
+ mergeAgentTools
95
+ };
96
+ //# sourceMappingURL=chat-params.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-params.js","sources":["../../../src/utilities/chat-params.ts"],"sourcesContent":["import { AGUIError, RunAgentInputSchema } from '@ag-ui/core'\nimport type { Context as AGUIContext } from '@ag-ui/core'\nimport type { JSONSchema, ModelMessage, Tool, UIMessage } from '../types'\n\nconst KNOWN_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n 'tool-call',\n 'tool-result',\n 'thinking',\n])\n\nfunction isValidParts(value: unknown): value is Array<{ type: string }> {\n if (!Array.isArray(value)) return false\n for (const p of value) {\n if (!p || typeof p !== 'object') return false\n const type = (p as { type?: unknown }).type\n if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false\n }\n return true\n}\n\n/**\n * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.\n *\n * Returns a spread-friendly object whose `messages` field is suitable for\n * passing directly to `chat({ messages })`. The existing\n * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and\n * reasoning/activity/developer-role normalization internally.\n *\n * @throws An error with a migration-pointing message when the body does\n * not conform to AG-UI 0.0.52 `RunAgentInputSchema`. Surface this as a\n * 400 Bad Request to the client.\n */\nexport function chatParamsFromRequestBody(body: unknown): Promise<{\n messages: Array<UIMessage | ModelMessage>\n threadId: string\n runId: string\n parentRunId?: string\n tools: Array<{ name: string; description: string; parameters: JSONSchema }>\n forwardedProps: Record<string, unknown>\n state: unknown\n context: Array<AGUIContext>\n}> {\n const parseResult = RunAgentInputSchema.safeParse(body)\n if (!parseResult.success) {\n return Promise.reject(\n new AGUIError(\n `Request body is not a valid AG-UI RunAgentInput. ` +\n `If you're upgrading from a previous @tanstack/ai-client release, ` +\n `see docs/migration/ag-ui-compliance.md. ` +\n `Validation errors: ${parseResult.error.message}`,\n ),\n )\n }\n\n const parsed = parseResult.data\n\n // AG-UI Zod uses `.strip()` so extra fields like `parts` on messages are\n // dropped during parse. We re-attach them from the original body so the\n // existing UIMessage path inside `chat()` can use them directly.\n const rawMessages =\n (body as { messages?: Array<Record<string, unknown>> }).messages ?? []\n const messages = parsed.messages.map((m, i) => {\n const raw = rawMessages[i]\n if (\n raw &&\n typeof raw === 'object' &&\n 'parts' in raw &&\n isValidParts(raw.parts)\n ) {\n return { ...m, parts: raw.parts } as UIMessage | ModelMessage\n }\n return m as ModelMessage\n })\n\n return Promise.resolve({\n messages,\n threadId: parsed.threadId,\n runId: parsed.runId,\n parentRunId: parsed.parentRunId,\n tools: parsed.tools as Array<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n forwardedProps: (parsed.forwardedProps ?? {}) as Record<string, unknown>,\n state: parsed.state,\n context: parsed.context,\n })\n}\n\n/**\n * Read an HTTP `Request`, parse its JSON body, and validate it as an\n * AG-UI `RunAgentInput` — collapsing the standard `req.json()` +\n * `chatParamsFromRequestBody(...)` pair into a single call.\n *\n * On a malformed body or invalid AG-UI shape, this **throws a\n * `Response`** with status 400 and a migration-pointing message in the\n * body. Frameworks that natively handle thrown `Response` objects\n * (TanStack Start, SolidStart, Remix, React Router 7) will return the\n * 400 to the client automatically, so the handler reduces to:\n *\n * ```ts\n * export async function POST(req: Request) {\n * const params = await chatParamsFromRequest(req)\n * // ...use params\n * }\n * ```\n *\n * In frameworks that do not auto-handle thrown `Response` objects\n * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call\n * with try/catch and return the caught Response yourself, or use\n * `chatParamsFromRequestBody` directly with your own JSON-parsing.\n *\n * @throws {Response} 400 on malformed JSON or invalid AG-UI shape.\n */\nexport async function chatParamsFromRequest(\n req: Request,\n): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>> {\n let body: unknown\n try {\n body = await req.json()\n } catch (cause) {\n // Preserve the underlying error on the thrown Response for\n // server-side observability without leaking it to the client.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as unknown as { cause?: unknown }).cause = cause\n throw res\n }\n try {\n return await chatParamsFromRequestBody(body)\n } catch (cause) {\n // Generic public message — avoid echoing Zod paths (which can contain\n // user payload fragments) or internal validator strings to the client.\n // The original AGUIError is attached as `cause` so server logs can\n // surface it without exposing it to remote callers.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as unknown as { cause?: unknown }).cause = cause\n throw res\n }\n}\n\n/**\n * Merge a server-side tool array with the AG-UI client-declared tools\n * received in the request body.\n *\n * Rules:\n * - Server tools win on name collision. The client's declaration is\n * ignored if the server already has a tool with that name. The client's\n * UI-side handler still fires when the streamed tool-result event comes\n * through (see `chat-client.ts` `onToolCall`), giving the\n * \"after server execution the client also handles\" semantic for free.\n * - Client-only tools (name not in `serverTools`) become no-execute\n * entries: the runtime's existing `ClientToolRequest` path handles\n * them — server emits a tool-call request, client executes via its\n * registered handler, client posts back the result.\n *\n * @param serverTools - The server's tool array (e.g. from\n * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.\n * @param clientTools - The `tools` array received from\n * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.\n * @returns A merged array suitable for `chat({ tools })`.\n */\nexport function mergeAgentTools(\n serverTools: ReadonlyArray<Tool>,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): Array<Tool> {\n const seen = new Set(serverTools.map((t) => t.name))\n const merged: Array<Tool> = [...serverTools]\n for (const ct of clientTools) {\n if (seen.has(ct.name)) {\n // Server wins on name collision.\n continue\n }\n seen.add(ct.name)\n merged.push({\n name: ct.name,\n description: ct.description,\n inputSchema: ct.parameters,\n // No `execute` — runtime treats this as a client-side tool and\n // emits ClientToolRequest events.\n } as Tool)\n }\n return merged\n}\n"],"names":[],"mappings":";AAIA,MAAM,uCAAuB,IAAI;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,SAAS,aAAa,OAAkD;AACtE,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO;AAClC,aAAW,KAAK,OAAO;AACrB,QAAI,CAAC,KAAK,OAAO,MAAM,SAAU,QAAO;AACxC,UAAM,OAAQ,EAAyB;AACvC,QAAI,OAAO,SAAS,YAAY,CAAC,iBAAiB,IAAI,IAAI,EAAG,QAAO;AAAA,EACtE;AACA,SAAO;AACT;AAcO,SAAS,0BAA0B,MASvC;AACD,QAAM,cAAc,oBAAoB,UAAU,IAAI;AACtD,MAAI,CAAC,YAAY,SAAS;AACxB,WAAO,QAAQ;AAAA,MACb,IAAI;AAAA,QACF,gLAGwB,YAAY,MAAM,OAAO;AAAA,MAAA;AAAA,IACnD;AAAA,EAEJ;AAEA,QAAM,SAAS,YAAY;AAK3B,QAAM,cACH,KAAuD,YAAY,CAAA;AACtE,QAAM,WAAW,OAAO,SAAS,IAAI,CAAC,GAAG,MAAM;AAC7C,UAAM,MAAM,YAAY,CAAC;AACzB,QACE,OACA,OAAO,QAAQ,YACf,WAAW,OACX,aAAa,IAAI,KAAK,GACtB;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,IAAI,MAAA;AAAA,IAC5B;AACA,WAAO;AAAA,EACT,CAAC;AAED,SAAO,QAAQ,QAAQ;AAAA,IACrB;AAAA,IACA,UAAU,OAAO;AAAA,IACjB,OAAO,OAAO;AAAA,IACd,aAAa,OAAO;AAAA,IACpB,OAAO,OAAO;AAAA,IAKd,gBAAiB,OAAO,kBAAkB,CAAA;AAAA,IAC1C,OAAO,OAAO;AAAA,IACd,SAAS,OAAO;AAAA,EAAA,CACjB;AACH;AA2BA,eAAsB,sBACpB,KACgE;AAChE,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,IAAI,KAAA;AAAA,EACnB,SAAS,OAAO;AAGd,UAAM,MAAM,IAAI;AAAA,MACd;AAAA,MACA,EAAE,QAAQ,IAAA;AAAA,IAAI;AAEd,QAAuC,QAAQ;AACjD,UAAM;AAAA,EACR;AACA,MAAI;AACF,WAAO,MAAM,0BAA0B,IAAI;AAAA,EAC7C,SAAS,OAAO;AAKd,UAAM,MAAM,IAAI;AAAA,MACd;AAAA,MACA,EAAE,QAAQ,IAAA;AAAA,IAAI;AAEd,QAAuC,QAAQ;AACjD,UAAM;AAAA,EACR;AACF;AAuBO,SAAS,gBACd,aACA,aAKa;AACb,QAAM,OAAO,IAAI,IAAI,YAAY,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACnD,QAAM,SAAsB,CAAC,GAAG,WAAW;AAC3C,aAAW,MAAM,aAAa;AAC5B,QAAI,KAAK,IAAI,GAAG,IAAI,GAAG;AAErB;AAAA,IACF;AACA,SAAK,IAAI,GAAG,IAAI;AAChB,WAAO,KAAK;AAAA,MACV,MAAM,GAAG;AAAA,MACT,aAAa,GAAG;AAAA,MAChB,aAAa,GAAG;AAAA;AAAA;AAAA,IAAA,CAGT;AAAA,EACX;AACA,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Core TanStack AI library - Open source AI SDK",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -53,9 +53,9 @@
53
53
  "tanstack-intent"
54
54
  ],
55
55
  "dependencies": {
56
- "@ag-ui/core": "0.0.49",
56
+ "@ag-ui/core": "^0.0.52",
57
57
  "partial-json": "^0.1.7",
58
- "@tanstack/ai-event-client": "0.3.1"
58
+ "@tanstack/ai-event-client": "0.3.3"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@opentelemetry/api": ">=1.9.0"
@@ -39,6 +39,47 @@ export async function POST(request: Request) {
39
39
  typed AG-UI event (discriminated union on `type`). The `toServerSentEventsResponse()`
40
40
  helper encodes that iterable into an SSE-formatted `Response` with correct headers.
41
41
 
42
+ ## Setup — Receiving AG-UI RunAgentInput on the Server
43
+
44
+ ```typescript
45
+ import {
46
+ chat,
47
+ chatParamsFromRequestBody,
48
+ mergeAgentTools,
49
+ toServerSentEventsResponse,
50
+ } from '@tanstack/ai'
51
+ import { openaiText } from '@tanstack/ai-openai/adapters'
52
+ import { serverTools } from './tools'
53
+
54
+ export async function POST(req: Request) {
55
+ let params
56
+ try {
57
+ params = await chatParamsFromRequestBody(await req.json())
58
+ } catch (error) {
59
+ return new Response(
60
+ error instanceof Error ? error.message : 'Bad request',
61
+ { status: 400 },
62
+ )
63
+ }
64
+
65
+ const stream = chat({
66
+ adapter: openaiText('gpt-4o'),
67
+ messages: params.messages,
68
+ tools: mergeAgentTools(serverTools, params.tools),
69
+ })
70
+
71
+ return toServerSentEventsResponse(stream)
72
+ }
73
+ ```
74
+
75
+ `chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core`. `mergeAgentTools` merges the server's tool registry with client-declared tools (server wins on collision; client-only tools become no-execute stubs that flow through the runtime's `ClientToolRequest` path).
76
+
77
+ `params.messages` is a mixed array of TanStack `UIMessage` anchors (with `parts`) and AG-UI fan-out duplicates (`{role:'tool',...}`, `{role:'reasoning',...}`). The existing `convertMessagesToModelMessages` (called inside `chat()`) handles dedup automatically.
78
+
79
+ **Wire shape (POST body):** AG-UI `RunAgentInput` — `{threadId, runId, parentRunId?, state, messages, tools, context, forwardedProps}`. The `messages` array carries TanStack `UIMessage` anchors with their canonical `parts` plus AG-UI mirror fields (`content`, `toolCalls`) inline; tool results and thinking parts are additionally emitted as fan-out `{role:'tool',...}` and `{role:'reasoning',...}` entries.
80
+
81
+ **`forwardedProps` security:** Don't spread it directly into `chat()` — clients could override `adapter`, `model`, `tools`, etc. Always allowlist specific fields.
82
+
42
83
  ## Core Patterns
43
84
 
44
85
  ### 1. SSE Format — toServerSentEventsStream / toServerSentEventsResponse
@@ -223,9 +264,11 @@ Source: docs/protocol/chunk-definitions.md
223
264
 
224
265
  ## Tension
225
266
 
226
- HIGH Tension: AG-UI protocol compliance vs. internal message format -- TanStack
227
- AI's `UIMessage` format (parts-based) diverges from AG-UI spec (content-based).
228
- Full compliance would require a different message structure.
267
+ RESOLVED: TanStack AI is fully AG-UI compliant on both axes (server→client events
268
+ AND client→server `RunAgentInput`). The wire format carries TanStack `UIMessage`
269
+ anchors with their parts intact alongside AG-UI fan-out messages, so strict AG-UI
270
+ servers see role-based messages while TanStack-aware servers read parts directly
271
+ without transformation. See `docs/migration/ag-ui-compliance.md` for details.
229
272
 
230
273
  ## Cross-References
231
274
 
@@ -1,23 +1,30 @@
1
1
  ---
2
2
  name: ai-core/structured-outputs
3
3
  description: >
4
- Type-safe JSON schema responses from LLMs using outputSchema on chat().
5
- Supports Zod, ArkType, and Valibot schemas. The adapter handles
6
- provider-specific strategies transparently — never configure structured
7
- output at the provider level. Pass stream:true alongside outputSchema for
8
- incremental JSON deltas + a terminal validated object via the
9
- `structured-output.complete` event. convertSchemaToJsonSchema() for manual
10
- schema conversion.
4
+ Type-safe JSON schema responses from LLMs using outputSchema on chat()
5
+ and useChat(). Supports Zod, ArkType, and Valibot schemas. The adapter
6
+ handles provider-specific strategies transparently — never configure
7
+ structured output at the provider level. Pass stream:true alongside
8
+ outputSchema for incremental JSON deltas + a terminal validated object
9
+ via the `structured-output.complete` event. Every assistant turn in
10
+ useChat carries its own typed `StructuredOutputPart` on
11
+ `messages[i].parts`, so multi-turn structured chats preserve history
12
+ automatically — partial/final derive from the latest assistant turn's
13
+ part. convertSchemaToJsonSchema() for manual schema conversion.
11
14
  type: sub-skill
12
15
  library: tanstack-ai
13
16
  library_version: '0.10.0'
14
17
  sources:
15
- - 'TanStack/ai:docs/chat/structured-outputs.md'
18
+ - 'TanStack/ai:docs/structured-outputs/overview.md'
19
+ - 'TanStack/ai:docs/structured-outputs/one-shot.md'
20
+ - 'TanStack/ai:docs/structured-outputs/streaming.md'
21
+ - 'TanStack/ai:docs/structured-outputs/multi-turn.md'
22
+ - 'TanStack/ai:docs/structured-outputs/with-tools.md'
16
23
  ---
17
24
 
18
25
  # Structured Outputs
19
26
 
20
- > **Dependency note:** This skill builds on ai-core. Read it first for critical rules.
27
+ > **Dependency note:** This skill builds on ai-core. Read it first for critical rules. The `useChat` patterns below build on ai-core/chat-experience — read that for the base hook surface, then come back here for the structured-output specifics.
21
28
 
22
29
  ## Setup
23
30
 
@@ -26,29 +33,32 @@ import { chat } from '@tanstack/ai'
26
33
  import { openaiText } from '@tanstack/ai-openai'
27
34
  import { z } from 'zod'
28
35
 
29
- const stream = chat({
36
+ const person = await chat({
30
37
  adapter: openaiText('gpt-5.2'),
31
- messages: [
32
- {
33
- role: 'user',
34
- content: [
35
- {
36
- type: 'text',
37
- content: 'Extract the person info from: John is 30 years old',
38
- },
39
- ],
40
- },
41
- ],
38
+ messages: [{ role: 'user', content: 'John Doe, 30' }],
42
39
  outputSchema: z.object({
43
40
  name: z.string(),
44
41
  age: z.number(),
45
42
  }),
46
43
  })
44
+
45
+ person.name // string — fully typed, no cast
46
+ person.age // number
47
47
  ```
48
48
 
49
- When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed based on the schema.
49
+ When `outputSchema` is provided, `chat()` returns `Promise<InferSchemaType<TSchema>>` instead of `AsyncIterable<StreamChunk>`. The result is fully typed.
50
+
51
+ Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats.
50
52
 
51
- Adding `stream: true` switches the return to `StructuredOutputStream<InferSchemaType<TSchema>>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below.
53
+ ## Decision: which pattern fits
54
+
55
+ | Building this | Use |
56
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
57
+ | One prompt in → one typed object out (script, server endpoint, CLI) | Pattern 1 (basic) or 2 (nested) |
58
+ | A UI that fills in field by field as the model streams (progressive form, live card) | Pattern 4 — `useChat({ outputSchema })` |
59
+ | Direct iteration of the stream in Node or tests | Pattern 3 — async iterable |
60
+ | Users iterate on a structured object across multiple turns (recipe builder, ticket refinement) | Pattern 5 — multi-turn structured chat |
61
+ | Tools that gather info, then return a typed object | Combine any of the above with `tools` — see ai-core/tool-calling |
52
62
 
53
63
  ## Core Patterns
54
64
 
@@ -132,9 +142,9 @@ console.log(company.employees[0].role)
132
142
  console.log(company.financials?.revenue)
133
143
  ```
134
144
 
135
- ### Pattern 3: Streaming structured output
145
+ ### Pattern 3: Direct stream iteration
136
146
 
137
- Pass `stream: true` alongside `outputSchema` to receive incremental JSON deltas while the model generates, plus a final validated typed object. Useful for streaming partial UI (progress views, typewriter previews, partially-filled forms).
147
+ Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a terminal validated 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.
138
148
 
139
149
  ```typescript
140
150
  import { chat } from '@tanstack/ai'
@@ -156,15 +166,8 @@ const stream = chat({
156
166
  stream: true,
157
167
  })
158
168
 
159
- let raw = ''
160
169
  for await (const chunk of stream) {
161
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
162
- // Partial JSON text — drive progress UI only. Do NOT JSON.parse.
163
- raw += chunk.delta
164
- } else if (
165
- chunk.type === 'CUSTOM' &&
166
- chunk.name === 'structured-output.complete'
167
- ) {
170
+ if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
168
171
  // Terminal event. `chunk.value.object` is fully validated and typed
169
172
  // against the schema you passed in — no helper or cast required.
170
173
  chunk.value.object.name // string
@@ -174,7 +177,7 @@ for await (const chunk of stream) {
174
177
  }
175
178
  ```
176
179
 
177
- The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through to the terminal event, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper needed.
180
+ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-output.complete', value: { object: T, raw: string, reasoning?: string } }`. The return type of `chat({ outputSchema, stream: true })` carries `T` through, so a plain discriminated narrow (`chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete'`) is enough — no type guard helper.
178
181
 
179
182
  **Adapter coverage for streaming:**
180
183
 
@@ -186,13 +189,208 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
186
189
  | `@tanstack/ai-groq` | Native single-request stream (Chat Completions) |
187
190
  | All other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
188
191
 
189
- The consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental deltas when the adapter implements `structuredOutputStream` natively.
192
+ Consumer code is identical across providers — always read the final object off `structured-output.complete`. You only see incremental `TEXT_MESSAGE_CONTENT` deltas when the adapter implements `structuredOutputStream` natively.
193
+
194
+ ### Pattern 4: useChat with outputSchema (progressive UI)
195
+
196
+ Pass `outputSchema` to `useChat` and you get a `partial` field that fills in as JSON streams in, plus a `final` field that snaps to the validated object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
197
+
198
+ **Server** (same as Pattern 3, just behind an SSE endpoint):
199
+
200
+ ```typescript
201
+ // app/api/extract-person/route.ts (or your framework's equivalent)
202
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
203
+ import { openaiText } from '@tanstack/ai-openai'
204
+ import { z } from 'zod'
205
+
206
+ const PersonSchema = z.object({
207
+ name: z.string(),
208
+ age: z.number(),
209
+ email: z.string().email(),
210
+ })
211
+
212
+ export async function POST(request: Request) {
213
+ const { messages } = await request.json()
214
+ const stream = chat({
215
+ adapter: openaiText('gpt-5.2'),
216
+ messages,
217
+ outputSchema: PersonSchema,
218
+ stream: true,
219
+ })
220
+ return toServerSentEventsResponse(stream)
221
+ }
222
+ ```
223
+
224
+ **Client:**
225
+
226
+ ```tsx
227
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
228
+ import { z } from 'zod'
229
+
230
+ const PersonSchema = z.object({
231
+ name: z.string(),
232
+ age: z.number(),
233
+ email: z.string().email(),
234
+ })
235
+
236
+ function PersonExtractor() {
237
+ const { sendMessage, isLoading, partial, final } = useChat({
238
+ connection: fetchServerSentEvents('/api/extract-person'),
239
+ outputSchema: PersonSchema,
240
+ })
241
+
242
+ return (
243
+ <div>
244
+ <button
245
+ disabled={isLoading}
246
+ onClick={() => sendMessage('Extract: John Doe, 30, john@example.com')}
247
+ >
248
+ Extract
249
+ </button>
250
+ {/* `partial` fills in field by field while streaming. */}
251
+ <p>Name: {partial.name ?? '…'}</p>
252
+ <p>Age: {partial.age ?? '…'}</p>
253
+ <p>Email: {partial.email ?? '…'}</p>
254
+ {final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
255
+ </div>
256
+ )
257
+ }
258
+ ```
259
+
260
+ - `partial` is `DeepPartial<z.infer<typeof PersonSchema>>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas.
261
+ - `final` is `z.infer<typeof PersonSchema> | null` — populated when `structured-output.complete` arrives.
262
+ - `outputSchema` is for client-side type inference only. **Validation runs on the server** against the schema you pass to `chat({ outputSchema })` there.
263
+ - Same shape works for non-streaming adapters: the fallback path emits one whole-JSON `TEXT_MESSAGE_CONTENT` then the terminal event, so `partial` populates and `final` snaps in the same render tick — same consumer code as the native-streaming providers, just without an intermediate field-by-field reveal.
264
+
265
+ ### Pattern 5: Multi-turn structured chat
266
+
267
+ Every assistant turn produced by `useChat({ outputSchema })` carries its own typed `StructuredOutputPart` on `messages[i].parts`. Old turns stay renderable; new turns produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
268
+
269
+ ```tsx
270
+ import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
271
+ import type { StructuredOutputPart } from '@tanstack/ai-client'
272
+ import { z } from 'zod'
273
+
274
+ const RecipeSchema = z.object({
275
+ title: z.string(),
276
+ cuisine: z.string(),
277
+ servings: z.number(),
278
+ ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
279
+ steps: z.array(z.string()),
280
+ })
281
+ type Recipe = z.infer<typeof RecipeSchema>
282
+ type RecipePart = StructuredOutputPart<Recipe>
283
+
284
+ function RecipeBuilder() {
285
+ const { messages, sendMessage } = useChat({
286
+ outputSchema: RecipeSchema,
287
+ connection: fetchServerSentEvents('/api/recipes'),
288
+ })
289
+
290
+ return (
291
+ <div>
292
+ {messages.map((m) => {
293
+ if (m.role === 'user') {
294
+ const text = m.parts
295
+ .filter((p) => p.type === 'text')
296
+ .map((p) => p.content)
297
+ .join('')
298
+ return <UserBubble key={m.id} text={text} />
299
+ }
300
+ if (m.role === 'assistant') {
301
+ // `data` is `Recipe` because the schema generic flows from
302
+ // `useChat({ outputSchema })` through `messages` to the part.
303
+ const part = m.parts.find(
304
+ (p): p is RecipePart => p.type === 'structured-output',
305
+ )
306
+ if (!part) return null
307
+ return <RecipeCard key={m.id} part={part} />
308
+ }
309
+ return null
310
+ })}
311
+ <button onClick={() => sendMessage('pasta for two')}>Cook</button>
312
+ <button onClick={() => sendMessage('now make it vegan')}>Modify</button>
313
+ </div>
314
+ )
315
+ }
316
+
317
+ function RecipeCard({ part }: { part: RecipePart }) {
318
+ // `data` lands on complete, `partial` fills in while streaming.
319
+ // Both are typed against the schema. No casts.
320
+ const recipe = part.data ?? part.partial ?? ({} as Partial<Recipe>)
321
+ return <h3>{recipe.title ?? 'Plating up…'}</h3>
322
+ }
323
+ ```
324
+
325
+ Key behaviors:
326
+
327
+ - **Per-turn parts.** Each `sendMessage()` produces a new assistant message with its own `StructuredOutputPart`. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
328
+ - **Typed by schema.** `messages[i].parts.find(p => p.type === 'structured-output').data` is typed as `Recipe` (no cast, no `unknown`). Works because `useChat<TSchema>` threads `InferSchemaType<TSchema>` down through `UIMessage<TTools, TData>` → `MessagePart<TTools, TData>` → `StructuredOutputPart<TData>`. **In `@tanstack/ai` core** the message types are single-generic (`UIMessage<TData>`); the tools generic lives in `@tanstack/ai-client` and the framework hook packages — import from your framework package or `ai-client`, not from `@tanstack/ai`.
329
+ - **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest assistant message's part (the one after the most recent user message). Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new assistant turn exists yet.
330
+ - **Round-trip preserves history.** When the client sends turn N+1, each prior assistant turn's `structured-output` part is serialized back as `{ role: 'assistant', content: <part.raw> }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip.
190
331
 
191
332
  ## Common Mistakes
192
333
 
334
+ ### HIGH: Filtering `TextPart`s out of `useChat` renderers when using `outputSchema`
335
+
336
+ Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
337
+
338
+ ```tsx
339
+ // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
340
+ const last = messages.at(-1)
341
+ last?.parts.map((part) => {
342
+ if (part.type === 'text') return null // ❌ hides the structured JSON
343
+ // ...
344
+ })
345
+ ```
346
+
347
+ 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.
348
+
349
+ ```tsx
350
+ // CORRECT — find the structured-output part directly; let actual TextParts render
351
+ last?.parts.map((part, i) => {
352
+ if (part.type === 'thinking')
353
+ return <ReasoningView key={i} text={part.content} />
354
+ if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
355
+ if (part.type === 'structured-output')
356
+ return <RecipeCard key={i} part={part} />
357
+ if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
358
+ return null
359
+ })
360
+ ```
361
+
362
+ If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
363
+
364
+ Source: PR #577 — structured-output became a typed UIMessage part.
365
+
366
+ ### HIGH: Treating `partial` / `final` as sticky state across turns
367
+
368
+ `partial` and `final` are **derived from the latest assistant message's `structured-output` part**, not a sticky hook-level slot. In a multi-turn chat:
369
+
370
+ - Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no assistant message after the latest user yet).
371
+ - Once the latest turn completes, `partial === final`. Earlier turns' data is NOT in `partial` / `final` — it lives on the prior assistant messages' parts.
372
+
373
+ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
374
+
375
+ ```tsx
376
+ // WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
377
+ {final && <RecipeCard recipe={final} />}
378
+
379
+ // CORRECT for history — walk messages, render every assistant's structured-output part
380
+ {messages.map((m) =>
381
+ m.role === 'assistant'
382
+ ? m.parts.find((p) => p.type === 'structured-output')
383
+ ? <RecipeCard key={m.id} part={...} />
384
+ : null
385
+ : null
386
+ )}
387
+ ```
388
+
389
+ Source: PR #577 — partial/final derive from the latest assistant turn's part.
390
+
193
391
  ### HIGH: Parsing streaming JSON deltas yourself
194
392
 
195
- When using `chat({ outputSchema, stream: true })`, the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
393
+ 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. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
196
394
 
197
395
  ```typescript
198
396
  // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
@@ -202,21 +400,15 @@ for await (const chunk of stream) {
202
400
  }
203
401
  }
204
402
 
205
- // CORRECT -- accumulate deltas only for UX progress; trust the terminal event
206
- let raw = ''
403
+ // CORRECT -- trust the terminal event
207
404
  for await (const chunk of stream) {
208
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
209
- raw += chunk.delta // optional: render a "streaming JSON" preview
210
- } else if (
211
- chunk.type === 'CUSTOM' &&
212
- chunk.name === 'structured-output.complete'
213
- ) {
405
+ if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
214
406
  const result = chunk.value.object // ✅ typed and validated
215
407
  }
216
408
  }
217
409
  ```
218
410
 
219
- If you need progressive _parsed_ state (e.g. show fields as they arrive), use a partial-JSON parser on the accumulated `raw` string at render time — but do NOT treat the result as schema-validated; only the terminal event is.
411
+ If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time — but do NOT treat the result as schema-validated; only the terminal event is. In `useChat`, this is already done for you (`partial` field on Pattern 4).
220
412
 
221
413
  Source: maintainer interview
222
414
 
@@ -290,5 +482,6 @@ Source: maintainer interview
290
482
 
291
483
  ## Cross-References
292
484
 
293
- - See also: ai-core/adapter-configuration/SKILL.md -- Adapter handles structured output strategy transparently
294
- - See also: ai-core/chat-experience/SKILL.md -- Consuming `StreamChunk` events on the client (the streaming variant uses the same chunk model plus the terminal `structured-output.complete` custom event)
485
+ - See also: **ai-core/chat-experience/SKILL.md** — Base `useChat` surface; the structured-output additions documented here layer on top.
486
+ - See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
487
+ - See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md).