Engineering

How a render spec turns one JSON payload into native UI

The whole contract is a spoken answer, a separator, and an array of widget objects. The interesting parts are the constraints, not the format.

VVoqal · Engineering10 min readPart of Render spec
A contact sheet of every render-spec widget drawn at once by the React widget rendering harness on sample data, including stat, list, chart, confirm, products and rating cards.

About this article

Everything interesting about a render spec is a restriction. The format takes ten minutes to understand and about six months to get the constraints right, and the constraints are the reason the thing is safe to point at a payments backend.

This post is the contract itself. The argument for why an agent should draw product UI at all is a separate one, and it comes first if you are not yet sold. The contact sheet at the top is the React harness we pixel-diff the SwiftUI renderer against, drawn on invented sample values, so it shows every shape at once in a way no single phone capture can.

The contract, in full#

One model call produces two things: a spoken answer, and a JSON array describing what to draw. They are separated by a sentinel, so the client can start speaking the first part while the second is still arriving.

text
Your available balance is 48,250 Egyptian pounds, with about 12,400 still settling.

===render===
[
  {
    "kind": "stat",
    "title": "Available balance",
    "value": "EGP 48,250.00",
    "delta": { "label": "settling", "value": "EGP 12,400.00" }
  },
  {
    "kind": "list",
    "title": "Last 3 settlements",
    "rows": [
      { "leading": "12 Sep", "title": "Card settlement", "trailing": "EGP 9,120.00" },
      { "leading": "11 Sep", "title": "Wallet settlement", "trailing": "EGP 3,040.00" },
      { "leading": "09 Sep", "title": "Card settlement", "trailing": "EGP 7,880.00" }
    ]
  }
]

That is the whole thing. No component code, no HTML, no styling, no layout instructions. The model names a kind from a catalogue the client already ships and supplies the data for it. Everything about how a stat looks, what typeface it uses, how it behaves under a screen reader and what happens when the number is negative is decided in your app, once, by your designers.

The kind field is the only part the client validates against a fixed list. Everything else is data the widget already knows how to lay out.

Why the answer and the widgets come out of one call#

The obvious design is two calls: answer the user, then ask a second time what to draw. It is worse in three ways that matter more than the elegance.

A second call costs a second round trip against a budget that is already tight. A warm turn in our own runtime settles around 2.5 to 3 seconds end to end, and a second model call would put a visible dent in that. It also splits the reasoning: the model that chose the answer is the one that knows which numbers deserve a card, and asking a fresh call to infer that from the finished prose throws that context away. And it opens a window where the speech and the widgets can disagree, which users notice immediately because they hear one number and see another.

The cost of the single-call design is that the model is producing a structured tail after free prose, and models are imperfect at that. Which leads directly to the parsing section below.

Anatomy of a widget object#

Every object carries a kind and then whatever that kind needs. The catalogue in our own SDK runs to roughly a dozen entries, visible in the capture at the top of this post: stat, progress, list, record, chart, confirm, products, ticket, callback, rating, text and media, plus a slot for a component the integrating team supplies themselves.

A products widget headed fresh picks, listing two grocery items with sizes, prices in Egyptian pounds and a round add button on each row.
One products object with an array of items produced this. The stepper, the image slot and the basket behaviour are the client's; the model supplied the array. Drawn by the widget renderer on sample items, with the image slots left empty.

Three properties are worth designing for from the start.

Widget payloads are small on purpose. Anthropic’s guidance on writing tools for agents applies directly here: implement “some combination of pagination, range selection, filtering, and/or truncation with sensible default parameter values for any tool responses that could use up lots of context” (11 September 2025). A list widget that renders 200 rows costs 200 rows of tokens on every subsequent turn of the conversation, because the tail stays in the history.

Identifiers travel, display strings do not. A row carries the order id the app needs to open the detail screen, and the model never has to reconstruct it from the title text.

Formatting is the client’s job. The model emits a value and a currency code, and the client formats it for the user’s locale. This is the single rule that stops a right-to-left layout from rendering a mirrored number, and it is the same reason an Arabic turn and an English turn can share one payload shape.

The rules the model is not allowed to break#

These are enforced in server code, not requested in the prompt. That distinction is the whole security posture, and it is worth being blunt about why: a rule in the prompt is a rule the model can be talked out of.

One confirm card per turn, and the model does not write it#

When the agent names a tool that changes state, the runtime does not execute it. It holds the call and builds the confirm widget itself, from the arguments it is holding, and appends that to the tail. If the model were also permitted to draw its own summary card, the user would see two, and would approve the wrong one. The full mechanism, including what happens after the tap, is in the confirm-card post.

The spoken answer never names the authentication method#

No “confirm with Face ID”, no “approve below”. The method varies by device and by risk tier, so the sentence is often wrong, and an assistant that reliably announces its own authentication step has written the script for anyone impersonating it.

No widget outside the catalogue#

If the model asks for a kind the catalogue does not contain, the server drops that object before the payload leaves. It does not pass it through for the client to ignore, because a client that silently ignores unknown kinds is a client that will eventually render one.

Every branch that is not the happy path ends somewhere the user can still act. Speech alone is a degraded answer, not an error screen.

Parsing it safely, because the tail will be malformed#

Some proportion of tails arrive broken. A trailing comma, a truncated array because the response hit its token ceiling, a second separator the model decided to emit for symmetry. The parser has to treat all of that as expected input.

The order that works: split on the first separator only, keep everything before it as speech no matter what happens next, then attempt the array. If the array fails to parse, the turn degrades to speech alone and the user gets a correct spoken answer with no cards, which is a worse answer and not a broken one. If the array parses but one object inside it is invalid, drop that object and render the rest.

The failure mode to design against is the one where a malformed tail leaks into the spoken text, because then the user hears a bracket. Splitting first and parsing second is what prevents it.

Widget lifecycle, and states that tell the truth#

A widget has a lifecycle. It is announced, it waits for data, and it either resolves or fails. Vercel’s AI SDK names the equivalent states for web tool parts as input-available, output-available and output-error (AI SDK docs), and the same three are worth borrowing wholesale, because they map onto three different skeletons.

A skeleton that shows the widget's real shape is honest. A generic three-bar shimmer under every kind is a lie the user catches when the chart arrives.

The rule that follows: the skeleton is drawn by the widget, not by the chat container. A stat skeleton is one tall block. A list skeleton is rows. Getting this wrong is the most common reason a fast turn feels slow, which is its own subject.

Where this sits next to the standards#

We are not claiming to have invented the pattern, and there are now two public efforts worth knowing about before you build anything here.

A2UI describes itself as “A Protocol for Agent-Driven Interfaces”, created by Google with contributions from CopilotKit and the open source community, and it takes the same position we do: agents send declarative component descriptions drawn from a pre-approved catalogue, not executable code, and clients render them with native widgets. It is at v0.9.1 with a v1.0 candidate as of September 2026 (a2ui.org). If you are starting today, read it before you design a bespoke schema.

MCP Apps takes the other fork. Its overview opens with “Text responses can only go so far. Sometimes users need to interact with data, not just read about it”, and its mechanism is a tool declaring a _meta.ui.resourceUri pointing at a ui:// resource that returns an HTML page, which the host renders in a sandboxed iframe and talks to over postMessage (MCP Apps overview). That is a real architecture with real advantages inside a chat host. It is also HTML in an iframe, which is not a native interface, and CopilotKit’s own page says so plainly: MCP Apps is “Web-first: not yet a good fit for non-web surfaces such as mobile or Slack” (copilotkit.ai).

AG-UI occupies a third position, describing itself as the agent-to-user layer alongside MCP for tools and A2A for agent-to-agent traffic, and noting that “these three agentic protocols are complementary” (AG-UI docs).

A render spec is the same idea as A2UI, shipped in production on native clients, with the constraints above enforced server-side.

What we would change if we designed it again#

Two things.

The separator should have been a structured field rather than a sentinel in the token stream. ===render=== works and it streams well, but it means the tail is text until it is parsed, and every parser bug we have had traces back to that.

And widget kinds should have carried a version from day one. Adding an optional field to a widget is easy. Changing what an existing field means, across five client SDKs where some installed versions are two years old, is not, and a version on the object would have made the compatibility question answerable instead of arguable.

Common questions#

What is a render spec? It is the JSON an agent returns alongside its spoken answer, naming widgets from a fixed catalogue the client already ships. Each object carries a kind and the data that kind needs. The client maps it onto a native view. No markup, code or styling crosses the wire.

How does an AI agent return UI instead of text? By naming UI rather than generating it. The model picks from a closed set of widget kinds and supplies data; the app decides what each kind looks like. Anything outside the set is rejected server-side before it reaches a client.

What happens if the JSON is malformed? The parser splits the spoken answer off first, so speech survives any parse failure. A broken array degrades the turn to speech only; a single invalid object is dropped and the remaining widgets render.

Is this the same as A2UI or MCP Apps? Same family as A2UI, which also sends declarative components from a pre-approved catalogue. MCP Apps is a different fork: it returns HTML rendered in a sandboxed iframe, which suits chat hosts and, by CopilotKit’s own account, is not yet a good fit for mobile.

Sources#

Filed underRender specWidgetsIntegration

Next

The request and response shape of one turn, field by field.

Read the render spec field reference

The rest of Render spec

Open the cluster

Server-driven native UI: the agent names the widgets, your app draws them, and shipping a new answer shape stops requiring an app release.

Elsewhere on the map