specification/v0_9/docs/renderer_guide.md
This document describes the architecture of an A2UI client implementation. The design separates concerns into distinct layers to maximize code reuse, ensure memory safety, and provide a streamlined developer experience when adding custom components.
Both the core data structures and the rendering components interact with Catalogs. Within a catalog, the implementation follows a structured split: from the pure Component Schema down to the Framework-Specific Adapter that paints the pixels.
The A2UI client architecture has a well-defined data flow that bridges language-agnostic data structures with native UI frameworks.
MessageProcessor parses these and updates the SurfaceModel (Agnostic State).Surface (Framework Entry View) listens to the SurfaceModel and begins rendering.Surface instantiates and renders individual ComponentImplementation nodes to build the UI tree.This establishes a fundamental split:
Because A2UI spans multiple languages and UI paradigms, the strictness and location of these architectural boundaries will vary depending on the target ecosystem.
In highly dynamic ecosystems like the web, the architecture is typically split across multiple packages to maximize code reuse across diverse UI frameworks (React, Angular, Vue, Lit).
web_core): Implements the Core Data Layer, Component Schemas, and a Generic Binder Layer. Because TS/JS has powerful runtime reflection, the core library can provide a generic binder that automatically handles all data binding without framework-specific code.react_renderer, angular_renderer): Implements the Framework-Specific Adapters and the actual view implementations (the React Button, Text, etc.).In statically typed languages (and AOT-compiled languages like Dart), runtime reflection is often limited or discouraged for performance reasons.
kotlin_core): Implements the Core Data Layer and Component Schemas. The core library typically provides a manually implemented Binder Layer for the standard Basic Catalog components. This ensures that even in static environments, basic components have a standardized, framework-agnostic reactive state definition.compose_renderer): Uses the predefined Binders to connect to native UI state and implements the actual visual components.In ecosystems dominated by a single UI framework (like iOS with SwiftUI), developers often build a single, unified library rather than splitting Core and Framework into separate packages.
ComponentContext and the framework-specific adapter logic are often tightly integrated.At the heart of the A2UI architecture are five key interfaces that connect the data to the screen.
ComponentApiThe framework-agnostic definition of a component. It defines the name and the exact JSON schema footprint of the component, without any rendering logic. It acts as the single source of truth for the component's contract.
interface ComponentApi {
/** The name of the component as it appears in the A2UI JSON (e.g., 'Button'). */
readonly name: string;
/** The technical definition used for validation and generating client capabilities. */
readonly schema: Schema;
}
ComponentImplementationThe framework-specific logic for rendering a component. It extends ComponentApi to include a build or render method.
How this looks depends on the target framework's paradigm:
Functional / Reactive Frameworks (e.g., Flutter, SwiftUI, React)
interface ComponentImplementation extends ComponentApi {
/**
* @param ctx The component's context containing its data and state.
* @param buildChild A closure provided by the surface to recursively build children.
*/
build(ctx: ComponentContext<ComponentImplementation>, buildChild: (id: string) => NativeWidget): NativeWidget;
}
Stateful / Imperative Frameworks (e.g., Vanilla DOM, Android Views)
Because the catalog only holds a single "blueprint" of each ComponentImplementation, stateful frameworks need a way to instantiate individual objects for each component rendered on screen.
interface ComponentInstance {
mount(container: NativeElement): void;
update(ctx: ComponentContext<ComponentImplementation>): void;
unmount(): void;
}
interface ComponentImplementation extends ComponentApi {
/** Creates a new stateful instance of this component type. */
createInstance(ctx: ComponentContext<ComponentImplementation>): ComponentInstance;
}
SurfaceThe entrypoint widget/view for a specific framework. It is instantiated with a SurfaceModel. It listens to the model for lifecycle events and dynamically builds the UI tree, initiating the recursive rendering loop at the component with ID root.
SurfaceModel & ComponentContextThe state containers.
SurfaceModel: Represents the entire state of a single UI surface, holding the DataModel and a flat list of component configurations.ComponentContext: A transient object created by the Surface and passed into a ComponentImplementation during rendering. It pairs the component's specific configuration with a scoped window into the data model (DataContext).The Data Layer maintains a long-lived, mutable state object. This layer follows the exact same design in all programming languages and does not require design work when porting to a new framework.
To implement the Data Layer effectively, your target environment needs two foundational utilities:
To represent and validate component and function APIs, the Data Layer requires a Schema Library (like Zod in TypeScript or Pydantic in Python) that allows for programmatic definition of schemas and the ability to export them to standard JSON Schema. If no suitable library exists, raw JSON Schema strings or Codable structs can be used.
A2UI relies on standard observer patterns. The Data Layer needs two types of reactivity:
onSurfaceCreated, onAction).dispose() method) to prevent memory leaks.We strictly separate construction from composition. Parent containers do not act as factories for their children.
const child = new ChildModel(config);
parent.addChild(child);
Models must provide a mechanism for the rendering layer to observe changes.
SurfaceGroupModel (lifecycle), SurfaceModel (actions), SurfaceComponentsModel (lifecycle), ComponentModel (updates), and DataModel (data changes).The model is designed to support high-performance rendering through granular updates.
SurfaceComponentsModel notifies when items are added/removed.ComponentModel notifies when its specific configuration changes.DataModel notifies only subscribers to the specific path that changed.The root containers for active surfaces and their catalogs, data, and components.
interface SurfaceLifecycleListener<T extends ComponentApi> {
onSurfaceCreated?: (s: SurfaceModel<T>) => void;
onSurfaceDeleted?: (id: string) => void;
}
class SurfaceGroupModel<T extends ComponentApi> {
addSurface(surface: SurfaceModel<T>): void;
deleteSurface(id: string): void;
getSurface(id: string): SurfaceModel<T> | undefined;
readonly onSurfaceCreated: EventSource<SurfaceModel<T>>;
readonly onSurfaceDeleted: EventSource<string>;
readonly onAction: EventSource<A2uiClientAction>;
}
/**
* Matches 'action' in specification/v0_9/json/client_to_server.json.
*/
interface A2uiClientAction {
name: string;
surfaceId: string;
sourceComponentId: string;
timestamp: string; // ISO 8601
context: Record<string, any>;
}
type ActionListener = (action: A2uiClientAction) => void | Promise<void>;
class SurfaceModel<T extends ComponentApi> {
readonly id: string;
...
readonly catalog: Catalog<T>;
readonly dataModel: DataModel;
readonly componentsModel: SurfaceComponentsModel;
readonly theme?: Record<string, any>;
/** If true, the client should send the full data model with actions. */
readonly sendDataModel: boolean;
readonly onAction: EventSource<A2uiClientAction>;
/**
* Dispatches an action from this surface.
* @param payload The raw action event from the component.
* @param sourceComponentId The ID of the component that triggered the action.
*/
dispatchAction(payload: Record<string, any>, sourceComponentId: string): Promise<void>;
}
SurfaceComponentsModel & ComponentModelManages the raw JSON configuration of components in a flat map.
class SurfaceComponentsModel {
get(id: string): ComponentModel | undefined;
addComponent(component: ComponentModel): void;
readonly onCreated: EventSource<ComponentModel>;
readonly onDeleted: EventSource<string>;
}
class ComponentModel {
readonly id: string;
readonly type: string; // Component name (e.g. 'Button')
get properties(): Record<string, any>;
set properties(newProps: Record<string, any>);
readonly onUpdated: EventSource<ComponentModel>;
}
DataModelA dedicated store for application data.
interface Subscription<T> {
readonly value: T | undefined; // Latest evaluated value
unsubscribe(): void;
}
class DataModel {
get(path: string): any; // Resolve JSON Pointer to value
set(path: string, value: any): void; // Atomic update at path
subscribe<T>(path: string, onChange: (v: T | undefined) => void): Subscription<T>; // Reactive path monitoring
dispose(): void;
}
JSON Pointer Implementation Rules:
/a/b/0/c), create intermediate segments. If the next segment is numeric (0), initialize as an Array [], otherwise an Object {}.undefined removes the key. Setting an array index to undefined preserves length but empties the index (sparse array).Type Coercion Standards:
| Input Type | Target Type | Result |
|---|---|---|
String ("true", "false") | Boolean | true or false (case-insensitive). Any other string maps to false. |
Number (non-zero) | Boolean | true |
Number (0) | Boolean | false |
Any | String | Locale-neutral string representation |
null / undefined | String | "" (empty string) |
null / undefined | Number | 0 |
String (numeric) | Number | Parsed numeric value or 0 |
Transient objects created on-demand during rendering to solve "scope" and binding resolution.
class DataContext {
constructor(dataModel: DataModel, path: string);
readonly path: string;
set(path: string, value: unknown): void;
resolveDynamicValue<V>(v: DynamicValue): V;
subscribeDynamicValue<V>(v: DynamicValue, onChange: (v: V | undefined) => void): Subscription<V>;
nested(relativePath: string): DataContext;
}
class ComponentContext<T extends ComponentApi> {
constructor(surface: SurfaceModel<T>, componentId: string, basePath?: string);
readonly componentModel: ComponentModel;
readonly dataContext: DataContext;
readonly surfaceComponents: SurfaceComponentsModel; // The escape hatch
dispatchAction(action: Record<string, any>): Promise<void>;
}
Escape Hatch: Component implementations can use ctx.surfaceComponents to inspect the metadata of other components in the same surface (e.g. a Row checking if children have a weight property). This is discouraged but necessary for some layout engines.
MessageProcessor)The "Controller" that accepts the raw stream of A2UI messages, parses them, and mutates the Models. It also handles the aggregation of client state for synchronization.
class MessageProcessor<T extends ComponentApi> {
readonly model: SurfaceGroupModel<T>;
constructor(catalogs: Catalog<T>[], actionHandler: ActionListener);
processMessages(messages: A2uiMessage[]): void;
addLifecycleListener(l: SurfaceLifecycleListener<T>): () => void;
getClientCapabilities(options?: CapabilitiesOptions): A2uiClientCapabilities;
/**
* Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled.
* This should be used by the transport layer to populate metadata (e.g., 'a2uiClientDataModel').
*/
getClientDataModel(): A2uiClientDataModel | undefined;
}
When a surface is created with sendDataModel: true, the client is responsible for sending the current state of that surface's data model back to the server whenever a client-to-server message (like an action) is sent.
Implementation Flow:
MessageProcessor tracks the sendDataModel flag for each surface.getClientDataModel() method iterates over all active surfaces and returns a map of data models for those where the flag is enabled.getClientDataModel() before sending any message to the server.a2uiClientDataModel in A2A metadata).updateComponents message provides an existing id but a different type, the processor MUST remove the old component and create a fresh one to ensure framework renderers correctly reset their internal state.To dynamically generate the a2uiClientCapabilities payload (specifically inlineCatalogs), the processor must convert internal component schemas into valid JSON Schemas.
Schema Types Location: Foundational schema types should be defined in a dedicated directory like schema. You can see the renderers/web_core/src/v0_9/schema/common-types.ts file in the reference web implementation as an example.
Detectable Common Types: Shared definitions (like DynamicString) must emit external JSON Schema $ref pointers. This is achieved by "tagging" the schemas using their description property (e.g., REF:common_types.json#/$defs/DynamicString).
When getClientCapabilities() converts internal schemas:
REF:.$ref object.allOf containing ComponentCommon).A catalog groups component definitions and function definitions together.
interface FunctionApi {
readonly name: string;
readonly returnType: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void';
readonly schema: Schema; // The expected arguments
}
/**
* A function implementation. Splitting API from Implementation is less critical than
* for components because functions are framework-agnostic, but it allows for
* re-using API definitions across different implementation providers.
*/
interface FunctionImplementation extends FunctionApi {
// Executes the function logic. Accepts static inputs, returns a value or a reactive stream.
execute(args: Record<string, any>, context: DataContext): unknown | Observable<unknown>;
}
class Catalog<T extends ComponentApi> {
readonly id: string; // Unique catalog URI
readonly components: ReadonlyMap<string, T>;
readonly functions?: ReadonlyMap<string, FunctionImplementation>;
readonly theme?: Schema;
constructor(id: string, components: T[], functions?: FunctionImplementation[], theme?: Schema) {
// Initializes the properties
}
}
Function Implementation Details: Functions in A2UI accept statically resolved values as input arguments (not observable streams). However, they can return an observable stream (or Signal) to provide reactive updates to the UI, or they can simply return a static value synchronously.
Functions generally fall into a few common patterns:
add or concat. Their logic is immediate and depends only on their inputs. They typically return a static value.clock() or networkStatus(). These return long-lived streams that push updates to the UI independently of data model changes.openUrl, closeModal) that return void. These are triggered by user actions rather than interpolation.If a function returns a reactive stream, it MUST use an idiomatic listening mechanism that supports standard unsubscription. To properly support an AI agent, functions SHOULD include a schema to generate accurate client capabilities.
Extensibility is a core feature of A2UI. It should be trivial to create a new catalog by extending an existing one, combining custom components with the standard set.
Example of composing a custom catalog:
# Pseudocode
myCustomCatalog = Catalog(
id="https://mycompany.com/catalogs/custom_catalog.json",
functions=basicCatalog.functions,
components=basicCatalog.components + [MyCompanyLogoComponent()],
theme=basicCatalog.theme # Inherit theme schema
)
While the ComponentImplementation API dictates that a component must be able to build() or mount(), how a developer connects that view to the reactive data model inside ComponentContext varies by language capabilities.
The most straightforward approach. The developer implements the ComponentImplementation and manually manages A2UI reactivity directly within the build method using the framework's native reactive tools (e.g., StreamBuilder in Flutter, or manual useEffect in React).
Example: Flutter Direct Implementation
Widget build(ComponentContext context, ChildBuilderCallback buildChild) {
return StreamBuilder(
// Manually observe the dynamic value stream
stream: context.dataContext.observeDynamicValue(context.componentModel.properties['label']),
builder: (context, snapshot) {
return ElevatedButton(
onPressed: () => context.dispatchAction(context.componentModel.properties['action']),
child: Text(snapshot.data?.toString() ?? ''),
);
}
);
}
For complex applications, scattering manual A2UI subscription logic across all view components becomes repetitive and error-prone.
The Binder Layer is an intermediate abstraction. It takes raw component properties and transforms the reactive A2UI bindings into a single, cohesive stream of strongly-typed ResolvedProps. The view component simply listens to this generic stream.
export interface ComponentBinding<ResolvedProps> {
readonly propsStream: StatefulStream<ResolvedProps>; // e.g. BehaviorSubject
dispose(): void; // Cleans up all underlying data model subscriptions
}
export interface ComponentBinder<ResolvedProps> {
readonly schema: Schema;
bind(context: ComponentContext<any>): ComponentBinding<ResolvedProps>;
}
In languages with powerful runtime reflection (like TypeScript/Zod), the Binder Layer can be entirely automated. You can write a generic factory that inspects a component's schema and automatically creates all necessary data model subscriptions, inferring strict types.
This provides the ultimate "happy path" developer experience. The developer writes a simple, stateless UI component that receives native types, completely abstracted from A2UI's internals.
// 1. The framework adapter infers the prop types from the Binder's Schema.
// The raw `DynamicString` label and `Action` object have been automatically
// resolved into a static `string` and a callable `() => void` function.
// Conceptually, the inferred type looks like this:
interface ButtonResolvedProps {
label?: string; // Resolved from DynamicString
action: () => void; // Resolved from Action
child?: string; // Resolved structural ComponentId
}
// 2. The developer writes a simple, stateless UI component.
// The `props` argument is strictly inferred from the ButtonSchema.
const ReactButton = createReactComponent(ButtonBinder, ({ props, buildChild }) => {
return (
<button onClick={props.action}>
{props.child ? buildChild(props.child) : props.label}
</button>
);
});
Because of the generic types flowing through the adapter, if the developer typos props.action as props.onClick, or treats props.label as an object instead of a string, the compiler will immediately flag a type error.
The adapter acts as a wrapper that instantiates the binder, binds its output stream to the framework's state mechanism, injects structural rendering helpers (buildChild), and hooks into the native destruction lifecycle to call dispose().
// Pseudo-code concept for a React adapter
function createReactComponent(binder, RenderComponent) {
return function ReactWrapper({ context, buildChild }) {
// Hook into component mount
const [props, setProps] = useState(binder.initialProps);
useEffect(() => {
// Create binding on mount
const binding = binder.bind(context);
// Subscribe to updates
const sub = binding.propsStream.subscribe(newProps => setProps(newProps));
// Cleanup on unmount
return () => {
sub.unsubscribe();
binding.dispose();
};
}, [context]);
return <RenderComponent props={props} buildChild={buildChild} />;
}
}
// Pseudo-code concept for an Angular adapter
@Component({
selector: 'app-angular-wrapper',
imports: [MatButtonModule],
template: `
@if (props(); as props) {
<button mat-button>{{ props.label }}</button>
}
`
})
export class AngularWrapper {
private binder = inject(BinderService);
private context = inject(ComponentContext);
private bindingResource = resource({
loader: async () => {
const binding = this.binder.bind(this.context);
return {
instance: binding,
props: toSignal(binding.propsStream) // Convert Observable to Signal
};
},
});
props = computed(() => this.bindingResource.value()?.props() ?? null);
constructor() {
inject(DestroyRef).onDestroy(() => {
this.bindingResource.value()?.instance.dispose();
});
}
}
Regardless of the implementation strategy chosen, the framework adapter or ComponentImplementation MUST strictly manage subscriptions to ensure performance and prevent memory leaks.
A crucial part of A2UI's architecture is understanding who "owns" the data layers.
ComponentModel. It creates, updates, and destroys the component's raw data state based on the incoming JSON stream.ComponentContext and ComponentBinding. When the native framework decides to mount a component onto the screen (e.g., React runs render), the Framework Adapter creates the ComponentContext and passes it to the Binder. When the native framework unmounts the component, the Framework Adapter MUST call binding.dispose().It's important to distinguish between Data Props (like label or value) and Structural Props (like child or children).
"Submit" instead of a DynamicString path). Whenever a data value updates, the binder should emit a new reference (e.g. a shallow copy of the props object) to ensure declarative frameworks that rely on strict equality (like React) correctly detect the change and trigger a re-render.ComponentId (e.g., Card.child), it emits an object like { id: string, basePath: string }.ChildList (e.g., Column.children), it evaluates the array. If the array is driven by a dynamic template bound to the data model, the binder must iterate over the array, using context.dataContext.nested() to generate a specific context for each index, and output a list of ChildNode streams.buildChild(id, basePath) method recursively.Implementation Tip: Context Propagation When implementing the recursive
buildChildhelper, ensure that it correctly inherits the current component's data context path by default. If a nested component (like a Text field inside a List template) uses a relative path, it must resolve against the scoped path provided by its immediate structural parent (e.g.,/restaurants/0), not the root path. Failing to propagate this context is a common cause of "empty" data in nested components.
updateComponents message, you MUST unsubscribe from the old path before subscribing to the new one.deleteSurface message), the implementation MUST hook into its native lifecycle to dispose of all data model subscriptions.Checkable)Interactive components that support the checks property should implement the Checkable trait.
CheckRule conditions defined in its properties.message of the first failing check as a validation error hint.Button clicks) should be reactively disabled or blocked if any validation check fails.The standard A2UI Basic Catalog specifies a set of core components (Button, Text, Row, Column) and functions.
When building libraries that provide the Basic Catalog, it is crucial to separate the pure API (the Schemas and ComponentApi/FunctionApi definitions) from the actual UI implementations.
web_core library to define the Basic Catalog API and Binders once, while separate packages (react_renderer, angular_renderer) provide the native view implementations.Button with their company's internal Design System Button) without having to rewrite the complex A2UI validation, data binding, and capability generation logic.To ensure all components are properly implemented and match the exact API signature, platforms with strong type systems should utilize their advanced typing features. This ensures that a provided renderer not only exists, but its name and schema strictly match the official Catalog Definition, catching mismatches at compile time rather than runtime.
In languages like Kotlin, you can define a strict interface or class that demands concrete instances of the specific component APIs defined by the Core Library.
// The Core Library defines the exact shape of the catalog
class BasicCatalogImplementations(
val button: ButtonApi, // Must be an instance of the ButtonApi class
val text: TextApi,
val row: RowApi
// ...
)
// The Framework Adapter implements the native views extending the base APIs
class ComposeButton : ButtonApi() {
// Framework specific render logic
}
// The compiler forces all required components to be provided
val implementations = BasicCatalogImplementations(
button = ComposeButton(),
text = ComposeText(),
row = ComposeRow()
)
val catalog = Catalog("id", listOf(implementations.button, implementations.text, implementations.row))
In TypeScript, we can use intersection types to force the framework renderer to intersect with the exact definition.
// Concept: Forcing implementations to match the spec
type BasicCatalogImplementations = {
Button: ComponentImplementation & { name: "Button", schema: Schema },
Text: ComponentImplementation & { name: "Text", schema: Schema },
Row: ComponentImplementation & { name: "Row", schema: Schema },
// ...
};
// If a developer forgets 'Row' or spells it wrong, the compiler throws an error.
const catalog = new Catalog("id", [
implementations.Button,
implementations.Text,
implementations.Row
]);
formatString)The Basic Catalog requires a formatString function capable of interpreting ${expression} syntax within string properties.
Implementation Requirements:
DataContext.resolveDynamicValue() or DataContext.subscribeDynamicValue() to recursively evaluate nested expressions or function calls (e.g., ${formatDate(value:${/date})}).${/user/name}) and FunctionCalls (e.g., ${now()}).${ sequences must be handled (typically escaping as \${).The Gallery App is a comprehensive development and debugging tool that serves as the reference environment for an A2UI renderer. It allows developers to visualize components, inspect the live data model, step through progressive rendering, and verify interaction logic.
The Gallery App must implement a three-column layout:
Surface.Every renderer implementation must include a suite of automated integration tests that utilize the Gallery App's logic to verify:
If you are an AI Agent tasked with building a new renderer for A2UI, you MUST follow this strict, phased sequence of operations.
Thoroughly review:
specification/v0_9/docs/a2ui_protocol.md (protocol rules)specification/v0_9/json/common_types.json (dynamic binding types)specification/v0_9/json/server_to_client.json (message envelopes)specification/v0_9/json/catalogs/minimal/minimal_catalog.json (your initial target)Create a comprehensive design document detailing:
ComponentImplementation API for this language and framework?Surface framework entry point function to recursively build children?Implement the framework-agnostic Data Layer (Section 3).
DataModel, ensuring correct JSON pointer resolution and the cascade/bubble notification strategy.ComponentModel, SurfaceComponentsModel, SurfaceModel, and SurfaceGroupModel.DataContext and ComponentContext.MessageProcessor and ClientCapabilities generation.DataModel (especially pointer resolution/cascade logic) and MessageProcessor. Ensure they pass before continuing.Implement the bridge between models and native UI (Section 5 & 6).
ComponentImplementation base class/interface.Surface view/widget that recurses through components.Target the minimal_catalog.json first.
Text, Row, Column, Button, TextField.capitalize function.Catalog.Build the Gallery App following the requirements in Section 8.
specification/v0_9/json/catalogs/minimal/examples/.formatString).specification/v0_9/json/catalogs/basic/examples/.