Server-driven UI for AI agents: our dashboard schema
July 22, 2026
Most AI-app front ends solve “show the user something” by handing the model a webview and letting it write whatever HTML it wants. That’s flexible, and it’s also the thing that makes App Store reviewers nervous, makes security people nervous, and — less discussed, more real — makes the actual UI inconsistent, because every dashboard is reinvented from scratch by whatever the model felt like generating that day.
Parlane does the opposite. A dashboard is a JSON document your server sends; the app renders it with a fixed set of native components. No HTML, no CSS, no script tags, nothing downloaded and executed. If you’ve used a server-driven UI system from a large consumer app before, this will feel familiar — we didn’t invent the pattern, we picked it because it’s the one that already survives App Store review and scales past a single developer’s taste.
The document shape
A UI document is { version: 1, id?, title?, root: Component }. A
component is { type, id?, props?, children?, action? }. That’s the whole
grammar. Here’s a real one — the “Bridge” home dashboard from our demo
agent, trimmed for length:
{
"version": 1,
"id": "home",
"title": "Bridge",
"root": {
"type": "stack",
"props": { "direction": "vertical", "spacing": 16 },
"children": [
{ "type": "text", "props": { "value": "All systems nominal", "style": "title" } },
{
"type": "stack",
"props": { "direction": "horizontal", "spacing": 12 },
"children": [
{ "type": "metric", "props": { "label": "Hull Integrity", "value": "98%", "delta": "+1%", "intent": "good" } },
{ "type": "metric", "props": { "label": "Reactor Load", "value": "62%", "delta": "+8%", "intent": "neutral" } },
{ "type": "metric", "props": { "label": "O2 Reserve", "value": "14 hrs", "delta": "-2 hrs", "intent": "bad" } }
]
},
{
"type": "card",
"props": { "title": "Power Draw (last 12h)" },
"children": [
{
"type": "chart",
"props": {
"kind": "line",
"labels": ["06", "07", "08", "09"],
"series": [{ "name": "Draw", "data": [4.1, 4.4, 5.0, 6.2] }]
}
}
]
}
]
}
}Two structural rules worth calling out because they’re easy to get wrong
the first time: container types (stack, card) can carry children;
every other type is a leaf and the schema rejects children on it. And
root is singular — one tree per document, not a list of top-level blocks.
The 17 types, on purpose, not by accident
The catalog is fixed at 17 component types: stack, card, text,
metric, chart (line/bar/sparkline), table, list, image, button,
input, toggle, slider, select, progress, badge, divider,
map. That’s the whole vocabulary for v1. It’s not exhaustive — there’s no
video player, no custom drawing surface, no embedded map interactions
beyond markers — and we’re not pretending it is. It covers “status
dashboard for a personal agent” well: metrics, charts, tables, a form to
change settings, a list of alerts. It does not cover “rich custom app UI,”
and if your use case needs that, this isn’t the right layer for it yet.
Every interactive component — button, input, toggle, slider,
select — carries or triggers an action envelope:
{
"tool": "set_environment",
"params": { "source": "systems" },
"confirm": "Are you sure?",
"refresh": "self"
}tool names a tool call on your server. confirm gates it behind a prompt
(a string is the message, true uses a generic one — the one piece of
copy the app is allowed to write itself, everything else in a dashboard is
server text). refresh says what happens after: "self" re-fetches the
current dashboard, "none" does nothing, anything else is another
dashboard’s id.
Binding: how a form becomes params
input, toggle, slider, and select declare a bind — a parameter
name. When an action fires from a sibling button, the client collects the
current value of every bound component and merges it into that action’s
params, keyed by bind, overlaying whatever static params the action
already had. Concretely:
{
"action_id": "a1b2c3d4-0001",
"tool": "set_environment",
"dashboard_id": "systems",
"params": {
"source": "systems-dashboard",
"scrubber": true,
"targetTemp": 21.5
}
}…and the server can answer with a full replacement document or a bare acknowledgment:
{
"ui": {
"version": 1,
"id": "systems",
"root": {
"type": "stack",
"props": { "direction": "vertical" },
"children": [
{ "type": "badge", "props": { "value": "Environment updated", "intent": "good" } },
{ "type": "metric", "props": { "label": "Cabin temperature", "value": "21.5°C", "intent": "neutral" } }
]
}
}
}That “either/or” is deliberate: exactly one of ui or ack is
meaningful in a response. If ui shows up, the client replaces the
current document outright, diffing by id for animation purposes only —
there is no patch format. We looked at patch-based updates (diff the tree,
send operations) and decided against it for v1: full-replace is trivially
idempotent, trivially cacheable, and impossible to get subtly wrong in a
way that leaves the client’s tree in a state the server never actually
sent. The cost is bandwidth on large documents changing small pieces
often. For a personal dashboard polling every 15 seconds, that cost is
noise. We logged the tradeoff rather than hid it — it’s the kind of
decision that deserves a paper trail.
One scoping choice worth flagging honestly: bound values are collected
document-wide, not scoped to the same card as the button, with
last-write-wins on key collisions. That’s the permissive default; a
tighter per-card scope is a compatible follow-up if it turns out people
give two inputs the same bind name and get bitten by it. So far, unique
names avoid the whole question.
What happens when we ship a new component type
Eventually the catalog grows — that’s the whole point of versioning it
separately from the app’s release cycle, since Parlane updates
on its own schedule and your server shouldn’t have to wait for it. So the
render path has to survive components it’s never heard of. Our answer: an
unknown type renders as a labeled placeholder, not a crash and not a
silently dropped node. The strict schema still rejects unknown types at
validation time — that’s intentional, it keeps the published contract
honest — but the renderer itself is defensively lenient, because a client
built last year should keep working against a server updated this year.
The limits, and why they’re not in the schema
Two structural limits: max depth 12, max 500 nodes per document. JSON Schema (draft 2020-12) can’t cleanly bound recursive depth, so these are enforced in code — the app’s renderer and our spec validator both check them, and a document that passes the pure schema but blows past depth 12 gets rejected before render. We’d rather say plainly “this is a code-level check, not a schema-level one” than leave you to discover it by hitting the limit blind.
Why limits at all? A personal dashboard for a home server doesn’t need recursion depth in the double digits, and a renderer that has to defend against pathological trees from an LLM that got creative is a renderer that’s easier to reason about. 500 nodes is generous for “status page,” tight for “app.” That’s the intended shape.
Why this, and not a webview
The honest reason isn’t purely aesthetic. App Store review guideline 2.5.2 treats downloaded, interpreted, or executed code as a different category of risk than data rendered by the host app’s own components. A JSON tree that a fixed native renderer draws is squarely on the “data” side of that line; arbitrary HTML in a webview, less so, especially once it can call back into native functionality. We’d rather build inside a constraint that’s already been tested by other approved apps than bet a launch on a review outcome we can’t predict.
The unplanned benefit: because every dashboard draws from the same finite catalog, dashboards from wildly different agents end up looking like they belong to the same product family, instead of each one being a fresh CSS decision the model made at 2am. Consistency turned out to be a side effect of the constraint we picked for an entirely different reason.
If you want to see the whole thing end to end — real fixtures, both valid and deliberately broken — the schemas and examples are public: the manifest reference covers the full catalog table, and the REST contract shows the same document shapes over plain HTTP. Same contract either transport; we designed it that way on purpose.