Render UI¶
Some answers are not prose. Steps per day want a bar chart; three cafés want a map; a login flow wants boxes and
arrows. render_ui lets the model say so in data: it emits a small, typed JSON spec, and the host draws it with
native views — Swift Charts, MapKit, a Canvas. Nothing in the spec is code and nothing is executed; a web view is
never involved. The same spec is what tiny's use_render draws in a terminal and what loopl's iOS app draws as a card.
import Loopl
func run(_ model: any Model) async throws {
let agent = Agent(model: model, tools: [RenderUITool()], systemPrompt: "Show numbers as a chart, places as a map.")
for try await event in agent.stream("Chart my steps: Mon 4000, Tue 6200, Wed 7100.") {
if case .toolResult(let r) = event, r.name == RenderUITool.toolName {
print(r.content) // "rendered 1 component (chart). The user sees the card — do not repeat the same data in prose."
}
}
}
The spec¶
{ "title": "Your week",
"components": [
{ "type": "markdown", "content": "Steps up **18 %** on last week." },
{ "type": "chart", "kind": "bar", "title": "Steps",
"series": [ { "name": "steps", "points": [ { "label": "Mon", "value": 4000 }, { "label": "Tue", "value": 6200 } ] } ] },
{ "type": "map", "pins": [ { "lat": 40.7447, "lon": -74.0323, "label": "bwè kafe" } ], "route": [[40.7447, -74.0323], [40.7398, -74.03]] },
{ "type": "diagram", "nodes": [ { "id": "open", "label": "Open app" }, { "id": "home" } ], "edges": [ { "from": "open", "to": "home", "label": "ok" } ] }
] }
One type per component; everything else is optional so a 0.8B model can emit it.
| type | fields | drawn as |
|---|---|---|
markdown |
content |
the transcript's Markdown renderer |
text |
content, style: plain · secondary · strong · caption · mono |
one styled line |
rule |
title? |
a divider, optionally titled |
keyvalue |
title?, data: {key: value} |
an aligned grid (stacked at accessibility sizes) |
table |
title?, headers: [String], rows: [[String]] |
MarkdownTable; ragged rows are padded, ≤ 8 columns × 50 rows |
list |
title?, items: [String \| {title, subtitle?, icon?}] |
bullets or SF Symbol rows |
chart |
title?, kind: bar · line · pie, series: [{name?, points: [{label, value}]}] |
Swift Charts — grouped bars, smoothed lines, a donut with a value legend (first series) |
map |
title?, pins: [{lat, lon, label?}], route?: [[lat, lon]] |
MapKit markers + polyline; tap opens Apple Maps with every pin |
diagram |
title?, nodes: [{id, label?}], edges: [{from, to, label?}] |
a layered node-edge graph on a Canvas (DiagramLayout) |
progress |
description?, total, completed |
a bar with the percentage and x of y |
image |
url or attachment (index into the user's pictures), caption? |
the picture; an attachment comes back through ToolOutput.images |
Lenient by design, honest by contract¶
The author is a small on-device model, so RenderSpec(args:) repairs what it can — "4k" and "7,100" become
numbers, {Mon: 4000} becomes points, object rows are read by header, "login -> home" is an edge, a node named only
by an edge is added; and the shapes measured on loopl-0.8b/2b: components sent as a JSON string (also when the
schema check has wrapped it into a one-element array), an extra } or a truncated tail (brackets are rebalanced), a
key missing its closing quote, a missing type (inferred from kind, from a type-named key such as {"chart": {…}},
or from the fields present), edges/pins/series left beside the component instead of inside it, and a diagram
split across edge-less components — and records every repair in issues. The result the model reads names the kinds it drew and
appends the issues as notes for the next call — worded so that the model knows the card is already drawn
(measured on loopl-0.8b: a line labelled issues: read as failure and the same card was re-sent four times). An
unknown type is kept as
RenderComponent.unknown(type:): the card says unknown component type "gauge" and the result lists the known
types. Only a missing or empty components throws.
let spec = try RenderSpec(args: ["components": .array([.object(["type": "chart", "points": .array([.object(["label": "Mon", "value": "4k"])])])])])
spec.components[0] // .chart(title: nil, kind: .bar, series: [Series(points: [Point("Mon", 4000)])])
spec.issues // [] — "4k" was read as 4000; a value with no number would be listed here
spec.resultText // "rendered 1 component (chart). The user sees the card — do not repeat the same data in prose."
Caps (RenderSpec.maxComponents 12, rows 50, points 120, pins 50, nodes 40 …) exist because the spec is untrusted
output rendered eagerly as views; overflow is reported, not silently cut.
Drawing it (LooplUI)¶
RenderCard(call:result:spec:images:) is the row the app shows: the ordinary tool row (tap for the raw arguments) with
the card beneath — RenderBody(spec:) if you only want the card. A render_ui call is a normal toolUse in the
transcript; the card re-renders from the saved ToolUse.input, so History needs no second format. Every kind
carries a spoken summary for VoiceOver (bar chart Steps: Mon 4000, Tue 6200 …; diagram Login flow with 6 steps: Open
app to Verify …), the map is a button that opens Maps, and the diagram's layout is pure
(DiagramLayout(nodes:edges:): DFS back-edge detection, then longest-path layers) so it is unit-tested without a Canvas.
Training note: the trajectories loopl's models learn for render_ui (one call, then one sentence that does not repeat
the numbers) are the DATASET-V12 families R1–R6.