Companion Instant UI — the cross-platform renderer contract
Instant UI lets the companion draw a small interactive panel during a turn
instead of (or alongside) speaking text — a form to collect a few values, a
summary card with a progress bar, a chart, a set of choices. Over the SDK this
arrives as a companion.ui_action event, and your host renders it.
The whole point: the payload is platform-neutral JSON. The exact same
interface is drawn by a web embed, a native iOS / Android view tree, or a
CLI / TUI — only the renderer differs per platform, never the contract. There
is no HTML, no Svelte, no DOM in the wire format; it is a tree of typed UI atoms
plus a state bag.
In the first-party Pouchy app the same schema is drawn by a Svelte renderer (
src/lib/components/genui/DynamicRenderer.svelte). A native/CLI host writes the equivalent ~20-atom renderer once and gets every panel the companion will ever emit.
1. Enable it
Mint the session token with the ui.render scope (non-sensitive; opt-in). An
app that owns its own UI and doesn't want the companion drawing panels simply
omits it. To receive the event stream you also need events.subscribe.
const c = createCompanion({ baseUrl, token }); // token granted ui.render
await c.connect();
c.onRender(({ interface: ui }) => myRenderer.draw(ui)); // your renderer
c.start();
await c.sendText('help me split the bill 4 ways');
onRender is sugar over on('companion.ui_action', …) with a guard that drops
malformed frames. The payload is RenderInterfacePayload.
2. The payload
{
"interface": {
"title": "Split the bill", // optional panel title
"nodes": [ /* the UI atom tree */ ], // 1..20 atoms (required)
"state": { "people": 4, "total": 0 },// initial values for bound inputs / templates
"computed": { // derived, read-only values
"each": { "op": "div", "args": ["total", "people"], "round": 2 }
},
"reportChanges": true // stream the user's edits back as a turn
}
}
stateis the bag input atoms read and write, and that{key}templates reference. Values arestring | number | boolean.computedare derived values recomputed fromstateon every change —opismul | add | sub | div,argsare state keys or literal numbers,roundfixes display decimals. Reference a computed key exactly like a state key. (No expression language — keep it to these four ops.)reportChanges— see §5 (the live-form loop).
The payload is already validated and clamped server-side (node budgets, src allow-lists, safe markdown, dropped unknown atoms). A renderer can trust the shape and never needs to sanitize model output itself.
3. The atoms
Each node is { type, …props }. Any node may also carry a when clause
(§4). Three buckets:
Display (read-only)
type |
Props | Render |
|---|---|---|
Text |
text?, template?, tone? (default/muted/strong) |
A line of text. template resolves {key} against state; text is literal. |
Markdown |
text |
Safe formatted prose (headings, bold, lists, code, links). No {key}. |
Icon |
name (lucide id), size? |
A glyph. Map names to your platform's icon set (or skip). |
Image |
src (https/data), alt?, ratio? |
An image. |
Progress |
value (number or { $state: key }), max?, variant? |
A progress bar. |
Chart |
chart (bar/line/area/pie/donut), data: [{label?, value}], max?, title? |
A simple chart. |
Table |
columns: string[], rows: string[][], title? |
A read-only grid of plain-string cells. |
Divider |
— | A horizontal rule. |
Video |
src (https/data), ratio? |
A native video player (no autoplay). |
AudioPlayer |
src (https/data), title? |
A native audio player. |
Input (read + write a state key)
type |
Props | Behaviour |
|---|---|---|
Slider |
bind, min?, max?, step?, label? |
Writes a number to state[bind]. |
Switch |
bind, label? |
Writes a boolean. |
Select |
bind, options: [{value,label}], placeholder? |
Writes the chosen value. |
TextInput |
bind, label?, placeholder?, multiline?, maxLength? |
Writes a string. |
DatePicker |
bind, min?, max?, label? |
Writes an ISO YYYY-MM-DD string. |
Button |
label, variant?, action |
action is an AgentAction (or null = dismiss). See §6. |
Layout (contain children)
type |
Props |
|---|---|
Group |
direction (row/column), gap?, wrap?, children |
Card |
title?, children |
Tabs |
tabs: [{label, children}], bind? (active index state key) |
Accordion |
sections: [{label, children, open?}], multi? |
A renderer that implements these renders every panel the companion can emit. If your platform genuinely can't draw one (e.g. no inline video on a TUI), render a graceful placeholder — the rest of the tree is unaffected.
4. Conditional nodes (when)
Any node may carry when: { key, equals?, truthy? } and is shown only while the
clause holds against live state. equals wins if present (value match); else
truthy gates on Boolean(state[key]) (default true). This lets a Switch or
Select reveal/hide a section reactively without the model rebuilding the
panel — re-evaluate when on every state change.
5. The live-form loop (reportChanges)
When interface.reportChanges is true, the host should send the user's edited
state back to the companion as the next turn so it can react to the adjustments —
"the AI follows your slider". The natural transport is a normal text turn that
states the chosen values, or a context.snapshot; the model then continues with
the real numbers in hand. When reportChanges is absent/false, the panel is
local — edits stay on the host until an explicit submit (a Button, §6).
This is what makes a form work without any button-action dispatch: collect, report, the model replies. It is the recommended interaction pattern over the SDK.
5b. Live updates (companion.ui_update)
After rendering a panel, the companion can change it in place instead of
re-rendering: an update_interface tool call emits a companion.ui_update event
carrying { update: { panelId?, updates: [{ key, value }] } }. Apply each write
to the rendered panel's state bag and re-evaluate the bound displays ({key}
templates, Progress, when visibility) — inputs keep their focus, nothing
rebuilds. panelId targets one of several on-screen panels when present.
let panel: PanelHandle | null = null;
c.onRender(({ interface: ui }) => { panel = renderInterface(ui, mount); });
c.onInterfaceUpdate(({ update }) => panel?.applyUpdate(update));
On native, the reactive state already drives this: write the keys into the
@Observable (SwiftUI) / snapshot-state (Compose) bag and the views recompose.
6. Buttons & actions (advanced)
A Button.action is an AgentAction — a request to run a registered action
(after { $state: key } params are resolved to current values). Over the SDK an
action whose type matches a tool the app declared surfaces as a
companion.tool_call you already handle; a confirm-gated action still runs its
confirmation. For v1, prefer the reportChanges loop (§5) for collecting input;
reach for action buttons only when a tap must trigger a declared app tool.
7. Rendering on each platform
- Web — a component per atom, a reactive store for
state, recomputecomputed+ re-evaluatewhenon change. (Reference: the first-party Svelte renderer.) - Native (iOS / Android) — a view per atom (SwiftUI
View/ Jetpack Compose@Composable), astateview-model, two-way bindings for the input atoms. The atom list maps cleanly to native controls (Slider→Slider, Switch→Toggle/Switch, Select→Picker/menu, DatePicker→native date UI). - CLI / TUI — render display atoms as text/boxes; drive input atoms with
prompts (a Select becomes a numbered menu, a Slider a number prompt). Layout
atoms become indentation/sections. A headless agent can even render to plain
text and feed answers back via
reportChanges.
Standard wire format option
If you'd rather not bind to Pouchy's atom names, the schema projects losslessly
(modulo a few _pouchy extension keys) to Google's A2UI v0.9 flat wire
format via src/lib/genui/a2ui-adapter.ts (toA2UI / fromA2UI). A renderer
built against the A2UI catalog can consume Pouchy Instant UI with that adapter in
front.
8. Why this is "beyond the sandbox"
The first-party app renders Instant UI inside the browser. The SDK is not bound to that: because the contract is pure data, the companion's generative UI runs wherever the host can draw ~20 atoms — a native app, a game engine's UI layer, a terminal, a kiosk. Same companion, same panels, any surface. The renderer is the only platform-specific part, and it's written once.