Blog · · 10 min read

How to render generative UI for AI agents safely

How to render generative UI for AI agents safely

Generative UI for AI agents can turn a plain tool response into a chart, form, checkout panel, or project dashboard. It can also create a new security boundary that looks like ordinary product UI. If the model chooses executable content, binds buttons to arbitrary actions, or renders stale state, a polished interface can bypass the controls that protect the underlying product.

A safe implementation gives the product, not the model, control of the contract. That contract converts an agent result into either a trusted native component tree or an isolated app. It sends every user action back through normal authentication, authorization, validation, and state checks. This article defines the contract and shows how to test it.

Why agent-generated interfaces fail

A common implementation maps a tool call to a React component. The Vercel AI SDK generative UI guide demonstrates this pattern directly. The model selects a tool, the application executes it, and the result renders through a corresponding component. The pattern works when the product owns the mapping.

The shortcut fails when the rendering path gains authority that the tool path never had.

A model may produce markup, scripts, URLs, or component names that the client treats as trusted code. A result that should be data becomes an executable extension point.

A rendered button may carry a tool name and arguments chosen earlier in the run. The user clicks after permissions, inventory, account state, or the record version has changed. Replaying the embedded instruction can apply stale authority to current product data.

The component may optimistically claim that a mutation succeeded. If the authoritative server rejected the write, the screen and product state now disagree.

Clients do not all render the same interface. A mobile client may lack a component version. An email, API consumer, or assistive technology may receive no useful representation at all.

Treat these as contract failures, not prompt-quality problems. Better instructions cannot enforce browser isolation, current authorization, resource versions, or accessible fallback behavior.

Design generative UI for AI agents with two rendering classes

Do not put every interactive result into the same trust bucket. Use two explicit classes with different capabilities.

Native declarative components

Use a declarative component tree when the interface can be expressed with product-owned controls such as text, cards, lists, forms, charts, and confirmation panels. The model or tool result supplies data and component identifiers, while the client resolves those identifiers against a trusted catalog.

Google A2UI follows this design. Its early-preview protocol sends declarative JSON and lets the client render from its own component catalog. The project explicitly contrasts this with model-generated executable code. That separation gives the product control over styling, accessibility, platform behavior, and which components exist.

Choose this class when:

  • the interface uses a bounded set of native controls;
  • the host must preserve its own design system and accessibility behavior;
  • every action can map to an existing server operation;
  • unsupported components can degrade to text or a simpler native view.

The client must reject unknown component types and properties. It must not convert an unknown value into raw HTML, a dynamic import, or a remote script.

Isolated interactive apps

Use an isolated app when the interaction genuinely requires custom layout, richer local behavior, or an independent UI bundle. The stable MCP Apps specification defines this class as an HTML resource running in a sandboxed iframe and communicating with the host through auditable JSON-RPC messages. It also defines content security policy metadata, permission requests, message validation, tool visibility, and host-side authorization requirements.

Choose this class only when a native catalog cannot express the job. An isolated app has a larger attack surface, so it needs a declared resource identity, integrity policy, network policy, permission boundary, and message schema. The sandbox does not replace authorization. It limits what compromised or hostile view code can reach before the host decides whether to act.

Use declarative native components unless the feature requires custom executable UI. Richness alone does not justify crossing the boundary.

Define a versioned render envelope

Both rendering classes should arrive inside one server-validated envelope. Do not let the client infer security policy from arbitrary tool output.

A minimal envelope can look like this:

{
  "schema_version": "1.0",
  "render_id": "rnd_01",
  "render_class": "native_catalog",
  "catalog_version": "2026-08-01",
  "resource": {
    "type": "order_summary",
    "version": 3,
    "snapshot_id": "order_842:v17"
  },
  "view": {
    "component": "OrderSummaryCard",
    "props": {
      "order_id": "order_842",
      "total": "42.00",
      "currency": "USD"
    }
  },
  "actions": [
    {
      "event_id": "cancel_order",
      "tool_binding": "orders.request_cancellation",
      "requires_confirmation": true
    }
  ],
  "fallback": {
    "text": "Order order_842 totals USD 42.00 and is awaiting shipment."
  }
}

This is an implementation recommendation, not a standard schema. Its fields enforce six useful rules.

  1. schema_version makes parser behavior explicit. Reject a major version the client does not support.
  2. render_class prevents an isolated app from being mistaken for a native component tree.
  3. catalog_version pins the meaning of component names and properties.
  4. snapshot_id connects what the user saw to an authoritative resource version.
  5. event_id is a stable UI event, not permission to execute arbitrary code.
  6. fallback keeps the result useful when interactive rendering is unavailable.

Validate this envelope on the server before delivery and again at the client boundary. Server validation protects stored and streamed outputs. Client validation protects older or independently deployed clients from a malformed payload.

Bind every UI event to an authorized server action

A click is a new request. It is not a continuation of the authority that produced the screen.

The host should translate an event_id through a server-owned registry. The registry identifies the allowed tool, input builder, confirmation policy, and output renderer. Never accept a tool name, endpoint, redirect, or script directly from component props.

handle_event(user, render_id, event_id, user_input):
    render = load_render(render_id)
    require(render.belongs_to(user.tenant))

    binding = registry.resolve(render.schema_version, event_id)
    require(binding.is_allowed_for(render.render_class))

    resource = load_current_resource(render.resource.id)
    require_authorized(user, binding.tool, resource)
    require_version_match(render.resource.snapshot_id, resource.version)

    args = binding.build_args(resource, user_input)
    validate(args, binding.input_schema)

    receipt = execute_once(binding.tool, args, render_id, event_id)
    return authoritative_snapshot(receipt.resource_id)

The authorization check uses the current user, tenant, tool, and resource. The version check detects a stale screen before a mutation. execute_once needs an idempotency identity derived from the render and event so a double click or reconnect does not repeat the side effect.

For high-impact actions, render the current server-resolved arguments in a confirmation step. The user should confirm the actual order, recipient, amount, or record version, not a sentence generated earlier in the agent run.

The same check applies to MCP Apps. Its specification requires the host to control access to tools and validate messages. A component's request to call a tool is input to the host's policy decision, not an authorization grant.

Reconcile state after every mutation

Do not ask the component to predict the final state. After a successful action, fetch or construct an authoritative snapshot and return it through the same render contract.

OpenAI's MCP Apps UI guide recommends treating the server as the source of truth. Its examples use structured tool results to update the model and component, and they attach components to specific resource URIs. The guide also notes that UI metadata can be ignored by non-UI clients, which makes fallback behavior part of portability rather than an optional polish step.

Use three states for each event:

  • accepted: the host validated and admitted the request;
  • completed: the underlying product operation produced a verified outcome;
  • reconciled: the client rendered an authoritative post-action snapshot.

If execution succeeds but reconciliation fails, show an explicit unknown state with a refresh path. Do not keep an optimistic success message on screen. If the resource version changed before execution, return a conflict state and rerender the current snapshot before asking the user to try again.

For streamed results, do not make an action active until the envelope, bindings, and resource snapshot are complete. A partially streamed button without a stable binding is not actionable UI.

Constrain isolated views beyond the iframe

An iframe is only one control. The host still needs a policy for origins, network access, navigation, downloads, clipboard use, external links, and tool calls.

Start with a deny-by-default content security policy. Allow only the resource origins and connection targets required by that app. Validate every message origin and schema. Associate the app with the server that supplied it, then deny cross-server tool calls unless a separate policy explicitly permits them.

Current project reports show why these controls need tests. goose issue 8014 requests CSP validation, resource-integrity checks, cross-server denial, audit logging, navigation policy, and regression tests before MCP Apps is enabled by default. MCP Context Forge issue 4972 reports app-routing and CSP failures during integration. Treat both as project reports rather than universal product guarantees, and use their failure modes in your test suite.

Record the resource URI, content hash, server identity, render ID, user identity, requested capability, policy decision, tool receipt, and final resource version. That record should explain what the host rendered and why each later event was allowed or denied without logging sensitive component data indiscriminately.

Preserve accessibility and fallback behavior

A component catalog gives the host a chance to encode accessibility once. Each component should define keyboard behavior, focus order, labels, status announcements, error placement, and reduced-motion behavior as part of its contract. A model should choose an approved component and supply bounded content, not invent ARIA relationships or focus management.

For isolated apps, require the same accessibility acceptance tests used for first-party product surfaces. Sandbox isolation does not excuse missing labels, trapped focus, or silent status changes.

Every render envelope also needs a noninteractive representation. At minimum, provide a concise text summary, the current state, and a safe link to a first-party product page. If a client lacks the requested catalog version, it should show the fallback and report an unsupported-render event. It should not display a blank card or attempt a looser parser.

Handle failures without widening trust

Define deterministic behavior for each boundary failure:

  • Unknown schema or catalog version: reject interactive rendering and use the fallback.
  • Unknown component or property: reject the component tree rather than dropping only the suspicious field.
  • Missing resource version: render read-only or request a fresh snapshot.
  • Authorization denial: return a typed denial without asking the model to reinterpret policy.
  • Duplicate event: return the original receipt and current snapshot.
  • Sandboxed app policy violation: terminate the view, record the denied capability, and keep product state unchanged.
  • Reconciliation timeout: show an unknown state and offer a server-backed refresh.
  • Renderer unavailable: preserve the fallback in conversation history and logs.

Do not recover by converting a rejected native payload into raw HTML or by granting a sandbox more network access. Recovery must preserve or reduce capability.

Verify the contract with negative tests

A successful demo proves only the happy path. Add automated tests at the render parser, component registry, event dispatcher, authorization layer, and reconciliation boundary.

Your release suite should prove that:

  • an unknown schema, component, property, action, or resource URI fails closed;
  • a component cannot select an arbitrary tool or external endpoint;
  • a user from another tenant cannot load or act on the render ID;
  • an action denied after the screen was created remains denied;
  • a stale resource version produces a conflict instead of a mutation;
  • repeated clicks and reconnects produce one side effect;
  • a cross-server app tool request is denied by default;
  • CSP, navigation, download, and permission violations are recorded and blocked;
  • a successful mutation is followed by an authoritative snapshot;
  • an unsupported client receives the same essential information through fallback text;
  • keyboard, screen-reader, focus, and error-state tests pass for every catalog component.

Close the evidence gap with one trace that joins render creation, policy decisions, user events, tool receipts, and the final resource version. If the trace cannot prove why a button existed, who clicked it, which current policy allowed it, and what product state resulted, the interface is not ready for action-taking use.

Start with one narrow component

Choose one existing tool result that users already inspect, such as an order summary or deployment status. Define its render envelope, one read-only component, one authorized event, one fallback, and the negative tests above. Ship it behind a capability flag before adding a general catalog or isolated apps.

Build the first screen so the product owns every boundary. Use trusted components or isolated custom code, check current authorization, reconcile authoritative state, preserve accessible interaction, and keep a fallback that still tells the truth. That is the standard to hold as you expand generative UI for AI agents.

References

Be first in line.

Join the waitlist and we'll email you the moment it's ready. No sales call.

Try the demo →
or talk to us