Structured output, streamed.
Solid is a JSON streaming library. A model that streams writes its JSON a few characters at a time, and half of it is not JSON. Solid makes structured output work with streaming: at every moment it holds a whole, working object of the schema you asked for.
pnpm add @niscorp/solid zod · 0.1.2 · Apache-2.0
const Suggestion = z.object({
show: z.string(),
night: z.string(),
seats: z.array(z.string()),
price_cents: z.number(),
why: z.string(),
});
const stream = createStream({ schema: Suggestion });
stream.on((suggestion) => draw(suggestion));
for await (const chunk of reply) stream.write(chunk);{
"show": "",
"night": "",
"seats": [],
"price_cents": 0,
"why": ""
}JSON.parse throws on this text.
The reply is text written for this page, and no model is called. The object is what the installed Solid holds.
First steps →A schema, the data, and callbacks.
Whoever reads the stream holds an object of the schema at every moment, so it can use what has arrived without waiting for the rest. That is what makes streaming possible with structured output.
createStream({ schema })stream.write(chunk)stream.onstream.select(path).onFinalstream.onErrorcreateStreamwrite(chunk)stream.onstream.select(path).onFinalstream.onErrorA part is finished before the reply is.
A reply is written from its first key to its last. So the first parts are finished long before the reply is, and Solid says so for each one. What reads the stream can act on a finished part while the rest is still being written.
How far the reply above had come when each part was finished
stream.select('seats').onFinal((seats) => {
showSeats(seats);
});A part is a key of your schema, or a path into it such as seats.0. Until it is finished its value may be half there: a string as far as it is written, and 7600 as 76.
A screen, drawn while it is written.
In Nisc a screen is JSON too: a list of nodes. So a screen can be streamed like any other structured output. Here Solid reads the JSON of a screen as it arrives, and Nova draws each node when Solid says the node’s component is finished, while the nodes after it are still being written.
The screen, drawn from it
The stream is text written for this page. The first screen of this site is streamed the same way.
With Cortex, an answer streams.
Cortex reads a model’s reply as a stream, feeds it to Solid as it arrives, and sends your app the answer so far as output-partial events. So an app built on Cortex can show an answer while it is written, and has no stream code of its own. You call Solid yourself when you read a model’s reply without Cortex. Cortex needs a schema Solid can start from, so for a recursive one, such as a whole Nova layout, it sends the answer at the end.
A value of the wrong kind is skipped.
Solid knows the kind of a value from its first character: a string, a number, a list, an object. If it is not the kind your schema has there, Solid skips the value and keeps what it had. The rest of the reply is read as usual. A key your schema does not have is skipped the same way, and onError names the path.
{"show":"The Tempest","night":"2026-10-24","seats":"C4 and C5","price_cents":7600,"why":"Two seats together."}{
"show": "The Tempest",
"night": "2026-10-24",
"seats": [],
"price_cents": 7600,
"why": "Two seats together."
}What onError is given
The seats are still the empty list the stream started with.
What Solid checks
close() calls onFinal too. So set a flag in onFinal and read it before you call close(): if it is not set, the reply stopped early. What the stream holds then is defaults and half values, and it can still pass your schema.
What an engineer asks.
z.any()?NoA string or number there is read. An object or list comes out empty, as it does under z.unknown() and a lazy schema. Spell the shape out.===.initial, and you must when an empty value would not pass your schema. The reply is written over it key by key, and a list keeps the items the reply did not reach.close() ends it with what there is. destroy() drops every listener.