Skip to content

A2UI Protocol

The A2UI (Agent-to-User Interface) Protocol is a JSON-based protocol for driving user interfaces from an AI agent. Freesail implements A2UI v0.9 as the communication layer between the Gateway and the frontend renderer.

This page documents A2UI as implemented in Freesail. For the full specification, see a2ui.org.


In Freesail, the agent never sends A2UI messages directly. Instead, it calls MCP tools exposed by the Gateway. The Gateway validates each call, translates it into an A2UI message, and streams it to the browser over SSE. User actions travel in the opposite direction — the frontend POSTs them to the Gateway, which queues them for the agent to pick up via MCP.

┌─────────────────┐ MCP tools ┌──────────────────┐ A2UI over SSE ┌───────────────────────┐
│ AI Agent │ ─────────────────► │ Freesail Gateway │ ────────────────► │ Freesail ReactUI │
│ │ │ │ │ Browser │
│ │ ◄───────────────── │ │ ◄──────────────── │ │
└─────────────────┘ MCP resources └──────────────────┘ HTTP POST └───────────────────────┘
(push via
ResourceUpdated) (/message)
MCP toolA2UI message producedDescription
create_surfacecreateSurfaceInitialise a new UI surface
update_componentsupdateComponentsSet or update the component tree
update_data_modelupdateDataModelUpdate data without resending components
delete_surfacedeleteSurfaceRemove a surface from the UI
get_data_modelgetDataModel (Freesail extension)Request the current data model from the frontend
get_component_treegetComponentTree (Freesail extension)Request the current component tree from the frontend

When a user interacts with the UI, the frontend POSTs an action to /message. The Gateway queues it and sends a ResourceUpdated notification to any agent subscribed to the session’s resource URI. The agent retrieves queued actions by:

  • Listening for notifications/resources/updated on mcp://freesail.dev/sessions/{sessionId} (push — preferred; used by @freesail/agent-runtime)
  • Calling the get_pending_actions MCP tool (polling)
  • Calling get_all_pending_actions to drain all sessions at once

The Gateway injects two synthetic actions into the queue automatically — the agent does not need to do anything special to receive them:

Action nameWhen firedContext
__session_connectedA browser tab connects via SSE{ sessionId }
__session_disconnectedThe browser tab closes{ sessionId }

These arrive via the action queue just like user actions. The @freesail/agent-runtime package handles them automatically and calls onSessionConnected / onSessionDisconnected on your agent class.


  1. Get catalogs — call get_catalogs(sessionId) to retrieve the component catalogs the client registered. The catalogId is required for create_surface. The content field lists all available components — never guess or invent component names.
  2. Get component/function details — call get_component_details and get_function_details before building a surface to understand each component’s required and optional properties.
  3. Create a surface — call create_surface with a unique surfaceId and the exact catalogId from step 1.
  4. Plan, then execute incrementally — decide the layout before sending components. Build the UI progressively so the user sees it taking shape.
  5. Add components — call update_components with a flat array of component definitions. One component must have id: "root". When adding a new component, always update its intended parent in the same call — orphan components are not rendered.
  6. Add client-side logic — use checks for input validation and formatString for text interpolation, keeping logic local without round-trips.
  7. Set data — call update_data_model to populate values that components reference via data bindings.

Initialises a new UI surface for a session. Must be called before update_components or update_data_model for any agent-managed surface.

The Gateway validates that the catalogId is one the client has registered. If the catalog is not supported by the session, the call returns an error.

ArgumentTypeRequiredDescription
surfaceIdstringUnique identifier. Must start with an alphanumeric character and contain only alphanumerics or underscores (^[a-zA-Z0-9][a-zA-Z0-9_]*$).
catalogIdstringThe catalog URI returned by get_catalogs.
sessionIdstringThe target client session.
sendDataModelbooleanIf true, the client sends the full surface data model alongside every user action. Defaults to false in Freesail.

Surface naming rules (enforced by the Gateway):

  • Agent-managed surfaces: alphanumeric start, alphanumerics and underscores only (e.g. user_profile, dashboard)
  • Client-managed surfaces start with __ (e.g. __chat). Agents cannot create or delete these — only call update_data_model on them.
  • Before creating a new surface, consider whether an existing one can be reused or deleted to save screen space.

Updates the component tree of a surface. The Gateway validates every component name against the surface’s catalog before forwarding — unknown component names return an error.

ArgumentTypeRequiredDescription
surfaceIdstringThe target surface (must already exist).
sessionIdstringThe target client session.
componentsarrayFlat list of component objects.

Root component rule: One component must have id: "root". Until the root is defined no other components are visible.

Orphan rule: When adding a new component, update its parent in the same call to add the new id to children or child. Components not referenced by any parent are silently ignored.

child vs children:

  • children: ["id1", "id2"] — for containers with multiple children (Row, Column, List)
  • child: "id" — for single-child containers (Card, Tab)

Updates data in a surface without resending the component tree. Use this for all content that changes after initial render. Follows upsert semantics: creates the path if it doesn’t exist, updates if it does, removes the key if value is omitted.

ArgumentTypeRequiredDescription
surfaceIdstringThe target surface (must already exist, or be a __ surface).
sessionIdstringThe target client session.
pathstringJSON Pointer to the location to update. Defaults to / (replaces entire model).
valueanyThe new value. If omitted, removes the key at path.

Reserved paths: Paths starting with __ (e.g. /__componentState) are reserved for client-side use. The Gateway will reject any agent write to these paths.

Removes a surface and all its components and data from the UI.

ArgumentTypeRequiredDescription
surfaceIdstringThe surface to remove. Agents cannot delete __ surfaces.
sessionIdstringThe target client session.

Requests the current data model from the client for a surface. The Gateway sends a getDataModel message downstream and waits up to 10 seconds for the client to respond. Useful when sendDataModel is not enabled and the agent needs to read the current form state.

ArgumentTypeRequiredDescription
surfaceIdstringThe surface to read.
sessionIdstringThe target client session.

Requests the current component tree from the client for a surface. The Gateway sends a getComponentTree message downstream and waits up to 10 seconds for the client to respond with the flat component list and root component ID.

This is useful after reconnects or agent restarts to verify the current rendered state, or when the agent needs to understand the current layout before deciding whether to update or replace it.

ArgumentTypeRequiredDescription
surfaceIdstringThe surface to read.
sessionIdstringThe target client session.

Returns: { success, surfaceId, components, rootId } where components is the flat array of component objects as the client currently holds them, and rootId is the ID of the root component (typically "root").


These are the actual JSON messages the Gateway sends to the browser over SSE. Documented here for reference — agents produce them by calling the MCP tools above, not by writing them directly.

Every message carries a version: "v0.9" field and exactly one message-type key.

{
"version": "v0.9",
"createSurface": {
"surfaceId": "user_profile",
"catalogId": "https://freesail.dev/catalogs/standard-catalog.json",
"sendDataModel": true
}
}
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "user_profile",
"components": [
{ "id": "root", "component": "Column", "gap": "16px", "children": ["name_text", "email_text"] },
{ "id": "name_text", "component": "Text", "text": "Jane Doe", "variant": "h2" },
{ "id": "email_text", "component": "Text", "text": { "path": "/user/email" }, "color": "textSecondary" }
]
}
}
{ "version": "v0.9", "updateDataModel": { "surfaceId": "user_profile", "path": "/user/email", "value": "jane@example.com" } }

Remove a key — omit value:

{ "version": "v0.9", "updateDataModel": { "surfaceId": "user_profile", "path": "/user/tempData" } }

Replace the entire model — omit path:

{ "version": "v0.9", "updateDataModel": { "surfaceId": "user_profile", "value": { "user": { "name": "Jane", "email": "jane@example.com" } } } }
{ "version": "v0.9", "deleteSurface": { "surfaceId": "user_profile" } }

Sent downstream to request the client’s current data model for a surface. The client responds by POSTing a __get_data_model_response action to /message with the full data model in the action context.

{ "version": "v0.9", "getDataModel": { "surfaceId": "contact_form" } }

Sent downstream to request the client’s current component tree for a surface. The client responds by POSTing a __get_component_tree_response action to /message with the flat component list and root component ID in the action context.

{ "version": "v0.9", "getComponentTree": { "surfaceId": "contact_form" } }

The client response includes components (the flat array of component objects as currently rendered) and root_id (the ID of the root component). The Gateway resolves the pending MCP tool call with this data — the agent receives { success, surfaceId, components, rootId } as the tool result.


The browser POSTs these to /message. The Gateway queues them for the agent and fires a ResourceUpdated notification on the session’s MCP resource URI.

Sent when a user interacts with a component that has an action defined.

PropertyTypeRequiredDescription
namestringThe action name (e.g. submit_form, chat_send).
surfaceIdstringThe surface where the action originated.
sourceComponentIdstringThe component that triggered the action.
timestampstringISO 8601 timestamp.
contextobjectThe resolved context payload from the component’s action definition.

When sendDataModel: true, the client includes the full surface data model in the request body (not a header). The Gateway attaches it to the queued action as clientDataModel, so the agent receives it via the action payload.

{
"version": "v0.9",
"action": {
"name": "submit_form",
"surfaceId": "checkout",
"sourceComponentId": "submit-btn",
"timestamp": "2026-01-01T12:00:00Z",
"context": { "email": "jane@example.com", "total": 99.99 }
},
"dataModel": {
"surfaceId": "checkout",
"dataModel": { "email": "jane@example.com", "total": 99.99 }
}
}

Reports a client-side validation or runtime error.

{
"version": "v0.9",
"error": {
"code": "VALIDATION_FAILED",
"surfaceId": "user_profile",
"path": "/components/0/text",
"message": "Expected stringOrPath, got integer"
}
}

Component properties that accept dynamic values use one of three forms:

FormExampleDescription
Literal"Jane Doe"A static value baked into the component definition.
Path binding{ "path": "/user/name" }Resolved against the surface data model at render time.
Function call{ "call": "formatDate", "args": { ... } }A client-side function evaluated at render time.

Prefer data bindings for any content that may change — they allow efficient update_data_model calls without resending components.

Paths starting with / are absolute and always resolve from the data model root. Paths without a leading / are relative and only make sense inside a template, resolving against the current item.

{ "path": "/user/name" } // ✅ absolute — always works
{ "path": "name" } // ✅ relative — valid inside a template only
{ "path": "/name" } // ❌ looks absolute but won't resolve to item data inside a template

The formatString function embeds data model values and function results into strings using ${...} expressions.

{
"component": "Text",
"text": {
"call": "formatString",
"args": { "value": "Hello, ${/user/name}! It is ${now()}." }
}
}
PatternExampleDescription
Absolute path${/user/name}Value at /user/name in the root data model.
Relative path${name}Value of name in the current template item scope.
No-arg function${now()}Call a client-side function with no arguments.
Function with args${formatDate(value:${/date}, format:'yyyy-MM-dd')}Named arguments; nested ${...} for data bindings inside args.
Nested functions${upper(${/user/name})}Chain function calls.

Escape a literal ${ with \${. Non-string values are coerced: numbers and booleans become their string form, null/undefined become "", objects and arrays are JSON-stringified.


To render a list of items from the data model, set children to a template object instead of an array:

{
"id": "item_list",
"component": "Column",
"children": { "componentId": "item_card", "path": "/items" }
}

The client iterates over the array at /items and renders item_card once per element, with data bindings scoped to that element.

Inside a template, use relative paths (no leading /) to access the current item’s fields:

{ "id": "item_card", "component": "Text", "text": { "path": "name" } }

With formatString inside a template:

{
"id": "item_card",
"component": "Text",
"text": { "call": "formatString", "args": { "value": "${name} — ${price}" } }
}

List items must be objects. Plain arrays of strings or numbers cannot use path bindings. Wrap scalar values as objects:

// ❌ Wrong — cannot bind to fields of a plain string
"tags": ["urgent", "reviewed"]
// ✅ Correct — wrap as objects, use { "path": "label" }
"tags": [{ "label": "urgent" }, { "label": "reviewed" }]

Interactive components define an action property specifying what happens on interaction.

Fires a named event. The context object is resolved at interaction time — path bindings capture the current data model state.

{
"id": "submit_btn",
"component": "Button",
"label": "Submit",
"variant": "primary",
"action": {
"event": {
"name": "submit_form",
"context": {
"name": { "path": "/formData/name" },
"email": { "path": "/formData/email" }
}
}
}
}

When clicked, the agent receives context: { "name": "Alice", "email": "alice@example.com" } — bindings already resolved.

Executes a client-side function without notifying the agent. Useful for openUrl, show, hide:

{
"id": "help_btn",
"component": "Button",
"label": "Help",
"action": {
"functionCall": { "call": "openUrl", "args": { "url": "https://docs.example.com" } }
}
}

To show or hide a component (e.g. a Modal) without a server round-trip:

{
"id": "open_modal_btn",
"component": "Button",
"label": "Open",
"action": {
"functionCall": { "call": "show", "args": { "componentId": "my_modal" } }
}
}

Input components (TextField, CheckBox, Slider, ChoicePicker, DateTimeInput, Dropdown) write their value back to the bound data model path immediately on user interaction. No network request is triggered — changes are local until a button action fires.

The pattern: bind the input’s value to a data model path, then reference the same path in a Button’s action context. When the button is clicked, the agent receives the current value already resolved:

[
{ "id": "name_field", "component": "TextField", "label": "Full Name", "variant": "shortText", "value": { "path": "/formData/name" } },
{ "id": "email_field", "component": "TextField", "label": "Email", "variant": "shortText", "value": { "path": "/formData/email" } },
{
"id": "submit_btn", "component": "Button", "label": "Submit", "variant": "primary",
"action": {
"event": {
"name": "submit_form",
"context": {
"name": { "path": "/formData/name" },
"email": { "path": "/formData/email" }
}
}
}
}
]

Components support a checks array for validation without agent round-trips.

  • Input components: a failed check displays the message inline on the field.
  • Button: a failed check disables the button.

Each check evaluates a condition (a DynamicBoolean — literal, path binding, or function call). If the condition is false, the check fails.

{
"id": "email_field",
"component": "TextField",
"label": "Email",
"value": { "path": "/formData/email" },
"checks": [
{
"condition": { "call": "required", "args": { "value": { "path": "/formData/email" } } },
"message": "Email is required."
},
{
"condition": { "call": "email", "args": { "value": { "path": "/formData/email" } } },
"message": "Enter a valid email address."
}
]
}
{
"id": "submit_btn",
"component": "Button",
"label": "Submit",
"checks": [
{
"condition": {
"call": "and",
"args": {
"a": { "call": "required", "args": { "value": { "path": "/formData/name" } } },
"b": { "call": "email", "args": { "value": { "path": "/formData/email" } } }
}
},
"message": "Please fill in all required fields."
}
]
}

Semantic tokens adapt automatically to light and dark themes:

TokenCSS variableUsage
bg--freesail-bgPage/surface root background
bgRaised--freesail-bg-raisedCard and panel backgrounds
bgMuted--freesail-bg-mutedSubtle background fills, dividers
bgOverlay--freesail-bg-overlayModal/dialog overlay backdrop
textForeground--freesail-text-foregroundPrimary body text
textSecondary--freesail-text-secondarySecondary or hint text
primary--freesail-primaryBrand accent colour
primaryHover--freesail-primary-hoverHover state for primary
primaryForeground--freesail-primary-foregroundText on primary-coloured backgrounds
error--freesail-errorError states
success--freesail-successSuccess states
warning--freesail-warningWarning states
info--freesail-infoInformational states
border--freesail-borderBorders and dividers
radiusSm / radiusMd / radiusLg--freesail-radius-*Border radii
shadowSm / shadowMd--freesail-shadow-*Box shadows
spaceXsspaceXl--freesail-space-*Fluid spacing (container-relative via cqi)
typeCaptiontypeH1--freesail-type-*Fluid font sizes (container-relative via cqi)
iconSmicon4xl--freesail-icon-*Fluid icon sizes (container-relative via cqi)

Spacing, typography, and icon tokens use fluid clamp() values with cqi units so they scale proportionally with the container width.

{ "component": "Text", "text": "Hint text", "color": "textSecondary" }

Use explicit CSS colors only when a specific color is required for meaning (e.g. a status indicator), or when the catalog doesn’t support semantic tokens. Prefer colors that work in both light and dark themes.

{ "component": "Text", "text": "Online", "color": "#22c55e" }

A contact form showing the full workflow — the agent calls MCP tools in sequence, and the Gateway translates each into the corresponding A2UI message:

// 1. Discover what components are available
const catalogs = await mcpClient.callTool({ name: 'get_catalogs', arguments: { sessionId } });
// catalogs → [{ catalogId: 'https://freesail.dev/catalogs/standard-catalog.json', title: '...', content: '...' }]
// 2. Get details for the components you plan to use
await mcpClient.callTool({
name: 'get_component_details',
arguments: { sessionId, catalogId: 'https://freesail.dev/catalogs/standard-catalog.json', components: ['Card', 'Column', 'Text', 'TextField', 'Button'] }
});
// 3. Create the surface
await mcpClient.callTool({
name: 'create_surface',
arguments: { surfaceId: 'contact_form', catalogId: 'https://freesail.dev/catalogs/standard-catalog.json', sessionId }
});
// 4. Define the component tree
await mcpClient.callTool({
name: 'update_components',
arguments: {
surfaceId: 'contact_form', sessionId,
components: [
{ id: 'root', component: 'Card', child: 'form_col' },
{ id: 'form_col', component: 'Column', gap: '16px', children: ['header', 'name_field', 'email_field', 'submit_btn'] },
{ id: 'header', component: 'Text', text: 'Contact Us', variant: 'h2' },
{ id: 'name_field', component: 'TextField', label: 'Full Name', variant: 'shortText', value: { path: '/contact/name' },
checks: [{ condition: { call: 'required', args: { value: { path: '/contact/name' } } }, message: 'Name is required.' }] },
{ id: 'email_field', component: 'TextField', label: 'Email', variant: 'shortText', value: { path: '/contact/email' },
checks: [{ condition: { call: 'email', args: { value: { path: '/contact/email' } } }, message: 'Enter a valid email address.' }] },
{ id: 'submit_btn', component: 'Button', label: 'Send Message', variant: 'primary',
action: { event: { name: 'submit_contact', context: { name: { path: '/contact/name' }, email: { path: '/contact/email' } } } },
checks: [{ condition: { call: 'and', args: {
a: { call: 'required', args: { value: { path: '/contact/name' } } },
b: { call: 'email', args: { value: { path: '/contact/email' } } }
}}, message: 'Please fill in all required fields.' }] }
]
}
});
// 5. Seed the data model
await mcpClient.callTool({
name: 'update_data_model',
arguments: { surfaceId: 'contact_form', sessionId, path: '/contact', value: { name: '', email: '' } }
});
// 6. Later — pick up the user's submission via push notification
// (handled automatically by @freesail/agent-runtime via MCP ResourceUpdated)
// or poll manually:
const result = await mcpClient.callTool({ name: 'get_pending_actions', arguments: { sessionId } });
// result contains: { name: 'submit_contact', context: { name: 'Alice', email: 'alice@example.com' } }