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"}
]
endThe 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.jsopens the URLs thatopenUrlactions ask for, fills a slider's track while it moves, and holds the hook of the MCP catalog'sMcpAppcomponent (see MCP tools and apps).a2ui_mcp_sandbox.html, the sandbox proxy page ofMcpApp(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".
| Effect | What 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 effects | nothing 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
endWithout :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_sendcallback, your transport to the agent;{:open_url, url}is pushed to the browser, wherea2ui_phoenix.jsopens it;- every other effect goes to the
:on_effectcallback, 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)
#=> trueThe 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;
}| Tokens | What they set |
|---|---|
--a2ui-color-primary | The accent: primary buttons, checked inputs, selected chips, the active tab, the slider, links, focus rings. |
--a2ui-color-on-background, --a2ui-color-background | The 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-muted | Cards, dialogs and chips; the text on them; quiet fills (code, the slider's track, placeholders). |
--a2ui-color-muted, --a2ui-color-subtle | Secondary text (captions, guidance); the quietest (input placeholders, list markers). |
--a2ui-color-border, --a2ui-color-border-strong | Dividers and the edges of cards; the edges of controls. |
--a2ui-color-input, --a2ui-color-on-input | Text fields, date and time inputs, checkboxes and radios. |
--a2ui-color-secondary, --a2ui-color-on-secondary | Default buttons. |
--a2ui-color-error, --a2ui-color-success, --a2ui-color-warning | Failed checks; status colors for components of your own. |
--a2ui-font-family, --a2ui-font-family-title, --a2ui-font-family-monospace | Text (the page's, unless set), headings, code and numbers. |
--a2ui-font-size, --a2ui-font-size-xs … --a2ui-font-size-2xl | The 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-gap | Space, 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-height | Shape, and the height of buttons and text fields. |
--a2ui-shadow-s, --a2ui-shadow-m, --a2ui-shadow-l, --a2ui-duration, --a2ui-easing | Depth 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: theA2UICore.Resolution.Node;@ctx: theA2UIPhoenix.Contextof the surface;@attrs: the attributes for the component's root element (its DOM id,data-a2ui-idand 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
endPass 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.