Solid · streaming JSON

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

A stream
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);
The reply so far
What the stream holds
{
  "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 →
How it works

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.

A schemaYou give Solid the Zod schema you asked the model for. From it Solid builds a whole object before any text has arrived.
The dataEach chunk of the reply is written in as it arrives. The object fills in, and at every moment it is an object of your schema’s shape.
CallbacksOne tells you the object changed. One tells you a part of it is finished. One tells you a value was skipped, and where.
Parts

A 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

show30 characters
night51 characters
seats77 characters
price_cents88 characters
why135 characters
One part of a stream
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.

An example

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 stream

The screen, drawn from it

The stream is text written for this page. The first screen of this site is streamed the same way.

In a Nisc app

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 wrong value

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.

A reply with a sentence where the list belongs
{"show":"The Tempest","night":"2026-10-24","seats":"C4 and C5","price_cents":7600,"why":"Two seats together."}
What the stream holds at the end
{
  "show": "The Tempest",
  "night": "2026-10-24",
  "seats": [],
  "price_cents": 7600,
  "why": "Two seats together."
}

What onError is given

[solid] seats: expected array, got string

The seats are still the empty list the stream started with.

What Solid checks

By defaultThe kind of each value, and that its key is in your schema.
When you askSet the constraints option to finalize, and the rules of your schema, such as a pattern or a maximum, are checked when a value is finished. A failure is reported and the value stays.
ModesThe default mode, recover, skips and reads on. In strict mode the first wrong value or unknown key ends the stream. In trust mode nothing in the reply is checked.

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.

Asked of Solid

What an engineer asks.

Does it need a model?NoIt reads text, from wherever you get it.
Does it read the text again as more arrives?NoEach character is read once, and no copy of the text is kept.
Does broken JSON break it?NoWhatever arrives, the stream holds an object of your schema. If nothing in a reply can be read, that is the initial value you gave it.
Does it turn "3" into 3?NoA string where a number belongs is a value of the wrong kind, and is skipped.
Does it read an object under 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.
Can I read a list while it grows?YesA string item is there from its first letter. An object item starts empty and gains its keys as they are read.
Can I tell what did not change?YesA part that did not change is the same object as before. Compare with ===.
Can I start from an object of my own?YesPass it as 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.
Can I stop reading?Yesclose() ends it with what there is. destroy() drops every listener.