Building an Agent-Driven UI with the A2UI Protocol
How I implemented the A2UI (Agent-to-UI) protocol to let an AI agent dynamically generate interactive UIs using streaming messages, data binding, and declarative components.
The Problem with Agent-Generated UIs
Most AI chat interfaces follow the same pattern: the user types, the agent responds with text. But what if the agent needs to collect structured data — a salary, a date, a multi-select preference? You end up with awkward prompts like "please enter your monthly income as a number" and fragile parsing on the backend.
I wanted the agent to be able to show actual UI components — sliders, date pickers, currency inputs, charts — and have the user interact with them directly. The question was: how does an AI agent safely tell a frontend what to render?
Enter A2UI
A2UI (Agent-to-UI) is a protocol designed exactly for this. It defines how an agent can send UI instructions to a client without executing arbitrary code. The core ideas are:
- Streaming messages — UI updates flow as JSONL (one JSON object per line), enabling progressive rendering
- Declarative components — UIs are described as data, not code. The agent picks from a pre-approved catalog
- Data binding — UI structure is separated from application state using JSON Pointer paths
- Surfaces — named containers that hold components and enforce a catalog of allowed types
I built a Personal Finance Planner to implement these concepts end-to-end. The agent asks questions, shows charts, and builds a financial plan — all without a single hardcoded flow.
The Architecture
The system has three layers:
Agent (Strands SDK + Claude)
↓ returns ComponentDef
Server (Next.js route handler)
↓ streams JSONL messages
Client (React + SurfaceRenderer)
↓ renders components with data binding
Agent Tools Return ComponentDefs
Each agent tool returns a ComponentDef — the A2UI unit of UI. Instead of the agent generating HTML or React code, it returns a declarative description:
function inputDef(type: string, fieldName: string, props: Record<string, JSONValue>) {
return {
id: `${type.toLowerCase()}-${fieldName}`,
type,
props: { ...props, fieldName },
bind: { value: `/collected/${fieldName}` },
};
}The bind field is where data binding happens. { value: "/collected/gross_salary" } means this component's value is connected to the path /collected/gross_salary in the data model. When the user submits, the value is written there automatically.
The Server Streams JSONL
The API route returns a ReadableStream that emits three types of messages:
{"type":"createSurface","surfaceId":"finance-planner","catalog":["TextInput","CurrencyInput",...]}
{"type":"updateDataModel","patches":[{"path":"/agent/lastMessage","value":"What's your monthly income?"}]}
{"type":"updateComponents","components":[{"id":"currencyinput-income","type":"CurrencyInput","bind":{"value":"/collected/income"}}]}
Each line is a complete JSON object. The client reads them as they arrive — no waiting for the full response.
The Client Renders from a Surface
The SurfaceRenderer takes the flat adjacency list of components and builds a render tree:
function BoundComponent({ node, dataModel, onAction }) {
const { def } = node;
const ReactComponent = componentRegistry[def.type];
// Resolve data bindings
const boundProps = { ...def.props };
if (def.bind) {
for (const [propName, pointer] of Object.entries(def.bind)) {
boundProps[propName] = dataModel.get(pointer);
}
}
const handleSubmit = (value) => {
if (def.bind?.value) {
dataModel.set(def.bind.value, value);
}
onAction(def.id, value);
};
return <ReactComponent {...boundProps} onSubmit={handleSubmit} />;
}The key insight: the 20 existing React components (TextInput, BudgetBreakdown, ProjectionChart, etc.) don't know they're in an A2UI system. The SurfaceRenderer acts as an adapter between the protocol and the component interface.
Data Binding with JSON Pointers
A2UI separates UI structure from application state. Components don't hold their own data — they bind to paths in a reactive DataModel:
class DataModel {
private data: Record<string, unknown> = {};
get(pointer: string): unknown {
return resolve(this.data, pointer); // RFC 6901
}
set(pointer: string, value: unknown): void {
set(this.data, pointer, value);
this.notifyListeners(pointer);
}
applyPatches(patches: DataPatch[]): void {
for (const patch of patches) {
set(this.data, patch.path, patch.value);
}
}
}Paths like /collected/gross_salary or /agent/lastMessage follow RFC 6901 (JSON Pointer). When the server sends an updateDataModel message, the client applies the patches and any bound components update automatically.
The Catalog: Security by Design
A2UI's catalog concept prevents UI injection. The server declares which component types are allowed when creating a surface:
{
"type": "createSurface",
"catalog": ["TextInput", "CurrencyInput", "BudgetBreakdown", "ScoreCard"]
}If the agent tries to render a component not in the catalog, the Surface class rejects it:
upsertComponent(def: ComponentDef): void {
if (!this.catalog.includes(def.type)) {
console.warn(`Component type "${def.type}" is not in the catalog — skipping.`);
return;
}
this.components.set(def.id, def);
}No arbitrary code execution. No script injection. The client only renders pre-approved components.
What I Learned
The adjacency list model is LLM-friendly
Flat lists of { id, type, parentId } objects are much easier for an LLM to generate than deeply nested trees. The client handles the tree assembly.
Deterministic IDs prevent LLM hallucination
Instead of asking the LLM to generate component IDs, I derive them from the field name: currencyinput-gross_salary. The agent picks the component type and field name; the ID follows deterministically.
Data binding eliminates a whole class of bugs
When the component doesn't manage its own state, you can't get out of sync between what the UI shows and what the server knows. The DataModel is the single source of truth.
Streaming changes perceived performance
Even though the agent still takes a few seconds to respond, the createSurface message arrives instantly. The client can show the surface frame while waiting for the component data.
Try It Out
The full source code is on GitHub: jay-1799/a2ui-nextjs-demo
You'll need AWS credentials with Bedrock access (Claude Sonnet 4). Clone the repo, add your credentials to .env.local, and run npm run dev.
The A2UI spec is at a2ui.org — it's still evolving (v0.9 draft), but the core concepts are solid and worth understanding if you're building agent-driven interfaces.