~/satyajit

Generative UI in two model calls

mdjsonmcp

2026-09-19 · 18 min · explainer · llm · architecture · agents · systems

json-render is Vercel Labs' generative-UI framework: you define a catalog of components, an LLM streams a JSON spec, your registry renders it. Version 0.21.0, shipped yesterday, adds two functions that change which half of that sentence the model is doing. The announcement:

The interesting word is instant, and the useful question is what it is instant relative to — but the architectural claim underneath it is the better story, and it is the same one this site has been chasing all week in forms and benchmarks: a model that only ever chooses is a different kind of component than a model that writes.

I cloned the repo, built the core package, and ran experimental_composeSpec against the playground's real catalog with an evaluator that records every byte it is handed. Everything below that is labelled measured came out of that harness.

What the two functions actually are

experimental_composeSpec (680 lines, plus 288 for the batched path and 185 of tree helpers) is an async generator. You hand it a catalog, an array of candidates, a prompt and an evaluate function; it yields step events carrying a full spec snapshot, then a complete event with stopReason: "finish" | "limit" | "unavailable".

Three declarations in one file carry the whole design. The repo at the v0.21.0 release commit 3ad3818, packages/core/src/experimental-compose.ts:

// experimental-compose.ts — in source order, with the rest elided
export interface Experimental_ChoiceQuestion {
  type: "choice";
  instructions: string;
  criteria: Record<string, string>;
}
// …
/** Custom adapters must return one of each question's offered criteria keys. */
export type Experimental_CompositionEvaluator = (request: {
  state: Record<string, unknown>;
  questions: Record<string, Experimental_ChoiceQuestion>;
  signal: AbortSignal;
}) => Promise<Experimental_CompositionEvaluation>;
// …
export async function* experimental_composeSpec(
  options: Experimental_ComposeSpecOptions,
): AsyncGenerator<Experimental_CompositionEvent> {
  // 680 lines. Delegates to composeBatch() unless you pass initialSpec.
}

criteria is the option set: a map from a key the model may return to the English sentence explaining what returning it means. Nothing else crosses the boundary. A model is not a dependency here — it is a function type, and the composer never imports one.

A candidate is not a component. It is a fully configured element — type, concrete props, state bindings, action bindings — that your application wrote. Here is one of the playground's 42, written through its own add() helper:

// apps/web/lib/jev/grammar.ts — one call to the playground's own add() helper
add(
  "save",
  "Button: Save changes. Bind press to setState to update the visible saved-status text. Local demo only.",
  "Button",
  { label: "Save changes", variant: "primary", disabled: false },
  "action:save",                        // resource: sharers are mutually exclusive
  {
    press: {
      action: "setState",
      params: { statePath: "/status", value: "Changes saved locally." },
    },
  },
);

The model never sees that object. It sees "save" and the description. When it answers "use:save", the composer structuredClones the recipe into the spec itself. Every prop in every candidate is validated against the catalog's Zod schema before the first model call, in validateCandidate, because the props were never the model's to choose.

experimental_createEvaluator is 112 lines and does much less than its name suggests. It is a transport adapter: it POSTs { state, questions } to https://ai-gateway.vercel.sh/v4/ai/evaluation-model with an ai-model-id header, parses the response with a small Zod schema, checks each returned choice is a key of the criteria that were offered, and lifts per-question confidence out of providerMetadata.typesafe.confidence. It does not score candidates, rank compositions, retry, or estimate cost — the docs say so in as many words. The model-neutrality is real: model is a plain string, and typesafe-ai/jev is described as "the current tested example" rather than a requirement. packages/core/src/experimental-evaluator.ts, same commit, is the entire round trip:

// experimental-evaluator.ts — argument checks and abort plumbing elided
return async ({ state, questions, signal }) => {
  // …
  const response = await fetch(
    "https://ai-gateway.vercel.sh/v4/ai/evaluation-model",
    {
      method: "POST",
      headers: { /* auth, protocol version, … */ "ai-model-id": model },
      body: JSON.stringify({ state, questions }),
      signal: controller.signal,
      cache: "no-store",
    },
  );
  if (!response.ok)
    throw new Error(`Evaluation request failed (HTTP ${response.status}).`);
  // … Zod-parse the body, then, for each question asked:
  if (!answer || !Object.hasOwn(question.criteria, answer.choice))
    throw new Error("Evaluator returned a choice outside the offered criteria.");
  // …
};

JSON.stringify({ state, questions }) is the request. There is no prompt template, no retry, no system message, no streaming. The model identifier is an HTTP header.

Vercel's Gateway lists Jev's maximum output tokens as 0. That is the whole trick in one field.

A slot is not a question

The part I most wanted to know: how does a UI become a decision problem? Not one Choice per slot. The default batched strategy asks two rounds:

Round one — membership. One root question ("which element is outermost", plus an unavailable escape), and then one question per candidate group. Candidates that share a resource string are mutually exclusive and share one question — the three avatar sizes are omit / lg / md / sm, the four submit-button labels are omit plus four. Reusable layout recipes with maxUses > 1 get a cardinality question instead: how many of these does the request need, 0 through 14.

Round two — layout. For each non-root element that got selected: parent_<id> (which container and which named slot, as keys like node_0:default) and order_<id> (position among siblings). Slots are options inside the parent question, not questions of their own.

Between the rounds the composer assembles a preview in catalog order and streams it, so something renders after the first call. If only one element was selected — or two with a single slot between them — the second call is skipped entirely. There is no separate "are we done" call: batching knows the membership answers, so finishing is implied.

Follow-up edits drop back to the sequential protocol: a next question whose criteria include replace:<id>, remove:<id>, move:<id> for every element in the existing tree, where a replace or a move takes a second call to pick the recipe or the destination.

the usual shapeThe model writes the UIjson-render + jevThe model picks the UIWHAT COMES BACKWHAT COMES BACK{"root":"a","elements":{"a"…one token at a time, until it stops{ root: "card", select_12: "use:input_name", … }50 keys, one forward pass, 0 output tokensTHE SPACE IT CAME FROMTHE SPACE IT CAME FROMevery string the tokenizer can emit— unbounded, and it runs off this page161 options across 37 questions — every onea key your code wrote. Hard stops at both ends.AN ELEMENT YOUR CATALOG DOES NOT CONTAINAN ELEMENT YOUR CATALOG DOES NOT CONTAIN"n7": { "type": "Timeline", "props": { … } }emitted. It is a legal string, so nothing stopped it.spec-validator.ts — 536 lines13 error codes, 6 silent repairs, lossy pruningof children that point at nothingTimelinehas no key on the strip above.not emitted, not rejected — not expressiblethe composer copies your recipe into the spec.The model never touches a prop.
Both panels describe the same library. The left is how json-render has always worked — an LLM streams a spec and a validator repairs it. The right is experimental_composeSpec: 161 options across 37 questions is the real first call for the playground’s 42 candidate recipes, measured by running the composer against a recording evaluator.

The denominator

Here is the arithmetic the announcement skips, and it is better than I expected.

The worry with decision-model UI is obvious: if a screen has a dozen slots and each slot is a round trip at 200–300 ms, "instant" is doing a lot of work. That is not what happens. I ran the docs' own suggested dashboard prompt — "Generate a sales dashboard with an orders table at the top, then revenue, orders and new customers metrics in a row, then a weekly revenue chart" — through the composer with a recording evaluator. The result is eight elements: a Stack root, a Grid, a Heading, a Table, three Metrics and a BarGraph.

Two calls. 37 questions and 161 options in the first, 13 questions and 61 options in the second. The round-trip count does not depend on how many elements you end up with; it depends on nothing at all. It is two.

receiptscaptured 2026-09-19

Composing a whole screen with json-render's experimental Jev composer costs two evaluation calls, not one per component. The default batched strategy asks every membership question in a first call and every placement question in a second, so the round-trip count is independent of how many elements end up on screen. The same eight-element result built one operation at a time costs nine calls and 42% more bytes. Follow-up edits are always sequential: a removal is two calls, a move is three.

what was composedstrategycallsquestionsoptionsrequest bytes
Sales dashboard — 8 elementsbatch (default)25022237,806
Sales dashboard — 8 elementssequential91434450,341
Account settings — 8 elementsbatch (default)25022238,561
edit: remove the notifications switchsequential (forced)2412121,278
edit: move the email field above the name fieldsequential (forced)3513026,127

Options counts include every key offered across every question in the call, including the always-present 'unavailable' and 'omit' escapes. The sequential dashboard run reaches the same eight elements as the batched one; its ninth call is the separate 'finish' decision that batching does not need. Byte counts are request bodies, not tokens; the token estimates in the article divide by four and are labelled as estimates.

method Cloned vercel-labs/json-render at 3ad38188 (v0.21.0), installed the workspace, built @json-render/core, and ran experimental_composeSpec through the playground's own wrapper (apps/web/lib/jev/compose.ts) with its real catalog (17 component types) and its real candidate grammar (42 recipes). The evaluator is a stub that records the exact { state, questions } payload it is handed and then returns a scripted set of choices that builds the named result — so the call counts, question counts, option counts and byte counts are the composer's, not mine. Prompts are the two the docs themselves suggest. Bytes are JSON.stringify length of the request body the Gateway adapter would POST.
data /articles/generative-ui-by-decision/data/call-budget.json (5 rows, 3.1 KB)
01 s2 s3 sONE DASHBOARD — ROOT + GRID + HEADING + TABLE + 3 METRICS + CHARTbatch37 questions, then 13494 ms · 2 callssequential8 elements + 1 finish2.22 s · 9 callsedit: movetarget, then destination741 ms · 3 calls
per call247 ms
Call counts are measured, not modelled. Latency is not: the three presets are the only published Jev round trips I know of, and all of them were measured on requests of a few hundred tokens with 8–14 questions. The batched select call here carries 37 questions and 161 options in roughly 7,100 tokens, which nobody has timed.

Multiply it out and "milliseconds" holds up. At TypeSafe's own cookbook round trip of 111–114 ms, two calls is ~225 ms. At the 246.7 ms Bespoke measured, ~495 ms. At the 338.6 ms an independent benchmark measured remote, ~680 ms. Against a generating model streaming a spec — seconds, on every published comparison — that is a real gap, and the shape of the gap is structural rather than a tuning win.

Two honest dents in it. First, none of those published latencies was measured on a request like this one: they are 8- and 14-question rubrics of a few hundred tokens, and the select call here carries 37 questions and 161 options in roughly 7,100. Jev scores options independently, which is exactly why it is unable to answer a question where one option refers to another — so 161 options is 161 scored items, and whether that stays flat is a claim nobody has measured in public. Second, the numbers above are the model only. They do not include your server, the Gateway hop, or the render.

Second-order but worth having: the same eight-element result composed one operation at a time is nine calls and 33% more bytes, and every follow-up edit is sequential by design — a removal is two calls, a move is three. So the instant path is the first render. Iteration costs what iteration always cost.

Where the milliseconds went

Batching does not make the work disappear; it moves it inside one request. The playground offers 42 candidate recipes, and here is what the first call is actually made of:

receiptscaptured 2026-09-19

The batched select call is 28,594 bytes — roughly 7,100 input tokens — for a catalog of 42 candidate recipes. Only a third of it is the option keys themselves. The single largest line item is one 271-byte instruction string repeated verbatim across 30 membership questions: 8,130 bytes, 28.4% of the request, sent again on every request.

part of the requestbytesshare
state.capabilities — 42 { id, description }5,43719.0%
state.context — app-supplied platform blurb5151.8%
state.guidance — the 'next' instruction4361.5%
state — user_request and JSON overhead810.3%
questions.instructions — 37 strings, 8 distinct10,11135.4%
↳ of which: one 271-byte string × 30 questions8,13028.4%
questions.criteria — the 161 offered keys9,78534.2%
questions — JSON keys and structure2,2067.7%
envelope230.1%
whole body28,594100%

state.capabilities is the id and description of all 42 candidates; the same descriptions appear a second time as criteria values in the membership questions, so every description is sent twice per call. TypeSafe documents a 64k-token per-request cap and a separate 32k cap on 'state plus the longest question'; Vercel's AI Gateway lists Jev's context window as 32,000. This call sits well inside all three.

method Captured the first { state, questions } payload handed to the evaluator when composeUI('Create account settings') runs against the playground's real catalog and grammar, then measured JSON.stringify length of each part. 'distinct instruction strings' counts unique values of question.instructions across the 37 questions in the call.
data /articles/generative-ui-by-decision/data/select-anatomy.json (10 rows, 2.3 KB)

Two things stand out. The option keys the model chooses between are barely a third of the payload. And the single largest line item is one 271-byte instruction string — "Which of these elements does user_request need? …" — repeated verbatim across 30 membership questions: 8,130 bytes, 28.4% of the request, re-sent on every request. There are eight distinct instruction strings across 37 questions. Whatever the Gateway's evaluation protocol does with that redundancy, the client is paying to serialize it.

The consequence is that the request grows with your catalog, not with your screen. Every candidate is sent twice per call — once in state.capabilities, once as a criteria value — and the whole thing has to fit in one context window. I measured the slope with synthetic catalogs from 42 to 801 recipes: 668.6 bytes per candidate, dead linear.

040k80k120k160k0200400600800INPUT TOKENS IN ONE BATCHED SELECT CALLcandidate recipes offered32k — Vercel AI Gateway's listed context window19564k — TypeSafe's own documented per-request cap391dots = measured · line = fit, 668.6 bytes per candidate · tokens estimated at 4 bytes each
candidates196
questions
197
~input tokens
32,786
of Gateway’s 32k
100.1%
cost, this call
$0.001377
Each candidate here carries a ~120-character description, which is what the playground’s own recipes look like; shorter descriptions push the ceiling out proportionally. The point is the slope, not the exact crossing: batching buys you a constant number of round trips by paying for the whole catalog on every one of them.

Which puts a ceiling on the design system you can offer in one shot. TypeSafe documents a 64k per-request cap; Vercel's Gateway model page lists Jev's context window as 32,000. Take the smaller one and a batched call fits on the order of 200 configured recipes at the playground's description length. That is plenty for a settings screen and not obviously enough for "your design system" in the sense a design-system owner means it — especially since the docs actively suggest building candidates from live records per request, which is exactly the thing that makes the catalog large.

Independent decisions can contradict each other

There is a cost to answering thirty-seven questions at once, and it is not the one people expect.

The independence is visible at the call site. Every parent_ and order_ question is built in one forEach over the selected elements and then handed to a single call, so the questions are serialized into the same request body before any of them is scored. packages/core/src/experimental-composition-batch.ts:

// experimental-composition-batch.ts — the layout pass, criteria bodies elided
// All IDs now exist, so ask their placements and sibling order together.
// Assemble on a private clone, and validate the *whole* tree before publishing:
// individually offered parents can still form a cycle or exceed maxDepth.
const layout: Record<string, Experimental_ChoiceQuestion> = {};
selected.slice(1).forEach((candidate, index) => {
  const id = `node_${index + 1}`;
  const parents = new Map(
    [...destinations].filter(([, parent]) => parent.id !== id),
  );
  placements.set(id, parents);
  if (parents.size > 1)
    layout[`parent_${id}`] = { type: "choice", /* …every other slot… */ };
  layout[`order_${id}`] = { type: "choice", /* …position among siblings… */ };
});
const arranged = await call("layout", { /* … */ }, layout);

The filter is the whole story. A node is kept out of its own parent list — parent.id !== id — and nothing else is excluded, because at the moment the questions are written nothing has an ancestor yet. So parent_node_1 may legally answer node_2:default while parent_node_2 answers node_1:default. Individually both are keys that were offered. Jointly they are a cycle.

AFTER CALL 1 — MEMBERSHIP IS DECIDED, PLACEMENT IS NOTnode_0Card (root) · defaultnode_1Stack · defaultnode_2Grid · defaultnode_3Heading · no slotdestinations = every slot of every selected container = node_0:default, node_1:default, node_2:defaultCALL 2 — ONE REQUEST, ONE QUESTION PER ELEMENTparents = destinations.filter(([, parent]) => parent.id !== id)the only exclusion is the node itself — an ancestor cannot be excluded, because nothing has an ancestor yetparent_node_1Choose the final parent and slot for node_1 (Stack).node_0:defaultnode_2:defaultchosenparent_node_2Choose the final parent and slot for node_2 (Grid).node_0:defaultnode_1:defaultchosenscored independentlyASSEMBLED ON A PRIVATE CLONE, THEN VALIDATED AS A WHOLEnode_0node_3reachable from the root: 2 of 4node_1node_2each is the other’s parentvalidate(next) throws“Spec contains unreachableelements.”both answers were keys the composer offeredyou keep the call-1 preview, in catalog order, labelled partial
The bounded-output property covers which components, props and actions — those are copied from your objects. It does not cover tree shape, because tree shape is the joint consequence of answers taken in parallel. Destination keys, the self-only filter and the thrown message are from experimental-composition-batch.ts and experimental-composition-tree.ts at 0.21.0.

The composer assembles the layout on a private clone and validates the whole tree before publishing it, so the cycle never reaches your renderer — but the request then throws from indexTree with "Spec contains unreachable elements.", and what you are left with is the first preview in catalog order, labelled partial. Same for a combined layout that exceeds maxDepth. The repo is honest about it in the place that counts: a test named "preserves the first valid preview when independently chosen parents form a cycle", whose scripted answers are literally parent_node_1: "node_2:default" and parent_node_2: "node_1:default" — the pair drawn above — asserting rejects.toThrow(/unreachable|cycle/) and exactly one surviving event. The source comment says the rest plainly: "individually offered parents can still form a cycle or exceed maxDepth."

So the bounded-output property is narrower than "the model cannot produce anything wrong". It bounds which components, which props, which actions — completely, by construction, because those are copied from your objects. It does not bound tree shape, because tree shape is the joint consequence of answers taken in parallel. That is a real and unavoidable trade for the round-trip count, and it is the one thing the sequential strategy buys you back.

The model never sees your data — except where you typed it in

state.capabilities is { id, description } and nothing else. No props, no bindings, no state values. The docs are explicit: "Initial state, raw props, and binding values are not sent automatically. Put the information needed to choose candidates in their descriptions." For an edit, the playground shares display labels only, and never entered form values.

That is a genuinely good privacy property and it has a consequence people will trip over. The model cannot rank your metrics by which moved most, because it cannot see them. What it can do is read whatever you wrote in the description — and so the playground's grammar does this:

add("revenue",
  "Metric: total sales revenue, $48,250, up 12.8%. Synthetic platform data.",
  "Metric", { label: "Revenue", value: "48,250", change: "+12.8%", ... })

The number reaches the model because a human typed it twice — once into the props the renderer reads, once into an English sentence the model reads. Which is the honest shape of the whole feature: buildCandidates is 322 lines of hand-authored recipes for 17 component types, and the docs say straight out that "a catalog alone is not enough for Jev: open-ended string props and data still need values." "Your components, your actions, your design system" is accurate. It is also work — this is not a compiler that reads your component types and derives an option set. You enumerate configured instances.

Confidence is produced, and nothing reads it

Every answer can carry a confidence in [0, 1]. The adapter pulls it out of TypeSafe's provider metadata. The composer range-checks it and throws on NaN. It lands on step.confidence, step.parentConfidence and step.answers[q].confidence, gets serialized into the playground's NDJSON stream, and is displayed as raw text in a debug tab.

Four files, in order, are its entire life. Every confidence in the repository outside a test is here:

// 1. packages/core/src/experimental-evaluator.ts — lifted out of provider metadata
confidence: result.data.providerMetadata?.typesafe?.confidence[name],
 
// 2. packages/core/src/experimental-compose.ts — range-checked; a bad one is fatal
if (answer.confidence !== undefined &&
    (!Number.isFinite(answer.confidence) ||
      answer.confidence < 0 || answer.confidence > 1))
  throw new Error("Evaluator returned invalid confidence.");
 
// 3. packages/core/src/experimental-composition-batch.ts — the batched step
const step: Experimental_CompositionStep = {
  index: steps.length,
  choice: phase,          // "select" | "layout"
  // …
  confidence: null,       // ← dropped, unconditionally, for every batched call
  parentConfidence: null,
  answers: result.answers, // the per-question numbers ride along here
};
 
// 4. apps/web/lib/jev/response.ts — the last thing anyone does with it
send({ __meta: "decision", ...event.step });   // JSON.stringify, to a debug tab

Step three is a literal null, not a fallback: in the batched path — the default path, the one the announcement is about — the step-level confidence is never computed from anything. Step four is JSON.stringify. Between the model producing a calibrated number and that number reaching a pane of text, no branch reads it.

Nothing branches on it. There is no threshold, no fallback component, no degraded render, no human handoff — those four sites are the complete grep. The project says so itself, twice: "Confidence is displayed without a quality gate" and "Confidence is not a calibrated quality threshold… a universal threshold has not been calibrated."

That is the right call for an experiment and the wrong end state, and it is the same gap the rest of this category has: a probability that nothing consumes is decoration. The interesting part is that the composer does ship a principled escape hatch, it just is not the number — unavailable is an option on the root and next questions, so declining is a choice the model can make inside the enumerated set rather than a threshold someone has to pick. An enumerated "I can't" is a better fallback than an uncalibrated float, and it is already there.

Is this shipping?

It is a Vercel Labs experiment, Apache-2.0, with experimental_ on both exports and an explicit "may change in any release, pin exact versions". The playground caps itself at 14 elements, 14 evaluations, depth four, 10 s per call and 55 s overall. Root selection, grouping and knowing when to stop are called out as weak: "a vague request such as 'Generate a dashboard with the table at the top' can produce only a table." Read it as a working prototype of an idea, not a product.

One factual correction, in the project's favour. The docs page says "these APIs are unreleased. You can try the source build below before they appear in a published npm version," and walks you through pnpm pack. That is stale: I pulled @json-render/core@0.21.0 from npm (published 2026-09-18) and both functions are exported, with the Gateway URL and the batched-composition strings present in the shipped bundle. The source-build instructions are no longer necessary.

What it is

Generative UI has meant an LLM emitting markup or a component tree: slow, priced per token, and unbounded — it can name a component you do not have and pass props you do not accept, which is why json-render carries a 536-line validator with thirteen error codes and a lossy repair pass. Putting a decision model behind it inverts the responsibility. The developer enumerates components, props and actions; the model returns keys; code does the assembling. Nothing is generated, so nothing has to be repaired, and the output space is a number you can print — 161.

Two calls for a screen is a genuinely good number, and I did not expect the batching to be this thorough. What the demo understates is the price of admission: a hand-written recipe for every configured instance you want offered, the entire catalog on the wire every request, and a context window that says how big the catalog can get. The mechanism is real. It is smaller than "your design system, rendered in milliseconds" and considerably more interesting than another wrapper.

What would change my mind

5 claims above, and what would falsify each

  1. Composing a screen costs two evaluation calls regardless of element count.

    Measured, for the batched path, by instrumenting the evaluator. It is falsified by any new tree that produces three or more calls without an error — or by maxSteps: 1, which the tests show stops after the select call with stopReason: "limit". Note it is already false for edits: initialSpec forces the sequential protocol, and a move is three calls.

  2. Jev's latency does not grow materially from a 14-question rubric to a 37-question, 161-option, ~7,100-token composition request.

    This is the load-bearing assumption behind every latency figure in this article and I could not test it — running the real thing needs a Gateway key with the typesafe-ai provider enabled. Send the recorded select payload to typesafe-ai/jev and time it against a single-question call. If the 37-question request lands nearer a second than 250 ms, "rendered in milliseconds" is a claim about the first paint after the select call, not about the finished layout, and the second round trip is where it goes.

  3. 668.6 bytes per candidate, so one batched call tops out around 200 recipes at the Gateway's listed 32k context.

    Fitted through five measured points with ~120-character descriptions. Halve the description length and the ceiling roughly doubles, so the crossing is soft. What is not soft is the slope: if someone measures a batched call whose body does not grow linearly with candidate count, the composer changed or I read composeBatch wrong.

  4. Nothing in json-render reads the confidence it collects.

    A grep, so it is only as good as my grep. Point at any code path where a confidence value changes what renders — a threshold, a fallback element, a retry — and this is wrong. As of 0.21.0 the only consumer I can find is a debug stream.

  5. The option set is hand-declared, not derived from the design system.

    buildCandidates is a 322-line literal and the docs say a catalog is not enough on its own. If a later release derives candidates from component prop schemas plus a data source — enumerating instances from records rather than from hand-written recipes — the most laborious part of this goes away and the "your design system" framing becomes straightforwardly true.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Generative UI in two model calls", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026generativeuibydecision,
  author = {Satyajit Ghana},
  title  = {Generative UI in two model calls},
  url    = {https://ai.thesatyajit.com/articles/generative-ui-by-decision},
  year   = {2026}
}
share