Your agent finished the build. It needs one decision from you: staging or production. So it writes three paragraphs and ends with "reply 1 for staging, 2 for production".

We solved this interface in 1984. Every agent product has un-solved it, because there was no standard way for a model to say "draw a button" — so models learned to describe buttons instead.

That changes on September 5. Ask Octomind to ship something and you get an actual card.

An Octomind session: the user asks to ship the docs site, the agent replies that the build is green, then draws a card titled "Deploy docs.octomind.run" with staging/production chips, a release-note field and Deploy / Not now buttons. Below it the user's answer reads "Deploy to production" and a second card confirms "Live in production" with an Open docs.octomind.run button

The obvious approach, and why it rots

The tempting fix is to let the model emit HTML, or invent a JSON widget format and render it. Nearly every product shipping "generative UI" today took one of those two roads.

HTML is a non-starter: agent output is untrusted, and shipping model-authored markup into a logged-in session is a cross-site scripting vulnerability with a product roadmap.

The proprietary-JSON road works right up until you have a second surface. Now your widget format has to render in a mobile app, in a Slack message, in an email digest — and every one of those needs its own interpreter, written by you, kept in sync by you, forever. The format also can't leave: no other renderer speaks it, no other agent emits it.

A2UI, and why we took the candidate over the stable release

A2UI is an open protocol for exactly this. The agent sends a description of a component tree and a data model; the client maps it onto native widgets. No code executes, no markup crosses the boundary.

There are two versions to choose from, and the intuitive choice is wrong. v0.9.1 is the "current production release" — and it is closed. Its own README says: "This specification is closed. Please do not propose changes here." All work happens in the v1.0 candidate.

Normally you'd still pick the frozen one, because that's what third-party renderers support. That reasoning doesn't apply when you're writing the renderer yourself. So we implemented v1.0, and got the thing that actually matters at runtime: v1.0 lets createSurface carry its components and data model inline, so a whole interface is one call. On v0.9.1 it's three round-trips through the model — three chances to be slow, three chances to half-fail.

We implement the stable core of v1.0 and deliberately skip the new bidirectional function RPC. It's the part of a candidate spec most likely to move, and it doesn't fit our transport anyway (more below).

The catalog is the published one — all 18 components, rendered in full. A partial catalog means agents emit surfaces you can't draw, which is the exact failure this feature exists to remove. The protocol's own conformance gallery, 43 example surfaces, runs against our renderer on every build.

The surface is the tool call

Here's the design decision that made the rest cheap.

An agent draws by calling one tool, ui, over our cloud MCP server. We could have stored surfaces in a table and pushed them to the browser. We didn't need to — because a tool call is already relayed to your open panel, already written to the session transcript, already read by the connector loop that mirrors sessions into chat.

So one call fans out to every consumer with no new plumbing:

text
                    ┌─ validated, fidelity reported ─────────┐
agent ── `ui` ──────┤                                        │
                    └─ relayed as a tool call ──┬── panel ──► the real interface
                                                ├── history ──► replay
                                                └── connectors ──► per-channel render

There's a second reason, and it's the honest one: a server-to-browser push wasn't available to us. Our API runs Swoole in SWOOLE_BASE mode, where a WebSocket connection belongs to the worker process that accepted it and no other worker can write to it. Riding the transcript wasn't a shortcut around that — it's the correct answer, because the surface genuinely is a product of the tool call. One record, so the live view and the reload can't disagree.

Close-up of a rendered A2UI card in the panel: a heading reading Deploy docs.octomind.run, a caption line with page count, build time and commit, a Target row with staging and production chips, a Release note text field, and Deploy and Not now buttons

Your click is just a message

When you press a button, the panel doesn't open a socket or call an RPC. It sends a normal user turn: a human-readable line, then the protocol payload.

That sounds unambitious. It's the most important decision in the feature.

It means an agent that has never heard of A2UI still reads something sensible. It means the answer survives reconnects, replays into history, and works identically whether you clicked in a browser or tapped in Telegram. And it means no blocking call sits inside a worker waiting for a human — a mistake we made elsewhere once and paid for in frozen request loops.

The half nobody ships: telling the agent what you saw

A card is easy in a browser. The real question is what happens when the same conversation is in WhatsApp.

Most systems answer by pretending. The form renders on the web and quietly becomes nothing in chat, and the agent — which is never informed — writes its next message as though you were looking at a button you cannot see.

Every channel here gets the best primitive it actually has. Same surface, no second authoring step:

ButtonsChoiceText input
Panelnativenativenative
Telegraminline keyboardkeyboard rowsask in text
SlackBlock Kitselect menuask in text
WhatsAppreply buttons (max 3) → listlist rowsask in text
GitHub, anywhere elsenumbered listnumberedask in text

And when something can't survive the trip, it degrades out loud. Here is the real output of that deploy card rendered for Telegram — not a mock-up, the actual bytes:

text
### Deploy docs.octomind.run

_34 pages changed · build 4m 12s · commit a91f3c_

---

**Target**

· staging
· production

Reply with Release note (optional).

-- actions: Deploy | Not now
-- degraded: TextField is not renderable on telegram — asked in text
             ChoicePicker needs a separate submit on telegram — options listed as text

That degraded list goes back to the agent, in the tool result, alongside a delivered map saying which channel got what fidelity. The agent knows its picker became a text question before it writes the next sentence.

That's the whole difference between an agent that sounds confused and one that doesn't, and it costs one field in a response.

Note the second degraded line, because it's a rule we argued about. A chat can show you options as buttons. It cannot express choose, then submit — two steps, one message. So a picker's options only become tappable buttons when the card has no separate submit button; otherwise they stay text. A tap that claims to have submitted a form it didn't submit is worse than a plain question.

Constraints we kept

Agent markup is untrusted input. No model-authored HTML is ever injected. URLs are scheme-checked, so a javascript: link in an Image or Button is dropped rather than rendered. A test pins that.

The agent can't restyle your app. v1.0 removed theming from the protocol, which we think is correct, and we go further: cards use the panel's own design tokens. An agent chooses structure. It does not choose colours.

Nothing leaves until you act. Typing into a card's fields updates a local data model in your browser. Not a keystroke reaches the agent until you press something.

An answered card stays answered. Reload a week later and it renders as spent, not as a live button waiting to be pressed a second time. A transcript is a record of what happened, not a form to refill.

What it isn't

It isn't a page builder. Surfaces live in a session, next to the work — no dashboards, no hosted apps. Custom component catalogs aren't supported yet; you get the standard one until an agent needs more.

And it's a candidate spec. v1.0 could still move before it's final, which is exactly why we implemented its stable core and left the churn-prone parts alone. Every message carries its version on the wire.

Try it

Ask an agent something that needs a decision — "deploy this, but ask me which environment first" — and watch it draw the question instead of typing it.

Available on every plan, nothing to configure. Machines pick it up on their next restart.