@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.
- package/README.md +0 -4
- package/dist/esm/activities/chat/index.d.ts +12 -3
- package/dist/esm/activities/chat/index.js +75 -7
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +41 -2
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.js +1 -1
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +12 -1
- package/dist/esm/activities/chat/stream/message-updaters.d.ts +35 -0
- package/dist/esm/activities/chat/stream/message-updaters.js +95 -0
- package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +1 -0
- package/dist/esm/activities/chat/stream/processor.js +90 -2
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.js +5 -0
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/adapter-internals.d.ts +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +8 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +86 -16
- package/dist/esm/utilities/ag-ui-wire.d.ts +44 -0
- package/dist/esm/utilities/ag-ui-wire.js +104 -0
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -0
- package/dist/esm/utilities/chat-params.d.ts +80 -0
- package/dist/esm/utilities/chat-params.js +96 -0
- package/dist/esm/utilities/chat-params.js.map +1 -0
- package/package.json +3 -3
- package/skills/ai-core/ag-ui-protocol/SKILL.md +46 -3
- package/skills/ai-core/structured-outputs/SKILL.md +240 -47
- package/src/activities/chat/index.ts +144 -10
- package/src/activities/chat/messages.ts +70 -4
- package/src/activities/chat/middleware/compose.ts +1 -1
- package/src/activities/chat/middleware/types.ts +12 -1
- package/src/activities/chat/stream/message-updaters.ts +171 -0
- package/src/activities/chat/stream/processor.ts +137 -2
- package/src/activities/chat/tools/schema-converter.ts +14 -0
- package/src/adapter-internals.ts +1 -0
- package/src/index.ts +11 -0
- package/src/types.ts +104 -15
- package/src/utilities/ag-ui-wire.ts +201 -0
- 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.
|
|
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.
|
|
56
|
+
"@ag-ui/core": "^0.0.52",
|
|
57
57
|
"partial-json": "^0.1.7",
|
|
58
|
-
"@tanstack/ai-event-client": "0.3.
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
6
|
-
provider-specific strategies transparently — never configure
|
|
7
|
-
output at the provider level. Pass stream:true alongside
|
|
8
|
-
incremental JSON deltas + a terminal validated object
|
|
9
|
-
`structured-output.complete` event.
|
|
10
|
-
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
145
|
+
### Pattern 3: Direct stream iteration
|
|
136
146
|
|
|
137
|
-
Pass `stream: true` alongside `outputSchema` to
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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
|
|
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 --
|
|
206
|
-
let raw = ''
|
|
403
|
+
// CORRECT -- trust the terminal event
|
|
207
404
|
for await (const chunk of stream) {
|
|
208
|
-
if (chunk.type === '
|
|
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
|
|
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/
|
|
294
|
-
- See also: ai-core/
|
|
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).
|