The Useful Boundary: A UI Contract, Not a Magic Protocol
A2UI is commonly used to describe an Agent-to-UI approach: an Agent emits a declarative description, and a product-owned client renders it with local code. That is a useful boundary when an Agent needs to present a comparison, collect constrained input, or show a workflow state without generating HTML, JavaScript, or native source code.
It is not a complete application architecture. A UI description does not authenticate a user, prove ownership of a booking, validate a price, approve a payment, or make an action happen exactly once. Those are properties of the application around the UI.
Before adopting any A2UI specification or implementation, record the exact surface that was tested:
{
"ui_contract": "a2ui-spec-release-or-commit",
"transport_profile": "pinned-profile",
"schema_bundle": "catalog-schema-revision",
"renderer": "product-renderer@revision",
"component_catalog": "catalog-version",
"action_api": "server-contract-version",
"accessibility_baseline": "tested-platforms-and-assistive-tech",
"checked_at": "recorded-time"
}
The message types, data-binding syntax, renderers, and SDKs published by an ecosystem can change. Verify them against the pinned source and your integration tests. Do not promote a package name, roadmap, or example message from a blog into a production dependency without that check.
When a Generated Surface Helps
Chat is good at explanation. It is weak at comparison, constrained input, and visible state. A travel assistant may make it easier to compare refundable fares in a table, but a "Book" button should not silently turn a model suggestion into a purchase.
Use a generated surface when it improves a concrete user task:
| User need | Suitable contract | Keep outside the Agent |
|---|---|---|
| Compare search results | Read-only cards with citations and refresh time | Ranking policy, price source, and disclosure |
| Choose filters | A fixed allowlisted filter form | Query authorization and server-side validation |
| Review a draft | Editable content with explicit save | Ownership, version conflict handling, and audit |
| Request a consequential action | Summary plus confirmation step | Object authorization, approval, idempotency, and execution |
Prefer a fixed interface for stable product workflows. Prefer text when interaction adds no value. A dynamic surface is a presentation technique, not evidence that a task deserves more autonomy.
Treat Every UI Payload as Untrusted Input
The Agent, retrieved documents, tool results, and remote services can all influence a UI payload. Even valid JSON can attempt to exhaust a renderer, impersonate a trusted screen, smuggle an external URL, or steer a user toward an unsafe action.
A renderer should reject rather than repair an invalid contract. Its admission checks should include:
- schema revision and message type;
- maximum bytes, component count, nesting depth, update rate, and retained surfaces;
- allowed component names and per-component property schemas;
- bounded text, image dimensions, rich-text subset, locale keys, and data references;
- URL scheme, host allowlist, redirect policy, and media retrieval policy;
- unique identifiers, valid parent relations, and cycle detection;
- surface ownership, expiry, and correlation to the current session.
This is defense in depth. JSON Schema can validate shape, but it cannot determine whether a price is current, an account belongs to the user, or a button is appropriate for a sensitive operation.
A Small, Product-Owned Catalog
Start with a deliberately small catalog such as Text, List, Notice, Select, TextInput, Button, and ConfirmationSummary. Each entry should have an explicit accessibility and data contract. Do not pass arbitrary properties through to a framework component.
type UiNode =
| {
kind: "Text";
id: string;
text: string;
tone?: "default" | "muted" | "danger";
}
| {
kind: "Button";
id: string;
label: string;
action: { name: "select_offer" | "request_confirmation"; token: string };
};
function isAllowedAction(name: string): name is UiNode["action"]["name"] {
return name === "select_offer" || name === "request_confirmation";
}
This type does not make a renderer secure by itself. The implementation must still parse untrusted input, validate the complete object at runtime, reject unknown fields where appropriate, escape text, and map a node only to reviewed application code. Never render model-produced HTML, styles, scripts, event handlers, or arbitrary deep links.
Data References Need Scope
Separating component structure from data can reduce repeated payloads, but a reference is not permission. Namespace data by surface and session. Forbid references that escape the current surface, and resolve sensitive values only after the server has filtered them for the authenticated user.
Avoid putting secrets, raw tool responses, internal identifiers, or authorization claims in a client-side data model merely because a component might need them. A display identifier and a server-issued opaque action token are usually safer than an account record or a model-selected object ID.
Rendering Is Not Authorization
Consider a user reviewing a proposed order. The Agent may render a summary and a request_confirmation control. The browser can submit that intent, but it cannot establish what the user may buy or what price applies.
The server should construct its decision from trusted session context and authoritative records:
from dataclasses import dataclass
@dataclass(frozen=True)
class ActionIntent:
action: str
token: str
confirmation_id: str | None
def handle_action(session, intent: ActionIntent, offers, confirmations):
if intent.action not in {"select_offer", "request_confirmation"}:
return {"status": "rejected", "reason": "unknown_action"}
offer = offers.resolve_for_subject(intent.token, session.subject_id)
if offer is None:
return {"status": "rejected", "reason": "offer_not_available"}
if intent.action == "select_offer":
return {"status": "accepted", "offer_id": offer.public_id}
confirmation = confirmations.create(
subject_id=session.subject_id,
offer_id=offer.id,
amount=offer.current_amount,
currency=offer.currency,
)
return {"status": "confirmation_required", "confirmation_id": confirmation.public_id}
The example is an application policy fragment, not an A2UI SDK. It omits payment execution intentionally. A consequential write needs a separate endpoint that rechecks the authenticated subject, object state, exact parameters, confirmation binding, expiry, idempotency key, and business policy immediately before the effect.
Never trust a model-generated user ID, role, price, destination, approval state, or authorization flag. Never let a component name, a schema-valid field, a prompt instruction, or a model refusal decide access.
Design the Action Lifecycle
A safe interaction distinguishes a proposal from an effect:
Use an opaque, short-lived action token that the server binds to the subject, tenant, surface, allowed action, and normalized parameters. Recheck state on every transition. A completed UI update is not proof that an email, payment, deletion, or reservation occurred exactly once.
For long-running work, expose clear states such as pending, needs_confirmation, running, succeeded, rejected, failed, and outcome_unknown. Define refresh, reconnect, cancellation, duplicate-submit, and expiry behavior. A user should be able to understand whether the system has performed an effect or cannot yet prove the result.
Resist Injection, Phishing, and Resource Abuse
Generated UI can make untrusted instructions look official. A retrieved document may try to rename a destructive action, request a credential, or introduce a plausible-looking support link. Rendering a catalog component does not remove this social attack surface.
Build controls at multiple layers:
- label the Agent-generated portion of the screen and preserve product-owned navigation;
- reserve security-sensitive language, identity prompts, and payment controls for fixed application components;
- render external content as text by default, with provenance and a user-mediated open action;
- allow images and URLs only through a proxy or verified allowlist, with size and content-type limits;
- require deliberate confirmation for destructive or externally visible actions;
- prevent the Agent from choosing callback URLs, telemetry destinations, or permission scopes;
- rate-limit surface creation and updates, and measure rejected payloads without retaining sensitive raw content.
Model output is input, not a policy engine. The same is true of retrieved text, tool annotations, and a remote Agent's UI proposal.
Accessibility, Localization, and Privacy Are Contract Requirements
A surface that only looks correct in a screenshot is not a native-quality interface. Make accessibility part of the catalog definition:
- semantic roles, labels, error messages, focus order, and keyboard operation;
- visible focus, sufficient contrast, zoom and reduced-motion behavior;
- screen-reader announcements for incremental updates without repetitive noise;
- localization-ready message keys, plural rules, date/number formatting, and right-to-left layouts;
- no color-only meaning and no time-critical interaction without an accessible alternative.
Do not send sensitive content to telemetry by default. Record a redacted contract revision, rejection class, component counts, latency, action state, and correlation ID. Give user-facing content a retention policy and ensure deletion reaches cached surfaces, analytics payloads, and artifact stores.
Test the Contract Like an API
Unit tests of a happy-path renderer are insufficient. Maintain versioned fixtures for accepted, rejected, and legacy payloads, then test:
| Test class | Example failure to catch |
|---|---|
| Schema and limits | Unknown component, cyclic tree, excessive depth, oversized text |
| Renderer safety | HTML-like text, unexpected property, unsafe URL, update storm |
| Authorization | Cross-tenant token reuse, stale offer, model-supplied account ID |
| Side effects | Duplicate confirmation, timeout after execution, cancellation race |
| Accessibility | Keyboard-only completion, focus after updates, screen-reader labels |
| Localization | Long translations, pluralization, RTL layout, locale-specific amounts |
| Abuse and recovery | Prompt-injected labels, renderer rollback, disconnected client |
Run these tests against the pinned specification and supported clients before an upgrade. Capture an immutable audit event for policy decisions, but keep raw conversation and UI data out of logs unless there is a documented, justified retention basis.
A2UI, A2A, and MCP Are Different Boundaries
An Agent-to-UI contract presents information and collects constrained intent. A2A can support delegation between independently operated Agent services. MCP provides a boundary between an AI application and tools, resources, or prompts.
They can coexist, but none grants authorization to the others. An A2UI control may initiate an application action; an A2A task may call an MCP tool; each boundary still needs trusted identity, tenant and object checks, budget controls, and an auditable side-effect policy.
FAQ
Is declarative UI safer than model-generated HTML?
It can materially reduce one class of code-execution risk when the renderer maps a strict catalog to reviewed local code. It does not protect against unsafe URLs, deceptive labels, data leakage, denial of service, or unauthorized server actions. Treat the description as untrusted input either way.
Should a renderer accept unknown components for forward compatibility?
Not by default. Reject the payload or render a product-owned safe fallback, report the contract mismatch, and upgrade catalog and renderer together through a tested compatibility release. Silent interpretation creates ambiguous behavior and makes review harder.
Can a UI schema validate a payment or deletion?
No. A schema validates structure and sometimes value ranges. The action service must validate identity, tenant, ownership, current state, amount, approval, idempotency, and business invariants against authoritative records at execution time.
What is the smallest useful production rollout?
Start with a read-only surface using a short catalog, server-filtered data, strict payload limits, accessible fixed controls, and observable rejection paths. Add action intents only after the authorization, confirmation, and recovery contracts have been tested.