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.
How It Works in Freesail
Section titled “How It Works in Freesail”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)What the agent calls
Section titled “What the agent calls”| MCP tool | A2UI message produced | Description |
|---|---|---|
create_surface | createSurface | Initialise a new UI surface |
update_components | updateComponents | Set or update the component tree |
update_data_model | updateDataModel | Update data without resending components |
delete_surface | deleteSurface | Remove a surface from the UI |
get_data_model | getDataModel (Freesail extension) | Request the current data model from the frontend |
get_component_tree | getComponentTree (Freesail extension) | Request the current component tree from the frontend |
How user actions reach the agent
Section titled “How user actions reach the agent”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/updatedonmcp://freesail.dev/sessions/{sessionId}(push — preferred; used by@freesail/agent-runtime) - Calling the
get_pending_actionsMCP tool (polling) - Calling
get_all_pending_actionsto drain all sessions at once
Session lifecycle events
Section titled “Session lifecycle events”The Gateway injects two synthetic actions into the queue automatically — the agent does not need to do anything special to receive them:
| Action name | When fired | Context |
|---|---|---|
__session_connected | A browser tab connects via SSE | { sessionId } |
__session_disconnected | The 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.
Agent Workflow
Section titled “Agent Workflow”- Get catalogs — call
get_catalogs(sessionId)to retrieve the component catalogs the client registered. ThecatalogIdis required forcreate_surface. Thecontentfield lists all available components — never guess or invent component names. - Get component/function details — call
get_component_detailsandget_function_detailsbefore building a surface to understand each component’s required and optional properties. - Create a surface — call
create_surfacewith a uniquesurfaceIdand the exactcatalogIdfrom step 1. - Plan, then execute incrementally — decide the layout before sending components. Build the UI progressively so the user sees it taking shape.
- Add components — call
update_componentswith a flat array of component definitions. One component must haveid: "root". When adding a new component, always update its intended parent in the same call — orphan components are not rendered. - Add client-side logic — use
checksfor input validation andformatStringfor text interpolation, keeping logic local without round-trips. - Set data — call
update_data_modelto populate values that components reference via data bindings.
MCP Tool Reference
Section titled “MCP Tool Reference”create_surface
Section titled “create_surface”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.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | Unique identifier. Must start with an alphanumeric character and contain only alphanumerics or underscores (^[a-zA-Z0-9][a-zA-Z0-9_]*$). |
catalogId | string | ✅ | The catalog URI returned by get_catalogs. |
sessionId | string | ✅ | The target client session. |
sendDataModel | boolean | — | If 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 callupdate_data_modelon them. - Before creating a new surface, consider whether an existing one can be reused or deleted to save screen space.
update_components
Section titled “update_components”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.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | The target surface (must already exist). |
sessionId | string | ✅ | The target client session. |
components | array | ✅ | Flat 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)
update_data_model
Section titled “update_data_model”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.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | The target surface (must already exist, or be a __ surface). |
sessionId | string | ✅ | The target client session. |
path | string | — | JSON Pointer to the location to update. Defaults to / (replaces entire model). |
value | any | — | The 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.
delete_surface
Section titled “delete_surface”Removes a surface and all its components and data from the UI.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | The surface to remove. Agents cannot delete __ surfaces. |
sessionId | string | ✅ | The target client session. |
get_data_model
Section titled “get_data_model”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.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | The surface to read. |
sessionId | string | ✅ | The target client session. |
get_component_tree
Section titled “get_component_tree”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.
| Argument | Type | Required | Description |
|---|---|---|---|
surfaceId | string | ✅ | The surface to read. |
sessionId | string | ✅ | The 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").
Downstream A2UI Messages
Section titled “Downstream A2UI Messages”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.
createSurface
Section titled “createSurface”{ "version": "v0.9", "createSurface": { "surfaceId": "user_profile", "catalogId": "https://freesail.dev/catalogs/standard-catalog.json", "sendDataModel": true }}updateComponents
Section titled “updateComponents”{ "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" } ] }}updateDataModel
Section titled “updateDataModel”{ "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" } } } }deleteSurface
Section titled “deleteSurface”{ "version": "v0.9", "deleteSurface": { "surfaceId": "user_profile" } }getDataModel (Freesail extension)
Section titled “getDataModel (Freesail extension)”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" } }getComponentTree (Freesail extension)
Section titled “getComponentTree (Freesail extension)”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.
Upstream Messages (Browser → Gateway)
Section titled “Upstream Messages (Browser → Gateway)”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.
action
Section titled “action”Sent when a user interacts with a component that has an action defined.
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | The action name (e.g. submit_form, chat_send). |
surfaceId | string | ✅ | The surface where the action originated. |
sourceComponentId | string | ✅ | The component that triggered the action. |
timestamp | string | ✅ | ISO 8601 timestamp. |
context | object | ✅ | The 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" }}Data Binding
Section titled “Data Binding”Component properties that accept dynamic values use one of three forms:
| Form | Example | Description |
|---|---|---|
| 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.
Absolute vs relative paths
Section titled “Absolute vs relative paths”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 templateformatString — String Interpolation
Section titled “formatString — String Interpolation”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()}." } }}Expression syntax
Section titled “Expression syntax”| Pattern | Example | Description |
|---|---|---|
| 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.
Dynamic Lists (Templates)
Section titled “Dynamic Lists (Templates)”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" }]Actions
Section titled “Actions”Interactive components define an action property specifying what happens on interaction.
Server action
Section titled “Server action”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.
Local action
Section titled “Local action”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" } } }}Two-Way Binding (Forms)
Section titled “Two-Way Binding (Forms)”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" } } } } }]Client-Side Validation (checks)
Section titled “Client-Side Validation (checks)”Components support a checks array for validation without agent round-trips.
- Input components: a failed check displays the
messageinline 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.
Input field validation
Section titled “Input field validation”{ "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." } ]}Button disable validation
Section titled “Button disable validation”{ "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." } ]}Colors and Theming
Section titled “Colors and Theming”Semantic color tokens (preferred)
Section titled “Semantic color tokens (preferred)”Semantic tokens adapt automatically to light and dark themes:
| Token | CSS variable | Usage |
|---|---|---|
bg | --freesail-bg | Page/surface root background |
bgRaised | --freesail-bg-raised | Card and panel backgrounds |
bgMuted | --freesail-bg-muted | Subtle background fills, dividers |
bgOverlay | --freesail-bg-overlay | Modal/dialog overlay backdrop |
textForeground | --freesail-text-foreground | Primary body text |
textSecondary | --freesail-text-secondary | Secondary or hint text |
primary | --freesail-primary | Brand accent colour |
primaryHover | --freesail-primary-hover | Hover state for primary |
primaryForeground | --freesail-primary-foreground | Text on primary-coloured backgrounds |
error | --freesail-error | Error states |
success | --freesail-success | Success states |
warning | --freesail-warning | Warning states |
info | --freesail-info | Informational states |
border | --freesail-border | Borders and dividers |
radiusSm / radiusMd / radiusLg | --freesail-radius-* | Border radii |
shadowSm / shadowMd | --freesail-shadow-* | Box shadows |
spaceXs … spaceXl | --freesail-space-* | Fluid spacing (container-relative via cqi) |
typeCaption … typeH1 | --freesail-type-* | Fluid font sizes (container-relative via cqi) |
iconSm … icon4xl | --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" }Explicit colors
Section titled “Explicit colors”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" }Complete Example
Section titled “Complete Example”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 availableconst 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 useawait 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 surfaceawait mcpClient.callTool({ name: 'create_surface', arguments: { surfaceId: 'contact_form', catalogId: 'https://freesail.dev/catalogs/standard-catalog.json', sessionId }});
// 4. Define the component treeawait 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 modelawait 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' } }