a2ui_agent is the agent's side of A2UI. The agent declares the catalogs it
can write for and agrees with each renderer on the ones they share. It puts
those catalogs' schemas in the model's prompt and reads the model's output
back as messages a renderer will accept. A2UIAgent.A2A.Plug serves the
result over A2A. Each example continues from the one before it.
Catalogs
An agent registers each catalog it can write for as an
A2UIAgent.CatalogConfig. Transformers narrow a catalog to what the agent
needs. A smaller catalog makes a shorter prompt and gives the model fewer
ways to go wrong:
alias A2UIAgent.{CatalogConfig, CatalogTransformer, Generator, RequestProcessor}
basic = A2UICore.Catalog.Basic.catalog("v1.0")
config =
CatalogConfig.new(basic,
transformers: [
CatalogTransformer.component_pruning(["Column", "Text", "TextField", "Button"])
]
)
config.transformed.components |> Map.keys() |> Enum.sort()
#=> ["Button", "Column", "Text", "TextField"]A2UIAgent.CatalogConfig.from_path/2 loads a catalog from a JSON file
instead. The MCP catalog registers like any other, for surfaces that call MCP
tools or embed MCP Apps:
mcp = CatalogConfig.new(A2UICore.Catalog.MCP.declared("v1.0"))
Map.keys(mcp.transformed.components)
#=> ["McpApp"]A A2UIAgent.Generator holds the registered catalogs and the few-shot
examples for the prompt. An example is a list of messages, and it must be
valid in the agent's catalogs:
example = [
%{
"version" => "v1.0",
"createSurface" => %{"surfaceId" => "hello", "catalogId" => basic.catalog_id}
},
%{
"version" => "v1.0",
"updateComponents" => %{
"surfaceId" => "hello",
"components" => [%{"id" => "root", "component" => "Text", "text" => "Hello!"}]
}
}
]
generator = Generator.new([config], examples: [example])Build the generator once, when the agent starts.
Each request
A renderer says which catalogs it supports, keyed by protocol version.
A2UIAgent.Generator.create_processor/3 keeps the agent's catalogs that the
renderer names and returns an A2UIAgent.RequestProcessor for them:
capabilities = %{"v1.0" => %{"supportedCatalogIds" => [basic.catalog_id]}}
{:ok, processor} = Generator.create_processor(generator, capabilities)When the renderer names none of the agent's catalogs, there is nothing to write for. The agent should answer in text:
{:error, error} =
Generator.create_processor(generator, %{
"v1.0" => %{"supportedCatalogIds" => ["https://example.com/catalogs/other.json"]}
})
error.category
#=> :catalogA renderer can also send catalogs inline, under "inlineCatalogs". The
generator uses them only when it was built with
accepts_inline_catalogs: true.
The prompt
A2UIAgent.RequestProcessor.prompt/1 returns the part of the system prompt
that teaches the model A2UI. It has the rules of the output format, the JSON
schemas of the negotiated catalogs, and the examples:
{:ok, snippet} = RequestProcessor.prompt(processor)
snippet |> String.split("\n") |> hd()
#=> "## Workflow Description:"Add the rest of the system prompt yourself: who the agent is and what it does.
Reading the model's output
The model writes direct JSON: ordinary text, with lists of A2UI messages
between <a2ui-json> and </a2ui-json> tags.
A2UIAgent.RequestProcessor.parse_response/3 splits a complete response into
text and messages, and checks the messages the way a renderer holding the
negotiated catalogs would:
model_output = """
Here is a form.
<a2ui-json>
[
{"version": "v1.0",
"createSurface": {"surfaceId": "form",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"}},
{"version": "v1.0",
"updateComponents": {"surfaceId": "form", "components": [
{"id": "root", "component": "Column", "children": ["name"]},
{"id": "name", "component": "TextField", "label": "Name", "value": {"@path": "/name"}}
]}}
]
</a2ui-json>
"""
{:ok, [{:text, text}, {:a2ui, messages}]} = RequestProcessor.parse_response(processor, model_output)
text
#=> "Here is a form."
Enum.map(messages, &(&1 |> Map.delete("version") |> Map.keys()))
#=> [["createSurface"], ["updateComponents"]]The reader forgives small JSON mistakes models make, such as trailing commas, raw line breaks in strings, curly quotes and a Markdown code fence around a block. It does not accept messages a renderer would reject. A component the transformers pruned away, for example, is an error:
{:error, error} =
RequestProcessor.parse_response(processor, """
<a2ui-json>
[{"version": "v1.0",
"createSurface": {"surfaceId": "pic",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"}},
{"version": "v1.0",
"updateComponents": {"surfaceId": "pic", "components": [
{"id": "root", "component": "Image", "url": "https://example.com/cat.png"}]}}]
</a2ui-json>
""")
error.category
#=> :validationThe error message says what is wrong. Giving it back to the model is a good way to get a corrected response.
Streaming
A model's response usually arrives in chunks. The parser that
A2UIAgent.RequestProcessor.parser/1 returns reads one chunk at a time with
A2UIAgent.DirectJson.Parser.parse_chunk/3. It emits text as it arrives, and
each message as soon as it is valid:
alias A2UIAgent.DirectJson.Parser
chunks = Regex.split(~r/(?<=\n)/, model_output)
{_parser, parts} =
Enum.reduce(chunks, {RequestProcessor.parser(processor), []}, fn chunk, {parser, parts} ->
{:ok, parser, new_parts} = Parser.parse_chunk(parser, chunk)
{parser, parts ++ new_parts}
end)
summary =
Enum.map(parts, fn
{:text, text} ->
text
{:a2ui, [%{"createSurface" => %{"surfaceId" => id}}]} ->
{:create, id}
{:a2ui, [%{"updateComponents" => %{"components" => components}}]} ->
{:components, Enum.map(components, & &1["id"])}
end)
summary
#=> ["Here is a form.", {:create, "form"}, {:components, ["root"]}, {:components, ["root", "name"]}]A message can be emitted again as it grows. Here the updateComponents
message comes out first with the components read so far, then again when the
next one arrives, so the renderer can draw the surface while the model is
still writing it. A createSurface is held back until it is complete, and so
is every message after it. When a block closes, it is checked as a whole, so
a block that turns out to be invalid fails at that point.
Serving over A2A
A2UIAgent.A2A.Plug serves an agent over the JSON-RPC binding of
A2A. The agent is a module implementing
A2UIAgent.A2A.Agent:
defmodule GreetingAgent do
@behaviour A2UIAgent.A2A.Agent
alias A2UIAgent.A2A.Request
# No catalog negotiated: answer in text.
@impl true
def run(%Request{processor: nil, text: text}), do: {:ok, "Hello, #{text}!"}
# `events` holds the renderer's messages, such as the actions of its users.
def run(%Request{processor: processor, text: text, events: _events}) do
{:ok, prompt} = A2UIAgent.RequestProcessor.prompt(processor)
{:ok, call_model(prompt, text)}
end
@impl true
def options do
basic = A2UIAgent.CatalogConfig.new(A2UICore.Catalog.Basic.catalog("v1.0"))
[generator: A2UIAgent.Generator.new([basic])]
end
@impl true
def card do
%{
"name" => "Greeting agent",
"capabilities" => %{"extensions" => [A2UIAgent.A2A.agent_extension("1.0")]}
}
end
# Stands in for the model, which gets the prompt and the user's text.
defp call_model(_prompt, text) do
"""
Hello!
<a2ui-json>
[{"version": "v1.0",
"createSurface": {"surfaceId": "hello",
"catalogId": "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"}},
{"version": "v1.0",
"updateComponents": {"surfaceId": "hello", "components": [
{"id": "root", "component": "Text", "text": "Hello, #{text}!"}]}}]
</a2ui-json>
"""
end
endrun/1 gets an A2UIAgent.A2A.Request. When the renderer's capabilities
match a catalog of the generator, processor is the request processor for
them. Otherwise it is nil, and the answer is text. The model's output can
be a string, or an enumerable of chunks as the model streams them.
Mount the plug in a Phoenix router or a Plug.Router. It needs the optional
plug dependency, which Phoenix applications already have:
forward "/a2a", A2UIAgent.A2A.Plug, agent: GreetingAgentPOST answers JSON-RPC: message/send with the finished task, and
message/stream with server-sent events as the output arrives. GET serves
the agent card. The renderer sends its capabilities in the message metadata,
as a2uiRendererCapabilities (a2uiClientCapabilities in protocols v0.9
and v0.9.1).
The answer has a text part for each run of text and a data part for each
A2UI message.
A2UIAgent.A2A.Server.handle/3 does the same work without HTTP. It is
convenient in tests:
body = %{
"jsonrpc" => "2.0",
"id" => 1,
"method" => "message/send",
"params" => %{
"message" => %{
"kind" => "message",
"messageId" => "m1",
"role" => "user",
"parts" => [%{"kind" => "text", "text" => "Ada"}],
"metadata" => %{
"a2uiRendererCapabilities" => %{
"v1.0" => %{"supportedCatalogIds" => [basic.catalog_id]}
}
}
}
}
}
{:reply, response, _extensions} =
A2UIAgent.A2A.Server.handle(body, GreetingAgent, GreetingAgent.options())
parts = response["result"]["status"]["message"]["parts"]
Enum.map(parts, & &1["kind"])
#=> ["text", "data", "data"]
A2UIAgent.A2A.a2ui_messages(parts) |> List.last() |> get_in(["updateComponents", "components"])
#=> [%{"id" => "root", "component" => "Text", "text" => "Hello, Ada!"}]A2UIAgent.A2A also has the helpers for building and reading A2UI parts and
for negotiating the A2UI extension.
MCP and AG-UI
A2UIAgent.MCP gives the values an MCP server puts in its responses, as the
A2UI project's guide to A2UI over MCP describes them. A tool call returns
A2UI messages in an embedded resource with the MIME type
application/a2ui+json, after a text fallback:
result = A2UIAgent.MCP.tool_result(messages, uri: "a2ui://form", text: "Here is a form.")
Enum.map(result["content"], & &1["type"])
#=> ["text", "resource"]
A2UIAgent.MCP.a2ui_messages(result) == {:ok, messages}
#=> trueThe client states its renderer capabilities in initialize, or in the
_meta of each tool call. A2UIAgent.MCP.renderer_capabilities/1 reads
them for the generator:
params = %{"capabilities" => %{"a2ui" => %{"clientCapabilities" => capabilities}}}
{:ok, _processor} =
Generator.create_processor(generator, A2UIAgent.MCP.renderer_capabilities(params))Users' actions come back as calls of the a2ui_action tool, which
A2UIAgent.MCP.action_tool/0 defines and A2UIAgent.MCP.action/2 reads.
A2UIAgent.AGUI does the same for AG-UI, following the conventions of
AG-UI's A2UI middleware. A surface is an activity whose snapshots carry all
its messages so far, so the agent keeps them per surface:
{events, _surfaces} = A2UIAgent.AGUI.snapshots(%{}, messages)
Enum.map(events, &{&1["type"], &1["messageId"]})
#=> [{"ACTIVITY_SNAPSHOT", "a2ui-surface-form"}]A2UIAgent.AGUI.encode/1 writes an event as AG-UI's HTTP binding sends it,
and A2UIAgent.AGUI.user_action/2 reads the action a run starts with.
Express
Direct JSON is the default format. In Express, the other built-in format, the model writes each component as a call whose arguments come in the order its catalog schema declares them, and the agent compiles the result into messages. The model's output is a fraction of the size of the JSON it stands for.
A generator writes Express when its format is A2UIAgent.Express: for every
request with Generator.new(configs, format: A2UIAgent.Express), or for one
request with the :format option of create_processor/3:
{:ok, processor} = Generator.create_processor(generator, capabilities, format: A2UIAgent.Express)
{:ok, snippet} = RequestProcessor.prompt(processor)
snippet |> String.split("\n") |> Enum.at(1)
#=> "# A2UI Express DSL Output Contract"The prompt teaches the notation and gives the signature of each component
and function of the negotiated catalogs, such as
TextField(label, value?, placeholder?, variant? (static), weight? (static), checks? (static)).
The generator's examples are written in Express too.
The model writes Express between <a2ui> and </a2ui> tags:
model_output = """
Here is a form.
<a2ui>
surface("form")
root = Column([name, send])
name = TextField("Name", $/name)
send = Button(Text("Send"), action=Event("send", {name: $/name}))
</a2ui>
"""
{:ok, [{:text, text}, {:a2ui, [message]}]} = RequestProcessor.parse_response(processor, model_output)
text
#=> "Here is a form."
Enum.map(message["createSurface"]["components"], &{&1["id"], &1["component"]})
#=> [{"root", "Column"}, {"name", "TextField"}, {"send", "Button"}, {"send_child", "Text"}]surface("form") starts the surface form, and each variable names a
component. $/name binds a value to the data model, and Event makes an
action. A component written inside another gets an id made from its
parent's: the button's label is send_child. In protocol v1.0 the
components of a new surface come inline in its createSurface message.
The messages are checked against the catalogs as direct JSON ones are, and
errors have the same categories.
Express is read once the response is complete: its parser has no
parse_chunk/3. Served over A2A, an Express agent sends its answer when the
model has finished, also for message/stream.
A2UIAgent.Express.Parser.decompile/2 writes messages back as Express, so
that earlier turns can go into the model's history in the notation it
writes:
{:ok, express} = A2UIAgent.Express.Parser.decompile(RequestProcessor.parser(processor), message)express holds:
surface("form")
root = Column([name, send])
name = TextField("Name", $/name)
send = Button(send_child, _, Event("send", {name: $/name}))
send_child = Text("Send")An update to a surface created in an earlier turn carries no catalog id. With
several catalogs, pass the catalog of each such surface:
decompile(parser, messages, surface_catalogs: %{"form" => catalog_id}).
Express reads arguments in the order the catalog document declares them, so
a catalog must be read with its key order kept. A2UICore.Catalog.Basic,
A2UIAgent.CatalogConfig.from_path/2 and A2UIAgent.CatalogProvider keep
it. A catalog built from a map, such as the result of a plain
Jason.decode/1, has its properties ordered by name; decode with
objects: :ordered_objects instead.
Skills
An Express prompt can also be packaged as skills: markdown files that an
agent framework loads when a task needs them.
A2UIAgent.SkillGenerator.skill_set/2 makes a core skill with the rules of
the notation and one skill for each catalog:
{:ok, format} = A2UIAgent.Express.new([basic])
{:ok, skills} = A2UIAgent.SkillGenerator.skill_set(format)
Enum.map(skills, & &1.filename)
#=> ["a2ui-core/SKILL.md", "a2ui-basic/SKILL.md"]A2UIAgent.SkillGenerator.skill/2 makes one skill with the whole prompt
instead. A2UIAgent.Skill.to_markdown/1 gives a skill's file, and
A2UIAgent.Skill.export/2 writes skills into a directory.
Building components in Elixir
Not every component has to come from the model. A2UIAgent.Builder builds
a component tree in Elixir and flattens it into the component list an
updateComponents message carries:
import A2UIAgent.Builder
import A2UIAgent.Builder.Basic
tree =
card(
id: "profile",
child:
column(
children: [
text(text: bind("/user/name"), variant: "caption"),
button(child: text(text: "Edit"), action: event("edit_profile"))
]
)
)
components = flatten(tree, catalog: basic)
Enum.map(components, &{&1["id"], &1["component"]})
#=> [{"profile__text_1", "Text"}, {"profile__text_2", "Text"}, {"profile__button_1", "Button"}, {"profile__column_1", "Column"}, {"profile", "Card"}]A node placed in a property of another node becomes a component of its
own, and the property holds its id. Children come before their parents,
and a node without an id gets one made from the root's. With catalog:,
each component gets the defaults its catalog schema declares, and a
component the catalog rejects raises:
hd(components)
#=> %{"id" => "profile__text_1", "component" => "Text", "text" => %{"@path" => "/user/name"}, "variant" => "caption"}
Enum.at(components, 1)["variant"]
#=> "body"A2UIAgent.Builder.call/3 writes a function call, and from v1.0 takes
catalog_id: for a function of a catalog other than the surface's.
A2UIAgent.Builder.messages/2 puts a tree in messages for a surface: a
createSurface when it is given a catalog id, the updateComponents, and
an updateDataModel for each data model entry.
Macros
A macro is a component the model can write that the agent expands into the catalog's components before they reach the renderer. One macro call stands for a whole subtree, so the model writes less and the subtree always comes out the same. A macro has parameters and a function that builds its tree:
alias A2UIAgent.{Macro, MacroExpander}
status_badge =
Macro.new(
"StatusBadge",
[status: :string, title: :string],
fn %{status: status, title: title} ->
row(children: [text(text: String.upcase(status), variant: "caption"), text(text: title)])
end,
description: "A status and a title, side by side."
)
expander = MacroExpander.new(basic, [status_badge])
{:ok, inference_catalog} = MacroExpander.inference_catalog(expander)
List.last(inference_catalog.component_order)
#=> "StatusBadge"The inference catalog is the base catalog with the macros added as
components. Register it in a A2UIAgent.CatalogConfig in place of the base
catalog, so that the prompt describes the macros and the model's messages
are checked against them. Before the messages go to the renderer,
A2UIAgent.MacroExpander.to_transport/2 expands the macros:
model_messages = [
%{
"version" => "v1.0",
"updateComponents" => %{
"surfaceId" => "status",
"components" => [
%{"id" => "root", "component" => "StatusBadge", "status" => "online", "title" => "API"}
]
}
}
]
{:ok, [message]} = MacroExpander.to_transport(expander, model_messages)
Enum.map(message["updateComponents"]["components"], &{&1["id"], &1["component"]})
#=> [{"root__text_1", "Text"}, {"root__text_2", "Text"}, {"root", "Row"}]The macro component's id becomes the id of the tree's root, and the other
components are named after it. A macro may use other macros with
A2UIAgent.Macro.call/2.