Skip to content
Select themeDarkLightAuto
Select languageEnglish

Overview

A2UI is a declarative, agent-driven UI protocol. An agent streams JSON describing a surface — a flat list of components and the data they bind to — and a renderer draws it. ag-ui-a2ui is the agent half of that exchange.

Nothing in this crate draws pixels, lays out a tree, or evaluates a UI at runtime. It builds A2UI, validates it, and wraps it for transport. Rendering is a genuinely different program, with a widget toolkit, an event loop, and a reactive data model, and it lives on the other end of the wire.

use ag_ui_a2ui::{Catalog, Component, Validator};
use serde_json::json;
let catalog = Catalog::basic();
let components = vec![
Component::new("root", "Column").with("children", json!(["title", "count"])),
Component::new("title", "Text").with("text", json!("Your cart")),
Component::new("count", "Text").with("text", json!({"path": "/items"})),
];
let report = Validator::new(&catalog).validate_surface(&components, Some(&json!({"items": 2})));
assert!(report.is_valid());

A2UI is not part of AG-UI. It has its own specification, its own version number, and its own toolkits in other languages. ag-ui-a2ui ships in this workspace because an AG-UI agent that wants to put a form in front of a user needs it, not because AG-UI defines any of it — and that separation is enforced rather than merely stated. Nothing below ag_ui_a2ui::agui knows what AG-UI is.

Two Cargo features draw the line:

Feature Default What it is
toolkit on Agent-side authoring: operation builders, catalog negotiation, prompt assembly, stream parsing, the recovery loop.
ag-ui on Interop with ag-ui: history entries from AG-UI messages, toolkit tool definitions as offerable tools. Implies toolkit.

Turn ag-ui off and the dependency on ag-ui goes with it:

[dependencies.ag-ui-a2ui]
version = "0.3"
default-features = false
features = ["toolkit"]

What is left is a crate you can drive over A2A or MCP instead. The envelope this crate produces is a plain JSON string, and ag_ui_a2ui::constants::MIME_TYPE is application/a2ui+json for the transports that want a media type. See Feature flags for the full matrix.

Components are sent as a flat adjacency list. Parent and child are linked by id reference, never by nesting: a Card names its child by id, a Column holds an array of ids. The renderer stores every component in a map and rebuilds the tree at render time.

That indirection is what makes the protocol streamable. The agent can define components in any order, and the renderer can start painting as soon as the component with id root arrives. The specification fixes that id — one component in one of the component lists must have id: "root" — and it is the anchor for everything the validator checks.

Ten message envelopes carry all of it — six from the agent, four back:

Direction Payload keys
agent → renderer createSurface, updateComponents, updateDataModel, deleteSurface, callRendererFunction, agentFunctionResponse
renderer → agent action, callAgentFunction, rendererFunctionResponse, error

Each message carries a version discriminator plus exactly one payload key. ag_ui_a2ui::message is the port of all ten, and AgentMessage has a constructor for the four an authoring agent sends most.

A2UI says nothing about how messages reach the renderer, so every toolkit had to agree on something. What they agreed on is a single JSON object keyed by a2ui_operations, carrying an array of operations. The frontend sniffs for exactly that key to decide whether a payload is A2UI at all.

use ag_ui_a2ui::toolkit::ops::{Intent, SurfaceSpec, assemble_ops};
use ag_ui_a2ui::{Component, wrap_as_operations_envelope};
use serde_json::json;
let spec = SurfaceSpec::new("cart")
.with_components(vec![Component::new("root", "Text").with("text", json!("Your cart"))]);
let envelope = wrap_as_operations_envelope(&assemble_ops(Intent::Create, &spec)).unwrap();
assert!(envelope.starts_with(r#"{"a2ui_operations":["#));

The envelope is a JSON string, which is what lets it sit in an AG-UI tool result, an A2A data part, or an MCP tool result without further wrapping. Over AG-UI, the carrier is a tool call named render_a2ui, whose result is the envelope:

use ag_ui_a2ui::constants::RENDER_A2UI_TOOL_NAME;
use ag_ui_a2ui::toolkit::ops::{Intent, SurfaceSpec, assemble_ops};
use ag_ui_a2ui::{Component, wrap_as_operations_envelope};
use ag_ui::RunOutcome;
use ag_ui::server::{Agent, Error, Result, RunContext};
use serde_json::json;
struct Merchant;
impl Agent for Merchant {
type State = ();
async fn run(&self, ctx: &mut RunContext<()>) -> Result<RunOutcome> {
let spec = SurfaceSpec::new("cart").with_components(vec![
Component::new("root", "Text").with("text", json!("Your cart")),
]);
let envelope = wrap_as_operations_envelope(&assemble_ops(Intent::Create, &spec))
.map_err(Error::agent)?;
let mut call = ctx.tool_call(RENDER_A2UI_TOOL_NAME)?;
call.args_json(&json!({ "surfaceId": "cart" }))?;
call.result(envelope)?;
ctx.say("Here is your cart.")?;
Ok(RunOutcome::Success)
}
}

render_a2ui is a call the agent answers itself. No client ever offered it, because there is nothing for a client to execute — the frontend draws the result rather than running the tool. This is why ag_ui::server treats RunAgentInput.tools as a capability list rather than an allow-list: emitting a call for a name absent from that list is a well-formed stream, and the ordering verifier says nothing about it. What the protocol constrains is ordering, and that is what gets checked.

e2e/tests/a2ui_surface.rs is the test that keeps this honest: an agent builds a surface with the toolkit, ships it as a tool result, a real ag_ui::client receives it over a real port, and the operations that come out the other end are asserted to be the ones that went in and to still validate against the catalog they were authored for.

Every message is stamped "version": "v0.9".

The A2UI specification itself has moved on to v1.0. The shipping toolkits have not: TypeScript, .NET, and Python all still stamp v0.9 on the wire, and .NET’s constants file marks those values a cross-language wire contract that must not diverge. Implementing v1.0 wire values today would mean interoperating with none of them, so this crate pins to what the ecosystem actually speaks. v1.0 goes behind a feature when the toolkits move.

The pin is not decorative. Validator reports a message declaring any other version as invalid_value, and the vendored conformance suite’s v0.8 cases are skipped rather than adapted — 63 of the 70 skips are exactly that.

Module What is in it
message The ten protocol envelopes, Component, ChildList, and data-model update semantics.
catalog What a surface may contain. Catalog::basic() is the standard 18-component catalog; Catalog::from_schema parses a custom one.
validate The semantic checks JSON Schema cannot express, plus the envelope and property-type checks a generating model gets wrong.
binding JSON Pointer resolution, template scopes, and the formatString interpolation grammar.
constants The cross-language wire values: the envelope key, the protocol version, the two tool names.
toolkit (feature) Everything between “the user asked for a UI” and “valid A2UI is on the wire”.
agui (feature) The AG-UI glue, and nothing else in the crate knows AG-UI exists.

Two pages go deeper: Authoring surfaces for the toolkit, and Validation for what gets checked and what the conformance suite says about it.