a2ui_phoenix draws A2UI surfaces in a Phoenix LiveView. The agent's messages build the surfaces, the user's input flows back into their data models, and the user's actions become messages for the agent. Underneath it is a2ui_core, whose processing guide covers the protocol itself.

Setup

def deps do
  [
    {:a2ui_phoenix, "~> 0.1.0"}
  ]
end

The package ships these assets in priv/static:

  • a2ui_phoenix.css, the design system of the basic and MCP catalogs: every component, in light and dark, drawn with --a2ui-* custom properties that a theme sets (see Styling and themes).
  • a2ui_phoenix.js opens the URLs that openUrl actions ask for, fills a slider's track while it moves, and holds the hook of the MCP catalog's McpApp component (see MCP tools and apps).
  • a2ui_mcp_sandbox.html, the sandbox proxy page of McpApp (see Embedding MCP Apps); an app that embeds no MCP Apps does not need it.

In a project created with mix phx.new, import the first two from your assets:

// assets/js/app.js
import "a2ui_phoenix/priv/static/a2ui_phoenix.js"
/* assets/css/app.css */
@import "../../deps/a2ui_phoenix/priv/static/a2ui_phoenix.css";

The basic Icon component draws a Material Symbols ligature (<span class="a2ui-icon">). Load the font for icons to show, for example from Google Fonts:

<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
/>

Another font with the same ligatures works too: name it in --a2ui-icon-font-family.

The session

An A2UIPhoenix.Session is the renderer's state for one conversation: the surfaces the agent built and the resolved component tree of each. Like everything in a2ui_core, it is a plain value. Each call returns the next session and a list of effects for the host:

alias A2UICore.Resolution.NodeResolver
alias A2UIPhoenix.Session

basic = A2UICore.Catalog.Basic.catalog("v1.0")
session = Session.new([basic], clock: fn -> ~U[2026-10-14 12:00:00Z] end)

{:ok, session, effects} =
  Session.handle_message(session, [
    %{
      "version" => "v1.0",
      "createSurface" => %{"surfaceId" => "main", "catalogId" => basic.catalog_id}
    },
    %{
      "version" => "v1.0",
      "updateDataModel" => %{"surfaceId" => "main", "path" => "/name", "value" => ""}
    },
    %{
      "version" => "v1.0",
      "updateComponents" => %{
        "surfaceId" => "main",
        "components" => [
          %{"id" => "root", "component" => "Column", "children" => ["name", "send"]},
          %{
            "id" => "name",
            "component" => "TextField",
            "label" => "Name",
            "value" => %{"@path" => "/name"}
          },
          %{
            "id" => "send",
            "component" => "Button",
            "child" => "sendLabel",
            "action" => %{
              "event" => %{"name" => "submit", "context" => %{"name" => %{"@path" => "/name"}}}
            }
          },
          %{"id" => "sendLabel", "component" => "Text", "text" => "Send"}
        ]
      }
    }
  ])

effects
#=> [
#=>   {:surface_created, "main"},
#=>   {:data_model_updated, "main", "/name"},
#=>   {:components_updated, "main", ["root", "name", "send", "sendLabel"]}
#=> ]

The tree of a surface is an A2UICore.Resolution.NodeResolver. User input names a node by its ref. Typing into the text field writes through its two-way binding:

positions = session |> Session.resolver("main") |> NodeResolver.positions()
name_field = positions["root/children/0"]

{:ok, session, effects} = Session.write(session, "main", name_field.ref, "value", "Ada")

effects
#=> [{:data_model_updated, "main", "/name"}]

Pressing the button invokes its action, which produces a {:send, message} effect: a message for the agent.

send_button = positions["root/children/1"]
{:ok, session, [send: message]} = Session.invoke(session, "main", send_button.ref, "action")

message
#=> %{
#=>   "version" => "v1.0",
#=>   "action" => %{
#=>     "name" => "submit",
#=>     "surfaceId" => "main",
#=>     "sourceComponentId" => "send",
#=>     "timestamp" => "2026-10-14T12:00:00Z",
#=>     "context" => %{"name" => "Ada"}
#=>   }
#=> }

A message the session cannot apply returns {:error, error, session, effects}. The returned session keeps whatever was applied before the failure. When the error concerns a surface, the effects include the error message to report to the agent:

{:error, _error, session, [send: report]} =
  Session.handle_message(session, %{
    "version" => "v1.0",
    "updateDataModel" => %{"surfaceId" => "other", "path" => "/x", "value" => 1}
  })

report
#=> %{
#=>   "version" => "v1.0",
#=>   "error" => %{
#=>     "code" => "INTEGRITY_ERROR",
#=>     "message" => "Surface not found for message: other",
#=>     "surfaceId" => "other"
#=>   }
#=> }

A validation error also names the field that failed, as a JSON Pointer into the message that carried it, such as "path" => "/components/0/text".

EffectWhat the host does
{:send, message}delivers message (action, error, callAgentFunction, rendererFunctionResponse) to the agent
{:open_url, url}opens url in the browser
{:agent_call_sent, call_id, timeout_ms}times the call it just sent, and calls Session.timeout_agent_call/2 when the time is up (see Agent functions)
{:diagnostic, surface_id, level, payload}logs a problem found while resolving (an unknown component type, a binding to a missing path) or an agent function call that failed
{:rpc_settled, call_id, outcome}, {:surface_created, id} and the other processor effectsnothing required; see A2UICore.Processor

In a LiveView

A2UIPhoenix.Live keeps the session in an assign and delivers its effects. A2UIPhoenix.Surface is the LiveComponent that draws one surface:

defmodule MyAppWeb.AgentLive do
  use MyAppWeb, :live_view

  alias A2UIPhoenix.{Live, Session}

  def mount(_params, _session, socket) do
    validation = A2UICore.Validation.Config.relaxed()
    session = Session.new([A2UICore.Catalog.Basic.catalog("v1.0")], validation: validation)
    {:ok, Live.init(socket, session, on_send: &send_to_agent/2)}
  end

  # Messages from the agent, however they arrive.
  def handle_info({:agent, payload}, socket) do
    {_result, socket} = Live.handle_message(socket, payload)
    {:noreply, socket}
  end

  def render(assigns) do
    ~H"""
    <.live_component
      :for={surface <- Session.surfaces(@a2ui)}
      module={A2UIPhoenix.Surface}
      id={"surface-" <> surface.id}
      session={@a2ui}
      surface_id={surface.id}
    />
    """
  end

  # Messages for the agent (`action`, `error`, `callAgentFunction`,
  # `rendererFunctionResponse`), here broadcast to the process that talks to it.
  defp send_to_agent(message, socket) do
    Phoenix.PubSub.broadcast(MyApp.PubSub, "a2ui:renderer", {:renderer, self(), message})
    socket
  end
end

Without :validation, a session applies component updates unchecked. A2UICore.Validation.Config.relaxed/0 checks each component against its catalog before it is applied and still accepts a surface whose components arrive over several messages, as a streaming agent sends them; A2UICore.Validation.Config.strict/0 also rejects an update that leaves the tree incomplete (no root, a missing child, a component nothing reaches). Validate what an agent you do not control sends.

A2UIPhoenix.Live.init/3 assigns the session (as @a2ui by default) and attaches a hook that applies what the surfaces report: input reaching a two-way binding and actions the user triggers. The LiveView does not handle those events itself. Effects are delivered as they happen:

  • {:send, message} goes to the :on_send callback, your transport to the agent;
  • {:open_url, url} is pushed to the browser, where a2ui_phoenix.js opens it;
  • every other effect goes to the :on_effect callback, when you give one.

Local UI state, such as the open tab or an open modal, lives in the A2UIPhoenix.Surface component and never reaches the agent.

Talking to the agent

The session does not know the transport. In the example above, renderer messages are broadcast on the "a2ui:renderer" topic together with the LiveView's pid. Whatever process talks to the agent subscribes to that topic and answers with send(pid, {:agent, payload}). Agent payloads can come from anywhere, for example a client reading an A2A stream, whose parts A2UIAgent.A2A.a2ui_messages/1 turns into messages.

Each message to the agent should carry what the renderer supports, and the data models of surfaces created with "sendDataModel" => true:

session = A2UIPhoenix.Live.session(socket)
{:ok, capabilities} = A2UICore.Processor.renderer_capabilities(session.processor, versions: ["v1.0"])
{:ok, data_model} = A2UIPhoenix.Session.client_data_model(session)

Over A2A these travel in the message metadata, as a2uiRendererCapabilities and a2uiRendererDataModel (a2uiClientCapabilities and a2uiClientDataModel in protocols v0.9 and v0.9.1).

Agent functions

In protocol v1.0 a component can use a function the renderer does not run: one that no catalog of the surface declares, or one that its catalog declares without an implementation, for the agent to run. The session sends such a call to the agent as a callAgentFunction message, and the component waits for the answer: the property is nil and the node is busy, which A2UIPhoenix.Surface marks with aria-busy="true".

alias A2UICore.Resolution.NodeResolver
alias A2UIPhoenix.{Components, Session}

basic = A2UICore.Catalog.Basic.catalog("v1.0")
session = Session.new([basic], default_timeout_ms: 5_000)

{:ok, session, effects} =
  Session.handle_message(session, [
    %{
      "version" => "v1.0",
      "createSurface" => %{"surfaceId" => "main", "catalogId" => basic.catalog_id}
    },
    %{
      "version" => "v1.0",
      "updateComponents" => %{
        "surfaceId" => "main",
        "components" => [
          %{
            "id" => "root",
            "component" => "Text",
            "text" => %{"@call" => "lookupCity", "args" => %{"zip" => "75001"}}
          }
        ]
      }
    }
  ])

Enum.drop(effects, 2)
#=> [
#=>   {:send,
#=>    %{
#=>      "version" => "v1.0",
#=>      "callAgentFunction" => %{
#=>        "surfaceId" => "main",
#=>        "functionCallId" => "call-1",
#=>        "callFunction" => %{"@call" => "lookupCity", "args" => %{"zip" => "75001"}}
#=>      }
#=>    }},
#=>   {:agent_call_sent, "call-1", 5000}
#=> ]

session |> Session.resolver("main") |> NodeResolver.root() |> Map.fetch!(:busy)
#=> true

The agent's agentFunctionResponse settles the call, and the surface is resolved again with the value:

{:ok, session, [{:rpc_settled, "call-1", {:ok, "Paris"}}]} =
  Session.handle_message(session, %{
    "version" => "v1.0",
    "agentFunctionResponse" => %{"functionCallId" => "call-1", "value" => "Paris"}
  })

session |> Session.resolver("main") |> NodeResolver.root() |> Components.props()
#=> %{"text" => "Paris"}

{:agent_call_sent, call_id, timeout_ms} asks the host to time the call: A2UIPhoenix.Live starts a timer and, when it fires, calls A2UIPhoenix.Session.timeout_agent_call/2, which fails the call with TIMEOUT if no answer came. A call that fails leaves the property nil and is reported as a {:diagnostic, surface_id, :error, payload} effect. The call a function-call action makes is sent every time the action runs, and its answer is not kept.

Functions the renderer does run follow their catalog's rules: a function reserved for the agent (allowedCallers: "agentOnly") is never run from a component, and one that needs a user gesture (requiresUserActivation, as openUrl does) runs from an action only. Elsewhere either is an expression error, reported as a diagnostic.

Styling and themes

a2ui_phoenix.css draws every component with the same tokens: CSS custom properties, named as in the reference web renderers. Their defaults are declared with :where(:root), so any rule of the page wins over them, and a theme is a few declarations:

:root {
  --a2ui-color-primary: #0e7490;
  --a2ui-font-family: "Inter", system-ui, sans-serif;
  --a2ui-border-radius: 0.375rem;
}
TokensWhat they set
--a2ui-color-primaryThe accent: primary buttons, checked inputs, selected chips, the active tab, the slider, links, focus rings.
--a2ui-color-on-background, --a2ui-color-backgroundThe text of a surface; the color under surfaces, for the host and components of its own (surfaces are transparent).
--a2ui-color-surface, --a2ui-color-on-surface, --a2ui-color-surface-mutedCards, dialogs and chips; the text on them; quiet fills (code, the slider's track, placeholders).
--a2ui-color-muted, --a2ui-color-subtleSecondary text (captions, guidance); the quietest (input placeholders, list markers).
--a2ui-color-border, --a2ui-color-border-strongDividers and the edges of cards; the edges of controls.
--a2ui-color-input, --a2ui-color-on-inputText fields, date and time inputs, checkboxes and radios.
--a2ui-color-secondary, --a2ui-color-on-secondaryDefault buttons.
--a2ui-color-error, --a2ui-color-success, --a2ui-color-warningFailed checks; status colors for components of your own.
--a2ui-font-family, --a2ui-font-family-title, --a2ui-font-family-monospaceText (the page's, unless set), headings, code and numbers.
--a2ui-font-size, --a2ui-font-size-xs … --a2ui-font-size-2xlThe type scale (xs, s, m, l, xl, 2xl); m is --a2ui-font-size, the body size.
--a2ui-grid-base, --a2ui-spacing-xs … --a2ui-spacing-xl, --a2ui-gapSpace, on a 0.5rem grid; --a2ui-gap separates the children of rows, columns and lists.
--a2ui-border-width, --a2ui-border-radius (and -s, -l, -xl), --a2ui-control-heightShape, and the height of buttons and text fields.
--a2ui-shadow-s, --a2ui-shadow-m, --a2ui-shadow-l, --a2ui-duration, --a2ui-easingDepth and motion.

The colors that come from the primary color (its hover shade, its tint, primary text that keeps its contrast, focus rings, and the text on it, white or black, whichever reads) are computed on each surface, so a new --a2ui-color-primary changes them too. Set --a2ui-color-on-primary, --a2ui-color-primary-hover, --a2ui-color-primary-soft or --a2ui-color-primary-text to choose them instead. Browsers without relative colors put white text on the primary color.

Component tokens refine one component, for example --a2ui-button-border-radius, --a2ui-card-padding, --a2ui-textfield-border-radius, --a2ui-icon-size, --a2ui-image-avatar-size or --a2ui-modal-border-radius; the stylesheet names each one where it uses it.

Light and dark

Colors are light-dark() pairs that follow the page's color-scheme. Set color-scheme: light dark on the page to follow the system, or light or dark to keep one; the a2ui-light and a2ui-dark classes choose for the surfaces inside the element that has them. The stylesheet leaves the page's color scheme to the page.

Markup

Each component has a root class named after it (a2ui-button, a2ui-text-field, a2ui-choice-picker, …) and a class for its variant (a2ui-button-primary, a2ui-text-h2, a2ui-image-avatar, a2ui-list-horizontal). States are ARIA attributes, not classes: aria-invalid on a control whose checks fail, aria-pressed on a selected chip, aria-selected on the active tab, aria-busy on a component that waits for an agent function.

The input components (TextField, CheckBox, ChoicePicker, Slider, DateTimeInput) share one anatomy: an a2ui-field element that holds the label (a2ui-field-label), the control, and the messages of its failed checks (a2ui-field-errors, an a2ui-field-error each). While a field holds no value (an empty text, an unchecked box, no choice) it has data-a2ui-empty, and its messages read as guidance instead of errors, so a form does not open in red. A slider sets --a2ui-slider-fill, the filled part of its track, from its value, and a2ui_phoenix.js keeps it in step while the slider moves.

Layout

A few rules make the usual compositions read well without a style of their own. Cards in a horizontal list are tiles of one width (--a2ui-list-card-width, 15rem) and one height, and the list scrolls sideways when they do not fit. A card is a column that its layout child fills, so a Column with justify: "spaceBetween" keeps a card's button at its bottom. A caption right under a heading in a column is drawn close to it, as its subtitle. Text that waits for an agent function shows its busy indicator on the same line.

Surface themes

A v0.9 or v0.9.1 surface may carry a theme. A2UIPhoenix.Surface sets its primaryColor (a #rrggbb color) as the --a2ui-color-primary custom property of the surface, which restyles everything drawn with the primary color, and shows the agent's agentDisplayName and iconUrl (an http or https URL) above the surface. Protocol v1.0 has no surface theme.

MCP tools and apps

The A2UI MCP catalog lets a surface call Model Context Protocol tools and embed MCP Apps. Add it next to the basic catalog. a2ui_core makes no MCP connections itself: wrap your MCP client in a module implementing A2UICore.MCP.Client, and give it to the catalog (for callMcpTool) and to the session (for the tools an embedded app calls):

defmodule MyApp.Weather do
  @behaviour A2UICore.MCP.Client

  @impl true
  def call_tool(_state, "get_weather", %{"city" => city}) do
    {:ok, %{"content" => [%{"type" => "text", "text" => "Sunny in #{city}"}]}}
  end
end

client = {MyApp.Weather, nil}

session =
  A2UIPhoenix.Session.new(
    [A2UICore.Catalog.Basic.catalog("v1.0"), A2UICore.Catalog.MCP.catalog("v1.0", client: client)],
    mcp_client: client
  )

call_tool/3 is all callMcpTool needs. For the UI resources a tool's result names, also implement read_resource/2 (and list_tools/1, which finds the UI resource a tool declares): without them those resources are not read.

A component or a function call names the MCP catalog by catalogId. When an action calls callMcpTool, A2UIPhoenix.Session.invoke/4 applies what the tool returns: the A2UI messages embedded in its result or held by the UI resource it names (_meta.ui.resourceUri), and, with targetDataPath, the MCP App that resource holds, written there for an McpApp to bind.

Embedding MCP Apps

McpApp runs an app in a double-iframe sandbox: an outer frame loads the sandbox proxy page, which runs the app in an inner frame without allow-same-origin, under a Content Security Policy that blocks network connections beyond the domains the app declares, with sensitive browser features denied unless the component delegates them. Register the hook with your LiveSocket:

// assets/js/app.js
import "a2ui_phoenix/priv/static/a2ui_phoenix.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...window.A2UIPhoenix.hooks},
  params: {_csrf_token: csrfToken}
})

If your LiveSocket already has hooks (an app generated by Phoenix 1.8 passes ...colocatedHooks), keep them: hooks: {...colocatedHooks, ...window.A2UIPhoenix.hooks}. Then serve the proxy page from the package's priv/static:

# lib/my_app_web/endpoint.ex
plug Plug.Static,
  at: "/a2ui_phoenix",
  from: {:a2ui_phoenix, "priv/static"},
  only: ["a2ui_mcp_sandbox.html"]

For stronger isolation, serve the page from an origin of its own and pass its URL as the mcp_sandbox assign of A2UIPhoenix.Surface.

A browser reloads an iframe that moves in the document, and a LiveView patch moves an element when an element without an id appears before it. Keep what you render before your surfaces stable (render a container that is sometimes empty rather than one that comes and goes, and give containers ids): when an app's frame does reload, the hook connects it again and sends it the state, but the app starts over.

The hook answers the app's handshake, display mode and link requests (http and https only) in the browser, resizes the frame (within 200-3000 by 100-2000 pixels), gives the app the component's color scheme as the host context's theme (light or dark; the system's when the page allows both) and tells it when that changes, and keeps the values the component binds in data.paths in step with the data model. What concerns the surface goes to A2UIPhoenix.Session.mcp_app_request/5: a tool call the component allows in allowedTools reaches the agent as an action named after the tool (and runs through the session's :mcp_client, whose result the app gets); a function the component lists in allowedFunctions runs once its arguments pass the schema given there; the app's data changes are written to the bound paths. Payloads with prototype keys, deeper than 10 levels or larger than 64 KiB are refused.

Components of your own

A catalog of your own needs a function component for each of its component types. A component receives these assigns:

  • @props: the node's properties, with every binding replaced by its current value;
  • @node: the A2UICore.Resolution.Node;
  • @ctx: the A2UIPhoenix.Context of the surface;
  • @attrs: the attributes for the component's root element (its DOM id, data-a2ui-id and the ARIA attributes the component declared);
  • @a11y: the component's accessibility attributes.

The catalog declares the component types and their properties. A property that holds a child takes the ComponentId type of common_types.json (or ChildList, for several), as in the basic catalog; that is what makes the renderer resolve it to a node. A property typed as a plain string stays an id, and the child is not drawn. A catalog for the three components below:

text = %{"$ref" => "common_types.json#/$defs/DynamicString"}

mine =
  A2UICore.Catalog.from_json!(%{
    "catalogId" => "https://example.com/catalogs/mine.json",
    "protocolVersion" => "v1.0",
    "components" => %{
      "Badge" => %{
        "type" => "object",
        "properties" => %{
          "component" => %{"const" => "Badge"},
          "label" => text,
          "tone" => %{"type" => "string"}
        },
        "required" => ["component", "label"]
      },
      "Panel" => %{
        "type" => "object",
        "properties" => %{
          "component" => %{"const" => "Panel"},
          "title" => text,
          "child" => %{"$ref" => "common_types.json#/$defs/ComponentId"}
        },
        "required" => ["component", "child"]
      },
      "Note" => %{
        "type" => "object",
        "properties" => %{"component" => %{"const" => "Note"}, "text" => text},
        "required" => ["component", "text"]
      }
    }
  })

session = A2UIPhoenix.Session.new([A2UICore.Catalog.Basic.catalog("v1.0"), mine])

And a function component for each type:

defmodule MyAppWeb.A2UIComponents do
  use Phoenix.Component

  alias A2UIPhoenix.{Components, Context}

  # A label with a tone: "info", "success" or "warning".
  def badge(assigns) do
    ~H"""
    <span class={["badge", "badge-#{@props["tone"]}"]} {@attrs}>{@props["label"]}</span>
    """
  end

  # A container: children are rendered with Components.child/1.
  def panel(assigns) do
    ~H"""
    <section class="panel" {@attrs}>
      <h2>{@props["title"]}</h2>
      <Components.child ctx={@ctx} child={@props["child"]} />
    </section>
    """
  end

  # An input: named with Context.input_name/2, it writes the property it names.
  def note(assigns) do
    ~H"""
    <textarea name={Context.input_name(@node, "text")} {@attrs}>{@props["text"]}</textarea>
    """
  end
end

Pass the components to each surface, by type:

<.live_component
  :for={surface <- Session.surfaces(@a2ui)}
  module={A2UIPhoenix.Surface}
  id={"surface-" <> surface.id}
  session={@a2ui}
  surface_id={surface.id}
  components={%{
    "Badge" => &MyAppWeb.A2UIComponents.badge/1,
    "Panel" => &MyAppWeb.A2UIComponents.panel/1,
    "Note" => &MyAppWeb.A2UIComponents.note/1
  }}
/>

Your map is merged over A2UIPhoenix.Components.Basic.components/0, so the same map can also replace a basic component. A component type with no function renders a placeholder.

An input's value reaches the data model only when the property it names is bound to a data path, as in "text" => %{"@path" => "/note"}. The submitted string is written as it is. A2UIPhoenix.Inputs shows how the basic components convert theirs.

Styled with the same tokens as the basic components, your components match them, in light and dark and under any theme:

.badge {
  padding: 0.125rem var(--a2ui-spacing-m);
  border: var(--a2ui-border-width) solid var(--a2ui-color-border);
  border-radius: 999px;
  background: var(--a2ui-color-surface-muted);
  color: var(--a2ui-color-on-surface);
  font-size: var(--a2ui-font-size-xs);
}

Streaming to other renderers

A2UIPhoenix.Plug.SSE streams A2UI messages to a renderer outside LiveView, such as a web renderer in the browser, as server-sent events, one message per event:

# In a controller:
def events(conn, params), do: A2UIPhoenix.Plug.SSE.stream(conn, MyAgent.messages(params))

# Or mounted in a router; for each request the plug calls MyAgent.messages(conn):
forward "/a2ui/events", A2UIPhoenix.Plug.SSE, source: {MyAgent, :messages, []}

The messages can be any enumerable, including a lazy stream. The response ends when the enumerable does or when the client goes away. A browser's EventSource reconnects when a response ends, so close it on the client once the stream is complete.

A message given as {id, message} is sent with an id: line. A browser's EventSource that loses the connection reconnects with the last id it received in the Last-Event-ID header, and A2UIPhoenix.Plug.SSE.last_event_id/1 reads it, so that the source can continue after that message. The :retry option sets how long the browser waits before it reconnects.