Daniele Polencic
Daniele Polencic

Streaming Zod: How Tambo Actually Works

Published in February 2026



Looks like they've hacked Zod to do validation on partial/streaming data. Very clever. by Colin Hacks (@colinhacks), creator of Zod.

Colin tweeted about Tambo, a React toolkit for generative UIs that streams structured data from LLMs into React components. He claims they found a way to use Zod for validating partial, streaming data.

My first thought was: how does that work?

Streaming schema validation seems to require a significant change to how Zod operates.

Maybe it would need something like a SAX-style JSON parser built into the schema?

I looked through the source code and realized there was a way to simplify it.

I also built a minimal demo to show how it works.

The real answer is actually simpler than I expected.

Zod isn't used at all during streaming.

It's only involved at the start, when schemas are converted to JSON Schema using zod-to-json-schema.

After that, Zod isn't needed anymore.

There are two ways streaming happens:

  1. Tool call arguments: String deltas from the LLM are collected and parsed with the partial-json library on every chunk. Then, a function called unstrictify removes null values in OpenAI's structured output mode, using the JSON Schema (not Zod).
  2. Component props: The backend processes the LLM output and sends JSON Patch (RFC 6902) operations to the client using fast-json-patch. The client doesn't parse partial JSON for props.

So, there's no Zod hacking involved.

The so-called "streaming validation" just uses partial-json (which closes open brackets and quotes) and converts schemas to JSON Schema at the start.

A more interesting question: why does Tambo use JSON Patch and a backend for component props when they already use partial-json on the client for tool call arguments?

The backend sits between the LLM and the client, finds completed key/value pairs, and sends clean patch operations.

This avoids the problem of "garbage intermediate keys," where partial-json can give you cut-off keys like {"da": ""} if the stream is mid-key.

But could this logic just run on the client instead?

I think it could.

Using a backend is more of an architectural decision than a technical requirement.

Tambo Cloud uses this setup as part of its revenue model, and the backend is already there for storing conversations and managing agents.

Another question I had was: why use patches at all? Couldn't component props use the same partial-json method as tool call arguments?

It all boils down to this:

Zod schema -> JSON Schema -> send to LLM as tool definition.
LLM streams JSON -> accumulate -> partial-json parse -> pass as props

The tricky part: partial-json can't tell whether a value is still being written or has finished.

For example, with a URL like "https://upload.wiki", it just closes the string, leaving you with a broken URL that would cause a 404 if you tried to render it in an <img>.

TypeStreamable?Why
Long stringsYesEvery prefix is renderable
Short stringsMostly"Ad" is fine to show, it'll grow
URLsNoUseless until complete
NumbersNo12 vs 120 vs 1200 — you can't know
BooleansNotru is not true
EnumsNo"pen" is not "pending"
Array elementsPartiallySettled elements are safe, last one is uncertain

This is the real unsolved problem: it's not about streaming validation, but about knowing when a streaming JSON value is "complete enough" to render.

The Zod schema already shows you how to stream.

The schema has enough type information to automatically figure out a streaming strategy. z.string() is streamable, show partial text as it arrives. z.url() becomes { format: "uri" } in JSON Schema, atomic, wait until the value stabilises. z.number(), z.boolean(), and z.enum() are all atomic. z.array() should emit all-but-last elements, holding back the trailing one which may be mid-stream.

I made a deriveStreamingStrategy(jsonSchema) function that analyzes the converted JSON Schema and generates a strategy map for each field.

Then, a filterProps(partial) function decides what gets sent to the component. Streamable fields pass through immediately. Atomic fields are held back until the value stops changing between consecutive parses, meaning the closing " was seen. Array fields emit all elements except the last, which may be mid-stream.

The render function becomes simple; it just displays whatever it gets.

All the logic lives in the filter, which is based on the schema.

A SAX-style JSON parser would handle this more cleanly.

A SAX parser only triggers events when a JSON value is fully complete, like when a closing quote is seen or a number ends with a comma or bracket.

The filterProps approach is a pragmatic workaround for using partial-json, which isn't a SAX parser.

The ideal solution would be to use a SAX JSON parser, emit completed paths, validate each one against the schema as you go, and then emit a patch.

That last step, validating the schema incrementally for each path, doesn't exist yet and would be a new feature.

oboe.js was the best SAX JSON parser for JavaScript, but it's archived.

jsonriver is the active alternative but lacks Oboe's pattern matching.

I put together a demo with Claude that shows all this in action. It converts a Zod schema to JSON Schema, simulates LLM streaming character-by-character, and runs partial-json on every chunk with a schema-derived strategy. The ProfileCard renders step by step: text streams in, URLs wait until they're complete, and tags show up only when they're ready.

Just zod + zod-to-json-schema + partial-json from esm.sh CDN.

Enjoyed this post?

I write about Kubernetes, TypeScript, software design, and AI. You can get new posts delivered to your inbox or via RSS.