Streaming structure, not text
Streaming a schema-shaped object as its fields fill in — deep-partial, not yet validated — rather than a blob of prose you parse afterwards, is what turns a token stream into a UI that can render a flight card before the sentence about it finishes, provided you write your own guard for the fields that haven’t finished arriving.
The flight chatbot has always answered in prose, and the previous lesson asked you to hold a price until it is complete. Try to implement that against prose and you find yourself writing a regular expression that looks for a number followed by the word “points” in a string that is still growing, so that you can suppress it until you decide it has stopped growing. That is not a rendering strategy. It is a parser for a language nobody defined, running on every chunk, guessing.
The alternative is to stop asking the model for a paragraph about a flight and start asking it for a flight. Same model, same call, one extra argument: a schema. What comes back is an object whose fields arrive one at a time, which means “hold the price field” is a condition on a field rather than a guess about a substring.
The API you will find in tutorials is deprecated
Nearly every article on this subject reaches for streamObject and its partialObjectStream. Vercel — documenting its own SDK, and its own breaking change — says plainly that this is over:
“generateObjectandstreamObjecthave been deprecated (PR #10754). They will be removed in a future version.”
The guide directs you to “generateText and streamText with an output setting instead”. Structured output stopped being its own function and became a setting on the ordinary text call. The current shape:
import { streamText, Output } from 'ai'
import { z } from 'zod'
const { partialOutputStream } = streamText({
model: 'xai/grok-4.6',
output: Output.object({
schema: z.object({
flight: z.object({
airline: z.string(),
price: z.number(),
stops: z.number()
})
})
}),
prompt: 'Recommend a flight.'
})
for await (const partial of partialOutputStream) {
render(partial.flight)
}Note where the old reference page for this API now lives: the URL slug still says stream-object, and what renders there is documentation for Output.object(). The page was folded into its own replacement. That is a small thing and it is the whole reason this course keeps telling you to prefer the wire to the wrapper — the wrapper moved twice while the SSE framing under it did not move at all.
The partial object is not validated
This is the correction that matters, and it inverts the thing most people assume they are buying. Passing a schema does not mean the streaming object conforms to it. Vercel’s own structured-data documentation:
“Complete output is fully validated against the schema. Partial output (during streaming) is a deep partial version of the schema type.”
And, on the same subject, without the euphemism:
“Partial outputs streamed via streamText cannot be validated against your provided schema, as incomplete data may not yet conform to the expected structure.”The predecessor API said the same about its own partial stream: values are “typed with a deep partial type, but not validated,” and if you want certainty that the content matches your schema “you need to implement your own validation for partial results, e.g. using Zod”. Two API generations, one unchanged contract. The schema buys you a request shape and a final check. It does not buy you a guarantee about anything on screen before the end.
Under it, this is the tool-call mechanism you already read
None of this is new machinery at the wire level. Anthropic’s streaming documentation — vendor documenting its own API — describes structured tool input arriving as input_json_delta events carrying “partial JSON strings,” accumulated until content_block_stop, where “the final tool_use.input is always an object”. Structured output is that same accumulation, pointed at your schema instead of a tool’s. Which tells you where the boundary is without reading any SDK source: the object is only guaranteed to be an object at the stop event. Everything before it is a partially-parsed JSON string that somebody has generously typed for you.
The same page also warns that models emit “one complete key and value property from input at a time”, with delays possible between events. Field-at-a-time arrival is what makes per-field disclosure decisions implementable at all — and the documented pause is why a card that renders nothing between fields looks hung.
Write the guard the SDK does not
Take the disclosure table from the previous lesson and turn it into a function. Not a schema re-validation on every chunk, which is both expensive and wrong-headed — the partial is supposed to fail the schema. A per-field arrival rule:
type FieldState<T> =
| { status: 'absent' }
| { status: 'arriving'; value: T }
| { status: 'settled'; value: T }
// A field is 'settled' only when a later field exists, or the stream
// finished. Anthropic emits one complete key/value at a time, so the
// presence of the NEXT key is the strongest in-band evidence that the
// previous one stopped growing. This is an inference, not a promise.
function priceState(p: DeepPartial<Flight>, done: boolean): FieldState<number> {
if (p.price === undefined) return { status: 'absent' }
if (done || p.stops !== undefined) return { status: 'settled', value: p.price }
return { status: 'arriving', value: p.price }
}Read the comment in that snippet twice, because it is doing the honest work. Nothing in the SDK tells you a field is finished. You are inferring it from field order, which means the inference is only as good as the order you asked for — and field order in the schema is therefore a UI decision, not a data-modelling one. Put the fields you will hold early, followed by a field you do not mind waiting on, and the arrival of the cheap field is your settle signal for the expensive one.
Where people get burned
Two traps sit close together here.
The first is trusting the type. A deep-partial type makes every field optional, which reads like safety and is not. The compiler will not stop you rendering { price: 6 } into a card, because that object is valid. If your review process is “it type-checks,” this ships.
The second is re-validating on every chunk. Running your Zod schema against each partial does not give you a settled object; it gives you a stream of failures, because incomplete data is expected not to conform. Teams that try it usually respond by making the schema permissive — every field optional, numbers coerced — at which point the final validation, the one the SDK actually does perform, is checking nothing.
What you get in exchange
The reason to do all of this is that a schema-shaped stream lets the answer become components. A flight card can render its airline and routing while the price is still marked as arriving. A comparison of three options can draw the second row the moment the second array element appears. The previous lesson’s hold and mark decisions stop being aspirations and become branches on a field’s state.
And the prose does not go away. A good answer here is a card plus a sentence explaining the trade, so your schema usually carries a rationale: z.string() alongside the typed fields. That string is the one field you stream freely, for the reason the previous lesson gave: a cut-off sentence looks cut off.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your card renders airline, price and stops from the partial object. The stream dies after the airline arrives. What is on screen, and what is wrong with it?
Check your answer
On screen: an airline name, possibly truncated, and two empty regions. The type system saw price: undefined and stops: undefined and you rendered the absent branch, so nothing crashed.
What is wrong is that “absent because it has not arrived yet” and “absent because the stream died” render identically, and the deep-partial type cannot tell them apart — both are undefined. The user sees a card that is still loading, forever. Field state is not enough; you need the stream’s own terminal state alongside it, which is why the control module treats an ended stream as an event to render rather than a condition to infer from silence.
Hands on
Turn one prose answer into a streamed object
Done when: The flight chatbot returns at least one recommendation as a schema-shaped object streamed field by field, the card renders per-field states rather than waiting for the whole object, and a held field from your disclosure table is provably held under a throttled connection.
- Write the schema for one recommendation. Keep it small — airline, cabin, routing, points cost, cash component, a
rationalestring. Order the fields so that every field you decided to hold is followed by a cheap one, because that following field is your settle signal. - Switch the call to
streamTextwithoutput: Output.object({ schema })and consumepartialOutputStream. If you find yourself typingstreamObject, you are reading a tutorial written against a deprecated API. - Write the
FieldStatehelper. Three states, not two. A field that is arriving must be distinguishable from a field that is absent, in the data, before you get anywhere near a component. - Render the card from field states. Airline and routing may show while arriving; the points cost renders its held or marked treatment until settled, per the row you wrote in the previous lesson.
- Throttle the connection hard and screenshot the card three times mid-stream. The pass condition is that no screenshot contains a points cost the finished card contradicts. If one does, your settle signal is wrong, not your component.
- Record in
ARTIFACT.md: the schema, the field order and why it is that order, and what you used as the settle signal for each held field. Bring the schema into the chat. I will look for a field order chosen for data-modelling tidiness rather than for the UI it has to drive.
What this does not cover
Structured output handles the parts of an answer that have a shape. It does nothing for the rationale string, which is markdown, arrives character by character, and spends most of its life structurally invalid — an unclosed emphasis, a half-written table, a fence with no closing backticks. That is the next lesson, on markdown that is still arriving, and it is the reason a card and its explanation need different rendering strategies inside the same answer.
A held field also leaves a hole, and this lesson said nothing about what goes in it. The honest-skeleton lesson takes that on directly, including the case this lesson creates: a schema with three optional recommendation slots does not entitle you to draw three placeholder cards, because the answer may contain one.
What a card costs to re-render as each field lands — memoization boundaries, whether the update fits an interaction budget, how to measure it — belongs to Front-end performance under streaming load. That course owns the render cost of exactly this pattern. This one owns the decision of what is in the object and when it may be shown.
Read this next — primary source
Migration Guide: Migrate AI SDK 5.x to 6.0Vercel — vendor documenting its own product, and documenting a breaking change to it. Free. Fetched 2026-09-05
This lesson takes one fact from it and one habit. The fact is that the API most tutorials still teach for streaming structured output is deprecated and scheduled for removal, which is the difference between a lesson that ships current and one that ships stale. The habit is the reason to read a migration guide end to end rather than grepping it: it is the only document that tells you which of your knowledge has an expiry date. Read it, then notice how much of it is renaming rather than new capability — that is the argument for adapting at the edge.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.