Skip to main content
avatarJay Patel

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.

Posted Mar 30, 20265 min readWeb Development, AI

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.

#a2ui#nextjs#typescript#ai-agents#strands-sdk#react

Licensed under CC BY 4.0

Share: