One card, six protocols
The same expense-approval card expressed in AG-UI, A2UI, MCP Apps, the OpenAI Apps SDK, json-render and an Adaptive Card.
GoalLearn the protocols by comparing them on one concrete example instead of six abstract specs.
- Layer
- Transport: agent ↔ frontend event stream
- The model emits
- A tool call. Your frontend owns the component it renders.
- The click gets back via
- Tool message in the next RunAgentInput (or an interrupt resume)
- Trust model
- No UI code crosses the wire; only args. You render your own component.
- Backed by
- CopilotKit + open community
// Any AG-UI client works the same way: fold TOOL_CALL_* events into args,
// then render a component you registered for that tool name.
const renderers = {
show_expense_approval: (args, respond) => (
<ExpenseCard {...args}
onApprove={() => respond({ decision: "approve" })}
onReject={() => respond({ decision: "reject" })} />
),
};The agent never sent UI. It sent arguments to a tool your frontend registered; this card is your own React component.
Nothing yet. Press a button in the render above.
Checked against: AG-UI events · AG-UI tools · AG-UI interrupts · as of 2026-11-08
The six side by side
Click a row to load it above.
| Protocol | Kind | Model emits | Who draws it | Click returns as | Streaming |
|---|---|---|---|---|---|
| AG-UI | Transport | Events + tool calls | Your components | Tool message / resume | Streaming-first: text, args, state deltas |
| A2UI | UI as data | Component list + data model | Client renderer + catalog | action message | Incremental by design (flat ids) |
| MCP Apps | UI hosting | Nothing UI-specific | Server-shipped HTML in sandbox | JSON-RPC tools/call, ui/message | tool-input-partial lets the view preview args |
| OpenAI Apps SDK | UI hosting | Nothing UI-specific | Server-shipped HTML in ChatGPT | window.openai.callTool | Same as MCP Apps |
| json-render | UI as data | Flat spec within a catalog | Your components via registry | Your action handler | SpecStream compiler patches the spec |
| Adaptive Cards | UI as data | (Usually code, not models) | Host renderer | adaptiveCard/action invoke | Whole card; refresh replaces it |
They mostly don't compete
The six answer three different questions. A real stack often uses one from each row: A2UI messages carried over AG-UI, rendered next to an MCP App in the same host.
How the agent's run reaches the browser: text, tool calls, state. Says nothing about what the UI looks like.
What to draw, as JSON the client interprets with components it trusts. Safe by construction, limited by the catalog.
Ship real HTML from a server and run it in a sandboxed iframe inside a host such as ChatGPT or Claude. Unlimited UI, paid for with a sandbox.
How it worksclick to expand
Three renders are real interpreters.A2UI, json-render and the Adaptive Card are drawn by code in this page that reads the exact JSON shown under “What travels”. Anything outside their small allowlists is refused instead of drawn.
Two renders are real sandboxes. The MCP Apps and Apps SDK views run in an iframe with sandbox="allow-scripts". This page plays the host: it answers ui/initialize, pushes the tool result, and receives tools/call. The View uses raw postMessage where a real app would use the ext-apps SDK.
Simplified on purpose. The handshake carries only a few fields, there is no MCP server behind the host, and AG-UI runs are shown as static events (Lab 02 plays one live).
// The host side of the MCP Apps bridge, in miniature
window.addEventListener("message", (e) => {
if (e.source !== iframe.contentWindow) return // only our View
const m = e.data
if (m.method === "ui/initialize")
reply(m.id, { protocolVersion, hostInfo, hostContext })
if (m.method === "ui/notifications/initialized")
notify("ui/notifications/tool-result", toolResult)
if (m.method === "tools/call")
reply(m.id, await mcpServer.callTool(m.params)) // proxied
})