Frontend Labs
⋯

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:

  1. The catalog is the security boundary. Unknown components and invalid props are refused, not "best-effort rendered". The Playwright suite sends an iframe component and an order-card with total: "free", and asserts that only the valid card renders (§77).
  2. 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.
  3. 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.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36