Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
03c093a
feat(ai-react,ai-client,ai): structured-output as a typed UIMessage part
tombeckenham May 18, 2026
640c3b4
fix(ai,ai-client,ai-react): address PR-review findings on structured-…
tombeckenham May 19, 2026
69bceb1
fix: address CodeRabbit findings + e2e structured-output renderer
tombeckenham May 19, 2026
08bea6c
fix(example): make structured-chat schema OpenAI-strict-compatible
tombeckenham May 19, 2026
30a0391
chore(example): add tiles for Structured Chat, Guitar Demo, and Realt…
tombeckenham May 19, 2026
ca93e3f
chore(example): drop Guitar Demo / Realtime tiles from this PR
tombeckenham May 19, 2026
3fdcf7c
fix(example): harden api.structured-chat abort race and 500 sanitization
AlemTuzlak May 19, 2026
64c80cb
fix(ai,ai-react): address CR-loop findings on structured-output parts
AlemTuzlak May 19, 2026
7f087cb
feat(ai,ai-client,ai-react,example): thread schema generic through UI…
AlemTuzlak May 19, 2026
9cd2292
docs(structured-outputs): split into top-level section with per-journ…
AlemTuzlak May 19, 2026
86667c9
docs(structured-outputs): fact-check pass — fix hallucinated API claims
AlemTuzlak May 19, 2026
3aacc76
test(e2e): cover multi-turn structured chat across every provider
AlemTuzlak May 19, 2026
fe40b95
ci: apply automated fixes
autofix-ci[bot] May 19, 2026
6a49113
skill(ai-core/structured-outputs): cover useChat + multi-turn patterns
AlemTuzlak May 19, 2026
b716861
ci: apply automated fixes
autofix-ci[bot] May 19, 2026
d6cab5a
skill(ai-core/structured-outputs): fact-check correction — fallback p…
AlemTuzlak May 19, 2026
a8dac53
chore(self-learning): codify "skills + docs + e2e" rules after PR #57…
AlemTuzlak May 19, 2026
bed147c
feat(ai-vue,ai-solid,ai-svelte): bring structured-output parity from …
AlemTuzlak May 19, 2026
ad99c76
chore(changeset): include all six framework packages bumped by PR #577
AlemTuzlak May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/structured-output-as-message-part.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
---

feat: structured-output as a typed MessagePart on each assistant UIMessage

`useChat({ outputSchema })` previously kept a single hook-level `partial`/`final`
slot, so multi-turn structured chats lost every prior turn's response as soon
as a new one streamed in. Each assistant turn now carries its own typed
`structured-output` MessagePart on the UIMessage it belongs to. History walks
`messages` and finds the typed part on each turn; the hook-level `partial`
and `final` are derived from the latest assistant message's part and continue
to work as before.

Server-side `chat({ outputSchema, stream: true })` emits a new
`structured-output.start` CUSTOM event before the JSON deltas so the client
processor can route them into the StructuredOutputPart instead of building a
TextPart. The wire converter serializes the part's raw JSON back as assistant
content, so multi-turn structured chats stay coherent (the LLM sees its own
prior structured responses on follow-up turns).

A new example route demonstrating this pattern is at
`/generations/structured-chat` in the `ts-react-chat` example.
13 changes: 13 additions & 0 deletions examples/ts-react-chat/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ export default function Header() {
<span className="font-medium">Structured Output</span>
</Link>

<Link
to="/generations/structured-chat"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1"
activeProps={{
className:
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1',
}}
>
<Braces size={20} />
<span className="font-medium">Structured Chat</span>
</Link>

<hr className="border-gray-700 my-2" />

<p className="text-xs text-gray-500 uppercase tracking-wider px-3 pt-2 pb-1">
Expand Down
43 changes: 43 additions & 0 deletions examples/ts-react-chat/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import { Route as GenerationsVideoRouteImport } from './routes/generations.video
import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription'
import { Route as GenerationsSummarizeRouteImport } from './routes/generations.summarize'
import { Route as GenerationsStructuredOutputRouteImport } from './routes/generations.structured-output'
import { Route as GenerationsStructuredChatRouteImport } from './routes/generations.structured-chat'
import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech'
import { Route as GenerationsImageRouteImport } from './routes/generations.image'
import { Route as GenerationsAudioRouteImport } from './routes/generations.audio'
import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe'
import { Route as ApiTanchatRouteImport } from './routes/api.tanchat'
import { Route as ApiSummarizeRouteImport } from './routes/api.summarize'
import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output'
import { Route as ApiStructuredChatRouteImport } from './routes/api.structured-chat'
import { Route as ApiImageGenRouteImport } from './routes/api.image-gen'
import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index'
import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId'
Expand Down Expand Up @@ -68,6 +70,12 @@ const GenerationsStructuredOutputRoute =
path: '/generations/structured-output',
getParentRoute: () => rootRouteImport,
} as any)
const GenerationsStructuredChatRoute =
GenerationsStructuredChatRouteImport.update({
id: '/generations/structured-chat',
path: '/generations/structured-chat',
getParentRoute: () => rootRouteImport,
} as any)
const GenerationsSpeechRoute = GenerationsSpeechRouteImport.update({
id: '/generations/speech',
path: '/generations/speech',
Expand Down Expand Up @@ -103,6 +111,11 @@ const ApiStructuredOutputRoute = ApiStructuredOutputRouteImport.update({
path: '/api/structured-output',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStructuredChatRoute = ApiStructuredChatRouteImport.update({
id: '/api/structured-chat',
path: '/api/structured-chat',
getParentRoute: () => rootRouteImport,
} as any)
const ApiImageGenRoute = ApiImageGenRouteImport.update({
id: '/api/image-gen',
path: '/api/image-gen',
Expand Down Expand Up @@ -144,13 +157,15 @@ export interface FileRoutesByFullPath {
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tanchat': typeof ApiTanchatRoute
'/api/transcribe': typeof ApiTranscribeRoute
'/generations/audio': typeof GenerationsAudioRoute
'/generations/image': typeof GenerationsImageRoute
'/generations/speech': typeof GenerationsSpeechRoute
'/generations/structured-chat': typeof GenerationsStructuredChatRoute
'/generations/structured-output': typeof GenerationsStructuredOutputRoute
'/generations/summarize': typeof GenerationsSummarizeRoute
'/generations/transcription': typeof GenerationsTranscriptionRoute
Expand All @@ -167,13 +182,15 @@ export interface FileRoutesByTo {
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tanchat': typeof ApiTanchatRoute
'/api/transcribe': typeof ApiTranscribeRoute
'/generations/audio': typeof GenerationsAudioRoute
'/generations/image': typeof GenerationsImageRoute
'/generations/speech': typeof GenerationsSpeechRoute
'/generations/structured-chat': typeof GenerationsStructuredChatRoute
'/generations/structured-output': typeof GenerationsStructuredOutputRoute
'/generations/summarize': typeof GenerationsSummarizeRoute
'/generations/transcription': typeof GenerationsTranscriptionRoute
Expand All @@ -191,13 +208,15 @@ export interface FileRoutesById {
'/image-gen': typeof ImageGenRoute
'/realtime': typeof RealtimeRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
'/api/structured-output': typeof ApiStructuredOutputRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tanchat': typeof ApiTanchatRoute
'/api/transcribe': typeof ApiTranscribeRoute
'/generations/audio': typeof GenerationsAudioRoute
'/generations/image': typeof GenerationsImageRoute
'/generations/speech': typeof GenerationsSpeechRoute
'/generations/structured-chat': typeof GenerationsStructuredChatRoute
'/generations/structured-output': typeof GenerationsStructuredOutputRoute
'/generations/summarize': typeof GenerationsSummarizeRoute
'/generations/transcription': typeof GenerationsTranscriptionRoute
Expand All @@ -216,13 +235,15 @@ export interface FileRouteTypes {
| '/image-gen'
| '/realtime'
| '/api/image-gen'
| '/api/structured-chat'
| '/api/structured-output'
| '/api/summarize'
| '/api/tanchat'
| '/api/transcribe'
| '/generations/audio'
| '/generations/image'
| '/generations/speech'
| '/generations/structured-chat'
| '/generations/structured-output'
| '/generations/summarize'
| '/generations/transcription'
Expand All @@ -239,13 +260,15 @@ export interface FileRouteTypes {
| '/image-gen'
| '/realtime'
| '/api/image-gen'
| '/api/structured-chat'
| '/api/structured-output'
| '/api/summarize'
| '/api/tanchat'
| '/api/transcribe'
| '/generations/audio'
| '/generations/image'
| '/generations/speech'
| '/generations/structured-chat'
| '/generations/structured-output'
| '/generations/summarize'
| '/generations/transcription'
Expand All @@ -262,13 +285,15 @@ export interface FileRouteTypes {
| '/image-gen'
| '/realtime'
| '/api/image-gen'
| '/api/structured-chat'
| '/api/structured-output'
| '/api/summarize'
| '/api/tanchat'
| '/api/transcribe'
| '/generations/audio'
| '/generations/image'
| '/generations/speech'
| '/generations/structured-chat'
| '/generations/structured-output'
| '/generations/summarize'
| '/generations/transcription'
Expand All @@ -286,13 +311,15 @@ export interface RootRouteChildren {
ImageGenRoute: typeof ImageGenRoute
RealtimeRoute: typeof RealtimeRoute
ApiImageGenRoute: typeof ApiImageGenRoute
ApiStructuredChatRoute: typeof ApiStructuredChatRoute
ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute
ApiSummarizeRoute: typeof ApiSummarizeRoute
ApiTanchatRoute: typeof ApiTanchatRoute
ApiTranscribeRoute: typeof ApiTranscribeRoute
GenerationsAudioRoute: typeof GenerationsAudioRoute
GenerationsImageRoute: typeof GenerationsImageRoute
GenerationsSpeechRoute: typeof GenerationsSpeechRoute
GenerationsStructuredChatRoute: typeof GenerationsStructuredChatRoute
GenerationsStructuredOutputRoute: typeof GenerationsStructuredOutputRoute
GenerationsSummarizeRoute: typeof GenerationsSummarizeRoute
GenerationsTranscriptionRoute: typeof GenerationsTranscriptionRoute
Expand Down Expand Up @@ -356,6 +383,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GenerationsStructuredOutputRouteImport
parentRoute: typeof rootRouteImport
}
'/generations/structured-chat': {
id: '/generations/structured-chat'
path: '/generations/structured-chat'
fullPath: '/generations/structured-chat'
preLoaderRoute: typeof GenerationsStructuredChatRouteImport
parentRoute: typeof rootRouteImport
}
'/generations/speech': {
id: '/generations/speech'
path: '/generations/speech'
Expand Down Expand Up @@ -405,6 +439,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStructuredOutputRouteImport
parentRoute: typeof rootRouteImport
}
'/api/structured-chat': {
id: '/api/structured-chat'
path: '/api/structured-chat'
fullPath: '/api/structured-chat'
preLoaderRoute: typeof ApiStructuredChatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/image-gen': {
id: '/api/image-gen'
path: '/api/image-gen'
Expand Down Expand Up @@ -462,13 +503,15 @@ const rootRouteChildren: RootRouteChildren = {
ImageGenRoute: ImageGenRoute,
RealtimeRoute: RealtimeRoute,
ApiImageGenRoute: ApiImageGenRoute,
ApiStructuredChatRoute: ApiStructuredChatRoute,
ApiStructuredOutputRoute: ApiStructuredOutputRoute,
ApiSummarizeRoute: ApiSummarizeRoute,
ApiTanchatRoute: ApiTanchatRoute,
ApiTranscribeRoute: ApiTranscribeRoute,
GenerationsAudioRoute: GenerationsAudioRoute,
GenerationsImageRoute: GenerationsImageRoute,
GenerationsSpeechRoute: GenerationsSpeechRoute,
GenerationsStructuredChatRoute: GenerationsStructuredChatRoute,
GenerationsStructuredOutputRoute: GenerationsStructuredOutputRoute,
GenerationsSummarizeRoute: GenerationsSummarizeRoute,
GenerationsTranscriptionRoute: GenerationsTranscriptionRoute,
Expand Down
89 changes: 89 additions & 0 deletions examples/ts-react-chat/src/routes/api.structured-chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createFileRoute } from '@tanstack/react-router'
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
import type { StreamChunk } from '@tanstack/ai'

// Schema shared by every turn in this conversation. The point of this example
// is to demonstrate that *every* assistant message carries its own typed
// structured-output part — old turns don't get blown away by new ones.
//
// Constraints (`min`, `int`, `default`) are intentionally avoided here:
// OpenAI's strict structured outputs reject `integer`, `minimum`/`maximum`,
// `minLength`/`maxLength`, `minItems`/`maxItems`, and `default`. The system
// prompt asks the model to satisfy these informally.
export const RecipeSchema = z.object({
title: z.string().describe('A short title for the recipe'),
cuisine: z.string().describe('Cuisine label, e.g. "Italian", "Mexican"'),
servings: z.number().describe('Number of servings (a positive integer)'),
estimatedCostUsd: z.number().describe('Rough total grocery cost in USD'),
ingredients: z
.array(
z.object({
item: z.string(),
amount: z.string().describe('Quantity with unit, e.g. "200 g"'),
}),
)
.describe('At least one ingredient'),
steps: z.array(z.string()).describe('Numbered cooking steps'),
tips: z.array(z.string()).describe('Optional list of tips; empty if none'),
})

export type Recipe = z.infer<typeof RecipeSchema>

const SYSTEM_PROMPT = `You are a chef assistant that always responds with a single recipe matching the provided JSON schema. When the user asks for modifications, produce a new recipe in the same shape that reflects the change. Stay terse — short titles, short steps.`

export const Route = createFileRoute('/api/structured-chat')({
server: {
handlers: {
POST: async ({ request }) => {
if (request.signal.aborted) {
return new Response(null, { status: 499 })
}

const abortController = new AbortController()
request.signal.addEventListener('abort', () => abortController.abort())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

let params
try {
params = await chatParamsFromRequestBody(await request.json())
} catch (error) {
return new Response(
error instanceof Error ? error.message : 'Bad request',
{ status: 400 },
)
}

try {
// Cast to AsyncIterable<StreamChunk> for the SSE serializer; the
// structured-output stream variant carries the extra `start` /
// `complete` custom events that don't appear in the AGUIEvent
// union but are valid StreamChunks at runtime.
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
systemPrompts: [SYSTEM_PROMPT],
outputSchema: RecipeSchema,
stream: true,
threadId: params.threadId,
runId: params.runId,
abortController,
}) as AsyncIterable<StreamChunk>
return toServerSentEventsResponse(stream, { abortController })
} catch (error) {
const message =
error instanceof Error ? error.message : 'An error occurred'
console.error('[api/structured-chat] Error:', error)
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
}
},
},
},
})
Loading
Loading