AI Integration

A2UI: The Case for Agents That Render Their Own UI

Apr 13, 2026 7 min read

As your agent gains new abilities—product search, comparison, checkout—the UI logic usually explodes into fragile conditionals. This article shows how A2UI lets agents assemble screens from a small component vocabulary so the frontend stays stable while capabilities grow.

Instead of hardcoding every possible screen your AI agent might need, let the agent describe what to render and the frontend handle how to render it. Here is what that looks like in practice.

When most developers add an AI agent to an application, the frontend drives the UI. The agent produces text or structured data, and the frontend has conditional rendering logic that maps agent output to specific components. If the agent returns products, render a product grid. If it returns a checkout state, render a form. If it returns an order confirmation, render a confirmation card.

This works until the number of states grows. Every new agent capability requires a new frontend branch. The agent and the frontend become tightly coupled — changing the agent's output format means updating the frontend in lockstep.

A2UI inverts this. The agent describes the UI as a structured payload. The frontend renders the described components. Adding a new agent capability — product comparison, order history, personalised offers — does not require a new frontend branch. The frontend already knows how to render the component types. The agent just uses them in a new arrangement.

THE PROBLEM A2UI SOLVES

 

Consider a product search result in a traditional agentic application. The LLM returns a text list of products. The frontend parses it, or the agent returns JSON, and the frontend maps it to a grid component.

Now you want to add product comparison. The agent needs to render a table instead of a grid. In the traditional approach you add a new conditional:

if (agentResponse.type === "product_grid") return <ProductGrid ... />;
if (agentResponse.type === "comparison_table") return <ComparisonTable ... />;

Add five more features, and you have ten conditionals, each tied to a specific agent output format. When you change the agent, you change the frontend. They are locked together.

A2UI's approach: define a small vocabulary of component types (card, text, button, badge, grid, form, field). The agent assembles these types into a layout. The frontend renders the layout. New agent capabilities reuse existing component types — they do not require new frontend branches.

THE PAYLOAD STRUCTURE

 

Every A2UI payload has the same shape:

{
    "type": "product_grid",    # screen-level discriminator
    "components": [            # flat list of component objects
        {
            "id": "grid-root",
            "type": "grid",
            "props": {"columns": 2},
            "children": ["card-shoe-001", "card-shoe-002"]
        },
        {
            "id": "card-shoe-001",
            "type": "card",
            "children": ["card-shoe-001-name", "card-shoe-001-price", ...]
        },
        {
            "id": "card-shoe-001-name",
            "type": "text",
            "props": {"content": "Nike Air Zoom Pegasus 41", "variant": "heading"}
        },
        ...
    ]
}

The structure is an adjacency list — each component has an id and a children list of child IDs. The component tree is built from this flat list. This makes serialisation and streaming simple: it is just a JSON array.

type is the top-level discriminator for the screen. components[].type is the component type within the screen. These are the two layers of the system.

BUILDING A PRODUCT GRID

 

The build_product_grid function takes a list of ProductCandidate objects and assembles the payload:

def build_product_grid(candidates):
    components = []
    card_ids = []

    for p in candidates:
        cid = f"card-{p.product_id}"
        card_ids.append(cid)

        stock_label = "In stock" if p.in_stock else "Out of stock"
        stock_variant = "success" if p.in_stock else "error"

        components += [
            {"id": cid, "type": "card", "children": [
                f"{cid}-img", f"{cid}-name", f"{cid}-price",
                f"{cid}-stock", f"{cid}-btn"
            ]},
            {"id": f"{cid}-img", "type": "image",
             "props": {"src": p.image_url, "alt": p.name}},
            {"id": f"{cid}-name", "type": "text",
             "props": {"content": p.name, "variant": "heading"}},
            {"id": f"{cid}-price", "type": "text",
             "props": {"content": f"${p.price:.2f}", "variant": "price"}},
            {"id": f"{cid}-stock", "type": "badge",
             "props": {"label": stock_label, "variant": stock_variant}},
            {"id": f"{cid}-btn", "type": "button",
             "props": {"label": "Add to Cart", "action": "add_to_cart",
                       "value": p.product_id, "variant": "primary"}},
        ]

    components.insert(0, {
        "id": "grid-root", "type": "grid",
        "props": {"columns": 2}, "children": card_ids
    })

    return {"type": "product_grid", "components": components}

The agent knows the data. It does not know the CSS, the grid implementation, or the React component library. It describes the structure. The frontend renders it.

In practice, each builder function is called from a LangGraph orchestrator node — the agent's intent classifier decides which node runs, and the node calls the appropriate builder to assemble the payload.

THE COMPARISON TABLE — A DIFFERENT TYPE, NO NEW FRONTEND CODE

 

When a user asks to compare products, the same agent returns a different payload type:

def build_compare_products(candidates):
    components = []

    # Header row — product names
    header_cells = []
    for p in candidates:
        cell_id = f"cmp-hdr-{p.product_id}"
        header_cells.append(cell_id)
        components.append({
            "id": cell_id, "type": "text",
            "props": {"content": p.name, "variant": "heading"}
        })

    # Attribute rows
    rows = [
        ("price",    "Price",       lambda p: f"${p.price:.2f}"),
        ("rating",   "Rating",      lambda p: f"⭐ {p.rating:.1f}"),
        ("stock",    "Availability",lambda p: "In stock" if p.in_stock else "Out of stock"),
        ("delivery", "Delivery",    lambda p: p.estimated_delivery or "—"),
        ("why",      "Why recommended", lambda p: p.recommendation_reason or "—"),
    ]

    for attr_key, attr_label, attr_fn in rows:
        row_id = f"cmp-row-{attr_key}"
        cell_ids = [f"{row_id}-label"]
        components.append({
            "id": f"{row_id}-label", "type": "text",
            "props": {"content": attr_label, "variant": "label"}
        })
        for p in candidates:
            cell_id = f"{row_id}-{p.product_id}"
            cell_ids.append(cell_id)
            components.append({
                "id": cell_id, "type": "text",
                "props": {"content": attr_fn(p), "variant": "body"}
            })
        components.append({
            "id": row_id, "type": "comparison-row", "children": cell_ids
        })

    return {"type": "comparison_table", "components": components}

The frontend checks a2ui_payload.type. If it is "comparison_table", render the ComparisonTable component. If it is "product_grid", render the product grid. That is the only conditional required for both screens.

THE CHECKOUT FORM — AGENT-DRIVEN, NOT HARDCODED

 

The checkout form is also generated by the agent through A2UI:

def build_checkout_form(session):
    return {
        "type": "checkout_form",
        "components": [
            {"id": "checkout-root", "type": "form",
             "props": {"title": "Complete Your Order",
                       "action": "complete_checkout",
                       "sessionId": session.session_id},
             "children": [
                 "checkout-name", "checkout-street",
                 "checkout-city", "checkout-state", "checkout-zip",
                 "checkout-payment", "checkout-total", "checkout-submit"
             ]},
            {"id": "checkout-name", "type": "field",
             "props": {"label": "Full Name", "inputType": "text",
                       "name": "name", "required": True}},
            ...
            {"id": "checkout-total", "type": "total-row",
             "props": {"label": "Total",
                       "value": f"${session.total:.2f}", "variant": "bold"}},
            {"id": "checkout-submit", "type": "confirm-button",
             "props": {"label": f"Place Order — ${session.total:.2f}",
                       "action": "complete_checkout"}},
        ]
    }

The agent knows the session total and the session ID. It constructs the form layout. The frontend renders field components, total rows, and a confirm button. If the checkout flow changes — new fields, different total display, different payment options — the agent changes the payload. The frontend does not change.

HOW THE FRONTEND RENDERS IT

 

On the React side, the agent state is consumed through useAgent. The a2ui_payload field in state contains the latest payload. The ShopPage component checks the type and renders:

{state.a2ui_payload?.type === "comparison_table" ? (
    <ComparisonTable components={state.a2ui_payload.components} />
) : (
    state.product_candidates?.length > 0 && (
        <div className="grid grid-cols-3 gap-3">
            {state.product_candidates.map(p => (
                <ProductCard key={p.product_id} product={p} />
            ))}
        </div>
    )
)}
{state.checkout_session?.status === "created" && (
    <CheckoutForm session={state.checkout_session} />
)}

The product cards are driven by product_candidates (from agent state directly), while the comparison table and checkout form are driven by the A2UI payload. This is because product cards have add-to-cart buttons that need direct event handling, while the comparison table and checkout form are purely descriptive — the agent provides the full layout.

THE TYPE DISCRIMINATOR PATTERN

 

The type field at the top of every A2UI payload is not just metadata — it is the switching mechanism. Every new screen the agent can generate is a new type value. The frontend maps types to components:

const SCREEN_COMPONENTS = {
    "product_grid": ProductGrid,
    "comparison_table": ComparisonTable,
    "checkout_form": CheckoutForm,
    "cart_summary": CartSummary,
    "order_confirmation": OrderConfirmation,
};

Adding a new screen — say, personalised_offer — means adding a Python function that builds the payload and a React component that renders it. The orchestrator and the routing layer do not change.

This is the architecture payoff of A2UI: you get a stable, extensible registry of screen types. New agent capabilities are new entries in the registry, not new conditionals scattered across both the backend and the frontend.

WHAT WE'D DO DIFFERENTLY

 

Schema validation — Currently, the frontend trusts the A2UI payload structure. In production, you would want a shared schema (JSON Schema or a TypeScript/Python type registry) that validates payloads before rendering. A malformed payload from a buggy agent should be caught at the boundary, not cause a React render error.

Component versioning — As the component vocabulary evolves, old payloads with old component types need to still render. A version field on the payload and a migration layer would handle this gracefully.

Action security — Button components carry an action value (add_to_cart, complete_checkout) and a value. The frontend executes these actions. An adversarial payload that injected unexpected action values could trigger unintended operations. Validating action names against an allow-list before execution is a worthwhile guard. For example, the frontend would only execute actions in {'add_to_cart', 'initiate_checkout', 'complete_checkout'} — anything else would be ignored and logged.

THE TAKEAWAY

 

A2UI separates two concerns that are usually tangled together: what to show and how to show it. The agent knows what data to display and how it should be structured. The frontend knows how to render component types into actual visual elements.

The result is a system where adding new agent capabilities does not require synchronised changes across the backend and frontend. The agent describes. The frontend renders. Each side evolves independently within the contract defined by the component vocabulary.

The ShopAgent demo is live at https://shop-agent.agilecreativeminds.nl. See the demo showcase or follow the demo walkthrough. Built by Agile Creative Minds.