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:
- Tool call arguments: String deltas from the LLM are collected and parsed with the
partial-jsonlibrary on every chunk. Then, a function calledunstrictifyremoves null values in OpenAI's structured output mode, using the JSON Schema (not Zod). - 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 propsThe 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>.
| Type | Streamable? | Why |
|---|---|---|
| Long strings | Yes | Every prefix is renderable |
| Short strings | Mostly | "Ad" is fine to show, it'll grow |
| URLs | No | Useless until complete |
| Numbers | No | 12 vs 120 vs 1200 — you can't know |
| Booleans | No | tru is not true |
| Enums | No | "pen" is not "pending" |
| Array elements | Partially | Settled 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.
