A2UI Surfaces
A2UI is the declarative format for agent-generated UI: the agent emits a flat list of components against a catalog the client owns, and the client renders it with its own components. Poetry ships both halves. Its registry is the catalog, and its renderer draws surfaces on the server as forms, delivered as Turbo Streams that keep their state. The A2UI Surface demo renders both catalogs and streams a third surface progressively; this guide is the recipe behind it.
Two catalogs
A surface names its catalog, and the renderer binds each catalog to the library differently. The spec's basic catalog, the one Google's examples and the AG-UI middleware default to, maps onto Poetry components: Text renders a Markdown subset, Row, Column, and List are flex layouts, Card, Tabs, and Button are the components of the same name, Modal is a Dialog, TextField is a Field with an Input or Textarea, CheckBox, Slider, and DateTimeInput are the bound controls, ChoicePicker is a radio group, a checkbox list, chips, or a Combobox when filterable, Divider is a Separator, and Image, Icon, Video, and AudioPlayer render natively.
Poetry's own catalog is the registry projected into A2UI's schema: every component with its style axes as enums, options as typed properties, slots as child references, and its rules in the description. Serve it for agents you brief yourself; this site's copy lives at /a2ui/catalog.json. Surfaces against it render straight from the registry, with no hand mapping, so a new component is available the moment it is registered.
# config/routes.rb
get "a2ui/catalog.json" => "a2ui#catalog"
# app/controllers/a2ui_controller.rb
class A2uiController < ApplicationController
CATALOG = Poetry::Agent::A2UI::Catalog.from_registry(Poetry::Ui.root)
def catalog
render json: CATALOG.to_h # CATALOG.inline for a middleware that wants { catalogId, components }
end
end
Fold the envelope
Messages arrive as an envelope: createSurface,
updateComponents, updateDataModel, and
deleteSurface. A session folds them into surfaces and records
what it could not honor as the spec's renderer-to-agent errors, which you
send back to the agent. A child referenced before it arrives is not an
error: streaming delivers components in any order, and the surface renders
what it has.
{ "version": "v1.0",
"createSurface": {
"surfaceId": "signin", "catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json",
"sendDataModel": true, "dataModel": { "email": "" },
"components": [
{ "id": "root", "component": "Column", "children": ["title", "email", "submit"] },
{ "id": "title", "component": "Text", "text": "## Sign in" },
{ "id": "email", "component": "TextField", "label": "Email", "value": { "path": "/email" },
"checks": [{ "condition": { "call": "email", "args": { "value": { "path": "/email" } } },
"message": "Enter a valid email" }] },
{ "id": "submit", "component": "Button", "child": "submit_label", "variant": "primary",
"action": { "event": { "name": "sign_in", "context": { "email": { "path": "/email" } } } } },
{ "id": "submit_label", "component": "Text", "text": "Sign in" } ] } }
session = Poetry::Agent::A2UI::Session.new
session.apply_all(messages) # => ["signin"], the surfaces that changed
session.surfaces["signin"] # the Surface: components, data model, version
session.errors # renderer-to-agent messages to send back, if any
Render a surface
The renderer takes a surface and a view context. With an action URL it renders a form: every bound input is named by its data-model pointer, every agent action is a submit button, and the wrapper carries the surface's version. Without one it renders a plain container. Rendering never raises for an agent's mistake: an unknown component, a dangling reference, a component the library refuses to build, or a function it cannot run renders nothing and lands in the renderer's warnings.
<%# in a view: the surface as a form whose actions post to your endpoint %>
<%= Poetry::Agent::A2UI::Renderer.new(surface, view: self, action_url: surface_action_path).call %>
# in a controller, when streaming
renderer = Poetry::Agent::A2UI::Renderer.new(surface, view: view_context, action_url: surface_action_path)
html = renderer.call
renderer.warnings # => [] - or what could not render: an unknown component, a dangling id, a refused function
Stream updates
Surfaces usually arrive over time: the surface first, its components next, its data after. Streams delivers each change as a Turbo Stream, an append into your container on a surface's first appearance and a morphing versioned replace after, so the page paints progressively and a late frame never overwrites a newer one.
# app/controllers/surfaces_controller.rb
class SurfacesController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
response.headers["X-Accel-Buffering"] = "no"
session = Poetry::Agent::A2UI::Session.new
streams = Poetry::Agent::A2UI::Streams.new(session: session, container: "surfaces",
render: ->(surface) { render_surface(surface) })
agent.each_message do |message| # however your agent hands you A2UI messages
html = streams.apply(message) # append on first appearance, a morphing replace after
response.stream.write(Poetry::Agent::AGUI::TurboStream.sse(html)) unless html.empty?
end
ensure
response.stream.close
end
private
def render_surface(surface, errors: {})
Poetry::Agent::A2UI::Renderer.new(surface, view: view_context, errors: errors,
action_url: surface_action_path).call
end
end
<div id="surfaces" class="flex flex-col gap-6"></div>
<turbo-stream-source src="<%= surfaces_stream_path %>"></turbo-stream-source>
Actions
The spec says two-way binding is local until an action, and a form is exactly that: nothing posts until the user presses a button. The endpoint writes the submitted values into the data model (only paths a bound input carries, each coerced to its kind), runs the surface's checks, and resolves the source component's event into the spec's action message. A valid action goes to the agent; an invalid one comes back with its failures by component, and you re-render the surface with them.
# POST /surfaces/action - the surface form: a2ui[surface], a2ui[action], a2ui[values][<pointer>]
def action
payload = params.require(:a2ui).permit!.to_h
session = surface_session # the surfaces this page holds, rebuilt or loaded
action = session.action(surface_id: payload["surface"], source: payload["action"],
values: payload["values"] || {})
return head :not_found unless action # no agent event on that component
streams = Poetry::Agent::A2UI::Streams.new(session: session, render: ->(surface) { render_surface(surface) })
streams.mark_seen(*session.surfaces.keys)
if action.valid?
reply = agent.reply(action.to_h) # the spec's action message; the agent answers with A2UI messages
render body: streams.apply_all(reply), content_type: "text/vnd.turbo-stream.html"
else
html = render_surface(action.surface, errors: action.errors)
render body: Poetry::Agent::AGUI::TurboStream.vreplace("a2ui-#{action.surface.id}", html, morph: true),
content_type: "text/vnd.turbo-stream.html", status: :unprocessable_entity
end
end
{ "version": "v1.0",
"action": { "name": "sign_in", "surfaceId": "signin", "sourceComponentId": "submit",
"timestamp": "2026-09-01T18:05:00.000Z", "context": { "email": "ada@example.com" } } }
action.forwarded_props is the same message in its AG-UI
placement, with the surface's data model alongside when it asked for
sendDataModel. Over AG-UI it rides the next run's
forwardedProps; the AG-UI Relay guide
shows that loop.
Checks
A component's checks run twice. On the server, every rule in
the surface runs against the submitted model before the action is accepted,
and a failure renders under its control. In the browser, the surface form
carries the same rules with their bindings made absolute, and the
poetry--agent--a2ui-surface controller evaluates them on every
keystroke: a button whose own checks fail is disabled, a failing input turns
invalid and its error slot fills. The five validators and the three
combinators run in both places; a function the browser does not know
passes there and is judged by the server. Where the browser can enforce a
rule natively, the renderer also emits the attribute: required, a pattern,
a length range, an email type.
Functions
The basic catalog's functions ship implemented: formatString
interpolation with paths, literals, and nested calls, the formatters
formatNumber, formatCurrency,
formatDate, and pluralize, the validators
required, regex, length,
numeric, and email, the combinators
and, or, and not, and
openUrl, which validates the scheme and renders as a link.
@index is available inside a list template. Poetry's own
catalog declares the same set, so agents against either catalog use them.
{ "id": "total", "component": "Text",
"text": { "call": "formatString",
"args": { "value": "${/count} ${pluralize(value: ${/count}, one: 'item', other: 'items')}, ${formatCurrency(value: ${/total}, currency: 'USD')}" } } }
Add your own with Functions.basic.define. A function is
renderer-only unless it declares otherwise; one declared for agents answers
an agent's callRendererFunction with a response the session
collects in session.responses.
# config/initializers/a2ui.rb - a function of your own, callable from surfaces and, here, by the agent
Poetry::Agent::A2UI::Functions.basic.define(
"initials", description: "The initials of a full name.", returns: "string",
params: { "value" => { "$ref" => "#{Poetry::Agent::A2UI::COMMON_TYPES}DynamicString" } },
required: %w[value], callers: "rendererOrAgent"
) { |args, _evaluator| args["value"].to_s.split.map { |word| word[0] }.join.upcase }
State across updates
An update replaces the whole surface, which would reset a tab the reader
picked or text they typed. Two things prevent that. Every rendered component
carries a render-stable key, so two renders of a surface are byte-identical
and Turbo morph pairs the same element across them. And the runtime's guard
keeps what the server cannot know while a morph runs: tab selection, a
dialog's open state, an expanded popup, and any control whose value differs
from what the server last rendered. Pass morph: false to
Streams if you want plain swaps.
Transports
The session does not care how messages arrive. Over AG-UI they come inside
the event stream as a2ui-surface activities, and
session.apply_activity(content) folds them; over HTTP they are
whatever your agent returns from a request, as the stream action above
assumes; over MCP they sit inside an application/a2ui+json
resource, and the messages you extract fold the same way.
Setup
gem "poetry-agent"
// app/javascript/controllers/index.js
import { registerPoetryControllers } from "@poetry/controllers"
import { registerPoetryAgent } from "@poetry/agent"
registerPoetryControllers(application)
registerPoetryAgent(application) // the surface controller, the versioned replace, the morph guard
The runtime registers the surface controller that runs checks as the user types, installs the versioned replace stream action, and installs the morph guard. Everything else is Ruby on the server.