{"slug": "structured-output-that-remembers-across-turns", "title": "Structured Output That Remembers Across Turns", "summary": "TanStack's AI SDK now preserves structured output across chat turns by attaching typed data to each message, eliminating the need for manual state management. The update adds a `structured-output` part type to every `UIMessage`, allowing developers to access validated objects from previous turns without custom history plumbing. This change simplifies multi-turn workflows like recipe refinement and form filling, reducing boilerplate code in applications using the `useChat` hook with `outputSchema`.", "body_md": "*by Alem Tuzlak on May 19, 2026.*\n\nYou ask the LLM for a recipe. It plates up *Spaghetti Pomodoro* — title, cuisine, servings, ingredients, steps, all typed against your schema. Beautiful. You ask it to make the recipe vegan. A new recipe streams in.\n\nThen the first one vanishes.\n\nThat used to be the deal with useChat({ outputSchema }): one hook-level partial / final slot. The instant a new turn started streaming, the previous turn's recipe was clobbered. Multi-turn anything (recipe refinement, ticket triage, iterative form filling) needed manual history plumbing — you'd intercept every chunk, snapshot final into your own state, juggle a recipes[] array yourself, and keep it in sync with messages[] to avoid drift. The schema's type safety also stopped at partial / final; once a structured payload landed in your local array, it was unknown again unless you cast.\n\nWe just shipped a different shape. Every assistant turn now carries its own typed structured-output part on its UIMessage. History is preserved by default. The schema generic threads all the way down to messages[i].parts.find(p => p.type === 'structured-output').data — no casts, no manual tracking. Same useChat hook, same outputSchema option, less code in your component. If you missed the prior post on streaming a single typed object end-to-end, [start here](https://tanstack.com/blog/streaming-structured-output) — that piece is the antecedent to this one.\n\nThis post walks through what changed, why it matters for any multi-turn UI, and how to build the recipe-builder pattern end-to-end in roughly 80 lines (split across a server route and a client component).\n\nThe previous useChat({ outputSchema }) exposed two values that tracked the *current* run:\n\nOn a single-turn extractor (paste a paragraph → get a typed Person), this was perfect. Field-by-field reveal as the JSON streamed, validated payload on terminal event, one render to consume both.\n\nOn a multi-turn chat it fell apart. partial and final were a *single slot*, scoped to whichever run was most recent. As soon as you called sendMessage() again, the previous turn's final was gone. The runtime had no place to keep it — the typed structured payload didn't live on the message itself, only in this transient hook state.\n\nThe workaround we'd see in user code:\n\n```\nconst [recipes, setRecipes] = useState<Array<Recipe>>([])\n\nconst { sendMessage, final } = useChat({\n  outputSchema: RecipeSchema,\n  connection: fetchServerSentEvents('/api/recipes'),\n  onFinish: () => {\n    if (final) setRecipes((prev) => [...prev, final])\n  },\n})\nconst [recipes, setRecipes] = useState<Array<Recipe>>([])\n\nconst { sendMessage, final } = useChat({\n  outputSchema: RecipeSchema,\n  connection: fetchServerSentEvents('/api/recipes'),\n  onFinish: () => {\n    if (final) setRecipes((prev) => [...prev, final])\n  },\n})\n```\n\nThree problems with that:\n\nThe right place for this state is *on the message it came from*. That's what we shipped.\n\nEvery UIMessage has a parts: MessagePart[] array. Before, the variants were text, image, audio, video, document, tool-call, tool-result, thinking. We added one:\n\n```\ntype StructuredOutputPart<TData = unknown> = {\n  type: 'structured-output'\n  status: 'streaming' | 'complete' | 'error'\n  /** Progressive parse — populated while streaming and after complete. */\n  partial?: DeepPartial<TData>\n  /** Validated final object — set when status === 'complete'. */\n  data?: TData\n  /** Accumulating JSON buffer — source of truth for the wire round-trip. */\n  raw: string\n  reasoning?: string\n  errorMessage?: string\n}\ntype StructuredOutputPart<TData = unknown> = {\n  type: 'structured-output'\n  status: 'streaming' | 'complete' | 'error'\n  /** Progressive parse — populated while streaming and after complete. */\n  partial?: DeepPartial<TData>\n  /** Validated final object — set when status === 'complete'. */\n  data?: TData\n  /** Accumulating JSON buffer — source of truth for the wire round-trip. */\n  raw: string\n  reasoning?: string\n  errorMessage?: string\n}\n```\n\nThe runtime routes TEXT_MESSAGE_CONTENT deltas (the streaming JSON bytes) into this part instead of building a TextPart. On the terminal structured-output.complete event, status flips to 'complete' and data is populated with the validated object. Every assistant turn produces a *new* assistant message, which carries its *own* structured-output part. The previous turn's part is untouched.\n\nThe hook-level partial and final still exist, they're derived from the *latest* assistant message's part now, instead of being a sticky slot. That means they read {} and null between sendMessage() and the first chunk (because no new assistant message exists yet), and they snap to the freshest turn's payload as it streams. The migration is zero, the same code that read partial / final for a single-turn extractor reads identical values in the new shape.\n\nWhat changed is everything *else*. Walking messages[] now exposes the full history of typed objects:\n\n``` js\ntype RecipePart = StructuredOutputPart<Recipe>\n\nmessages.map((m) => {\n  if (m.role === 'assistant') {\n    const part = m.parts.find(\n      (p): p is RecipePart => p.type === 'structured-output',\n    )\n    // part.data is Recipe (the schema-inferred type) — no cast.\n    // part.partial is DeepPartial<Recipe>.\n    if (part) return <RecipeCard part={part} />\n  }\n  return null\n})\njs\ntype RecipePart = StructuredOutputPart<Recipe>\n\nmessages.map((m) => {\n  if (m.role === 'assistant') {\n    const part = m.parts.find(\n      (p): p is RecipePart => p.type === 'structured-output',\n    )\n    // part.data is Recipe (the schema-inferred type) — no cast.\n    // part.partial is DeepPartial<Recipe>.\n    if (part) return <RecipeCard part={part} />\n  }\n  return null\n})\n```\n\nThe schema generic flows through useChat<TTools, TSchema> → UIMessage<TTools, TData> → MessagePart<TTools, TData> → StructuredOutputPart<TData>, so part.data resolves to Recipe with no cast. The RecipePart alias is for readability; you can inline Extract<typeof p, { type: 'structured-output' }> instead if you'd rather not name it. The same flow runs in Vue (computed), Solid (createMemo), and Svelte ($derived.by) — the parity work shipped in the same release.\n\nHere's the full recipe-builder pattern. Server endpoint first:\n\n``` js\n// app/api/recipes/route.ts\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai'\nimport { z } from 'zod'\n\nexport const RecipeSchema = z.object({\n  title: z.string(),\n  cuisine: z.string(),\n  servings: z.number(),\n  estimatedCostUsd: z.number(),\n  ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),\n  steps: z.array(z.string()),\n  tips: z.array(z.string()),\n})\n\nexport type Recipe = z.infer<typeof RecipeSchema>\n\nconst SYSTEM = `You are a chef. Reply with a single recipe matching the JSON schema. When the user asks for modifications, produce a new recipe in the same shape that reflects the change.`\n\nexport async function POST(request: Request) {\n  const { messages } = await request.json()\n  const stream = chat({\n    adapter: openaiText('gpt-5.2'),\n    messages,\n    systemPrompts: [SYSTEM],\n    outputSchema: RecipeSchema,\n    stream: true,\n  })\n  return toServerSentEventsResponse(stream)\n}\njs\n// app/api/recipes/route.ts\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai'\nimport { z } from 'zod'\n\nexport const RecipeSchema = z.object({\n  title: z.string(),\n  cuisine: z.string(),\n  servings: z.number(),\n  estimatedCostUsd: z.number(),\n  ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),\n  steps: z.array(z.string()),\n  tips: z.array(z.string()),\n})\n\nexport type Recipe = z.infer<typeof RecipeSchema>\n\nconst SYSTEM = `You are a chef. Reply with a single recipe matching the JSON schema. When the user asks for modifications, produce a new recipe in the same shape that reflects the change.`\n\nexport async function POST(request: Request) {\n  const { messages } = await request.json()\n  const stream = chat({\n    adapter: openaiText('gpt-5.2'),\n    messages,\n    systemPrompts: [SYSTEM],\n    outputSchema: RecipeSchema,\n    stream: true,\n  })\n  return toServerSentEventsResponse(stream)\n}\n```\n\nThat's the whole server side. The only structured-output-specific lines are outputSchema: RecipeSchema and stream: true. The runtime takes care of converting the schema to JSON Schema, hitting the provider's native structured-output API, validating the response, and emitting structured-output.start + structured-output.complete events to the client.\n\nClient:\n\n``` js\nimport { useState } from 'react'\nimport { useChat, fetchServerSentEvents } from '@tanstack/ai-react'\nimport type { StructuredOutputPart } from '@tanstack/ai-client'\nimport { RecipeSchema, type Recipe } from './api/recipes'\n\ntype RecipePart = StructuredOutputPart<Recipe>\n\nexport function RecipeBuilder() {\n  const [input, setInput] = useState('')\n\n  const { messages, sendMessage, isLoading } = useChat({\n    outputSchema: RecipeSchema,\n    connection: fetchServerSentEvents('/api/recipes'),\n  })\n\n  return (\n    <div>\n      {messages.map((m) => {\n        if (m.role === 'user') {\n          const text = m.parts\n            .filter((p) => p.type === 'text')\n            .map((p) => p.content)\n            .join('')\n          return <UserPrompt key={m.id} text={text} />\n        }\n        if (m.role === 'assistant') {\n          const part = m.parts.find(\n            (p): p is RecipePart => p.type === 'structured-output',\n          )\n          if (!part) return null\n          return <RecipeCard key={m.id} part={part} />\n        }\n        return null\n      })}\n\n      <input value={input} onChange={(e) => setInput(e.target.value)} />\n      <button\n        onClick={() => {\n          sendMessage(input)\n          setInput('')\n        }}\n        disabled={isLoading || !input.trim()}\n      >\n        {isLoading ? 'Cooking…' : 'Cook'}\n      </button>\n    </div>\n  )\n}\n\nfunction RecipeCard({ part }: { part: RecipePart }) {\n  // `data` is Recipe (typed by the schema). `partial` is DeepPartial<Recipe>.\n  // Both are typed — no cast, no `unknown`.\n  const recipe = part.data ?? part.partial ?? {}\n  return (\n    <article>\n      <h3>{recipe.title ?? 'Plating up…'}</h3>\n      {recipe.cuisine && <p>{recipe.cuisine}</p>}\n      {recipe.ingredients?.map((ing, i) => (\n        <li key={i}>\n          {ing?.amount} {ing?.item}\n        </li>\n      ))}\n    </article>\n  )\n}\njs\nimport { useState } from 'react'\nimport { useChat, fetchServerSentEvents } from '@tanstack/ai-react'\nimport type { StructuredOutputPart } from '@tanstack/ai-client'\nimport { RecipeSchema, type Recipe } from './api/recipes'\n\ntype RecipePart = StructuredOutputPart<Recipe>\n\nexport function RecipeBuilder() {\n  const [input, setInput] = useState('')\n\n  const { messages, sendMessage, isLoading } = useChat({\n    outputSchema: RecipeSchema,\n    connection: fetchServerSentEvents('/api/recipes'),\n  })\n\n  return (\n    <div>\n      {messages.map((m) => {\n        if (m.role === 'user') {\n          const text = m.parts\n            .filter((p) => p.type === 'text')\n            .map((p) => p.content)\n            .join('')\n          return <UserPrompt key={m.id} text={text} />\n        }\n        if (m.role === 'assistant') {\n          const part = m.parts.find(\n            (p): p is RecipePart => p.type === 'structured-output',\n          )\n          if (!part) return null\n          return <RecipeCard key={m.id} part={part} />\n        }\n        return null\n      })}\n\n      <input value={input} onChange={(e) => setInput(e.target.value)} />\n      <button\n        onClick={() => {\n          sendMessage(input)\n          setInput('')\n        }}\n        disabled={isLoading || !input.trim()}\n      >\n        {isLoading ? 'Cooking…' : 'Cook'}\n      </button>\n    </div>\n  )\n}\n\nfunction RecipeCard({ part }: { part: RecipePart }) {\n  // `data` is Recipe (typed by the schema). `partial` is DeepPartial<Recipe>.\n  // Both are typed — no cast, no `unknown`.\n  const recipe = part.data ?? part.partial ?? {}\n  return (\n    <article>\n      <h3>{recipe.title ?? 'Plating up…'}</h3>\n      {recipe.cuisine && <p>{recipe.cuisine}</p>}\n      {recipe.ingredients?.map((ing, i) => (\n        <li key={i}>\n          {ing?.amount} {ing?.item}\n        </li>\n      ))}\n    </article>\n  )\n}\n```\n\nThat's the entire client side. There's no separate recipes[] state, no onFinish callback, no manual history sync. Each sendMessage() triggers a new run, which produces a new assistant message, which carries its own typed structured-output part. The render loop walks messages and the new card just lands.\n\nTry it:\n\n\"Pasta dinner for two, under $15.\" → recipe lands.\n\n\"Now make it vegan.\" → second recipe lands. The first is still on screen.\n\n\"Add a salad and make it gluten-free.\" → third recipe lands. Both previous turns are still there.\n\nMulti-turn structured chats only work if the model sees its own prior responses on each follow-up turn. Otherwise turn N+1 has no idea what \"make it vegan\" refers to.\n\nThe wire layer handles this. When useChat sends turn N+1, it serializes the conversation back through uiMessageToModelMessages. For each assistant message with a completed structured-output part, the converter emits:\n\n```\n{ role: 'assistant', content: part.raw }\n{ role: 'assistant', content: part.raw }\n```\n\npart.raw is the original JSON the model produced, preserved byte-for-byte from the streaming bytes that built the part in the first place. The model sees its own prior recipe verbatim and can reason about modifications. There's a defensive fallback (JSON.stringify(part.data)) for terminal-only completes that arrived without streamed bytes, plus a \"drop the turn if we can't serialize it\" guard for unserializable data like BigInts or circular refs. Streaming and errored parts are dropped from the round-trip; you don't want to feed an incomplete JSON fragment back to the LLM.\n\nYou don't see any of this. You called sendMessage('now make it vegan') and the model knew what it was modifying.\n\nThe same release shipped parity across the framework hook packages: @tanstack/ai-react, @tanstack/ai-vue, @tanstack/ai-solid, and @tanstack/ai-svelte (which uses createChat instead of useChat). Each one threads the TSchema generic the same way:\n\nNet effect: in any of the four frameworks, with outputSchema: RecipeSchema, messages[i].parts.find(p => p.type === 'structured-output').data resolves to Recipe | undefined. Default TData = unknown keeps every existing consumer that doesn't pass a schema source-compatible.\n\n@tanstack/ai-preact doesn't support outputSchema yet; that's tracked separately.\n\nThe multi-turn pattern lights up any UI where:\n\nFor a single round-trip (one prompt, one typed object), use [chat({ outputSchema })](https://tanstack.com/ai/docs/structured-outputs/one-shot), the non-streaming activity is simpler. For a streaming UI that progressively fills in one object (the classic field-by-field form), use [useChat({ outputSchema })](https://tanstack.com/ai/docs/structured-outputs/streaming) and read partial / final, the new shape preserves that surface unchanged. Use [multi-turn](https://tanstack.com/ai/docs/structured-outputs/multi-turn) when history is a feature, not a chore.\n\nThe full recipe-builder UI ships in the [ts-react-chat example](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) at /generations/structured-chat: cuisine-aware hero banners, streaming preview, multi-turn history, the lot. The docs walk through the pattern at [structured-outputs/multi-turn](https://tanstack.com/ai/docs/structured-outputs/multi-turn).\n\nIf you already use useChat({ outputSchema }), you don't need to change anything to get the new types — partial and final keep working. You opt into multi-turn the moment you walk messages instead of just reading the hook-level sugar.", "url": "https://wpnews.pro/news/structured-output-that-remembers-across-turns", "canonical_source": "https://tanstack.com/blog/multi-turn-structured-output", "published_at": "2026-05-19 12:00:00+00:00", "updated_at": "2026-05-27 08:07:03.237464+00:00", "lang": "en", "topics": ["large-language-models", "generative-ai", "ai-tools", "ai-products"], "entities": ["Alem Tuzlak", "TanStack", "useChat", "UIMessage", "Spaghetti Pomodoro"], "alternates": {"html": "https://wpnews.pro/news/structured-output-that-remembers-across-turns", "markdown": "https://wpnews.pro/news/structured-output-that-remembers-across-turns.md", "text": "https://wpnews.pro/news/structured-output-that-remembers-across-turns.txt", "jsonld": "https://wpnews.pro/news/structured-output-that-remembers-across-turns.jsonld"}}