61. Actionable UI: a small framework for agentic interfaces
A text-only agent is tiring to use. "Your order A-1002 is in transit, expected Friday, would you like to…" is worse than a card with the order and an Approve refund button. Generative UI lets the agent choose what the user sees. The industry has converged on three layers:
| Layer | What it standardizes | Examples |
|---|---|---|
| Event protocol (agent ↔ UI runtime) | a typed stream: run started, text deltas, tool calls, state updates, UI requests, run finished | AG-UI (CopilotKit and partners) |
| Declarative UI spec (what to render) | a JSON tree of components from a catalog the client trusts; no code crosses the wire | A2UI (Google), tool→component mapping in the Vercel AI SDK |
| Sandboxed UI resources (render arbitrary UI) | the tool returns HTML that runs in a sandboxed iframe and talks over postMessage |
MCP Apps (the MCP UI extension), OpenAI Apps SDK |
The first two compose: A2UI payloads can travel over an AG-UI stream. The third is for third-party agents whose UI you can't know in advance.
Build it: event stream + component registry + actions
Support Desk implements the first two layers in about 120 lines.
The event protocol. The server never sends HTML. It sends typed events (the AG-UI style), one per SSE frame:
data: {"type":"run-started"}
data: {"type":"tool-call","name":"lookupOrder","args":{"orderId":"A-1001"}}
data: {"type":"text-delta","delta":"I found A-1001. "}
data: {"type":"ui","component":"refund-approval","props":{"id":"A-1001","item":"Noise-cancelling headphones","total":199,"status":"delivered"}}
data: {"type":"run-finished"}
The component registry. The client owns a catalog of components. Each entry has a schema and a render function. The agent can only pick a component and supply props, and the registry validates those props before anything reaches the DOM:
// agent-ui.js
const orderSchema = { id: "string", item: "string", total: "number", status: "string" };
const registry = {
"order-card": {
schema: orderSchema,
render: (o) => h("article", { class: "order-card", "aria-label": `Order ${o.id}` },
h("strong", {}, o.id), " ", o.item,
h("div", { class: "meta" }, money(o.total), " · ", h("span", { class: "status" }, o.status))),
},
"refund-approval": {
schema: orderSchema,
render: (o, { actions }) => {
const status = h("p", { class: "status", role: "status" });
const approve = h("button", { type: "button", onclick: async () => {
approve.disabled = decline.disabled = true;
try {
await actions.requestRefund(o.id); // app code, CSRF-protected, re-checked by the server
status.textContent = `Refund requested for ${o.id}.`;
} catch (err) {
status.textContent = `Refund failed: ${err.message}`;
approve.disabled = decline.disabled = false;
}
} }, `Approve refund of ${money(o.total)}`);
const decline = h("button", { type: "button", class: "secondary", onclick: () => {
approve.disabled = decline.disabled = true;
status.textContent = "Refund declined. Nothing was changed.";
} }, "Decline");
return h("article", { class: "order-card approval", "aria-label": `Approve refund for ${o.id}` },
h("strong", {}, o.id), " ", o.item, h("div", { class: "actions" }, approve, decline), status);
},
},
};
function validate(schema, props) {
if (typeof props !== "object" || props === null) return null;
const clean = {};
for (const [key, type] of Object.entries(schema)) {
if (typeof props[key] !== type) return null; // wrong or missing: refuse the whole component
clean[key] = props[key];
}
return clean; // unknown props are dropped
}
export function renderComponent({ component, props }, ctx) {
const def = Object.hasOwn(registry, component) ? registry[component] : null; // not `in`: no prototype keys
const clean = def && validate(def.schema, props);
if (!clean) { console.warn("agent-ui: refused component", component); return null; }
return def.render(clean, ctx);
}
h() builds DOM with createElement and text nodes only, and the page's
CSP enforces Trusted Types (§65), so even a bug in a render function cannot
turn a prop into markup.
Three design rules carry over to any agentic UI:
- The catalog is the security boundary. Unknown components and invalid
props are refused, not "best-effort rendered". The Playwright suite
sends an
iframecomponent and anorder-cardwithtotal: "free", and asserts that only the valid card renders (§77). - Actions call app code, not the agent. The Approve button calls
actions.requestRefund, a normal API call with the user's session and CSRF token. The server checks ownership and state again. The agent proposed the refund, and a human and the server authorized it. - Every action reports its outcome in the card (
role="status"), so the transcript doubles as an audit trail the user can read.
When the UI must be arbitrary: sandboxed iframes
For third-party tools whose UI you can't catalog (the MCP Apps model),
render their HTML in a sandboxed, opaque-origin iframe and talk only
through postMessage:
const frame = document.createElement("iframe");
frame.sandbox = "allow-scripts"; // NO allow-same-origin: the frame gets an opaque "null" origin
frame.srcdoc = toolHtml; // can't read your cookies, storage or DOM
document.querySelector("#tool-slot").append(frame);
addEventListener("message", (e) => {
if (e.source !== frame.contentWindow) return; // origin is "null" for srcdoc: check the source window
const msg = e.data;
if (msg?.type === "tool-action" && ALLOWED_ACTIONS.has(msg.action)) {
confirmWithUser(msg).then((ok) => ok && runAction(msg)); // the host page decides, never the frame
}
});
Never give agent-supplied HTML allow-same-origin together with
allow-scripts. That combination lets the frame remove its own sandbox.