Skip to content

Developer Guide

This guide covers how to build Agent-driven UI applications using the Freesail SDK. You’ll learn how to set up the architecture, run the gateway, connect agents, and build interactive UIs.


Freesail uses a Triangle Pattern with three independent processes:

Freesail Architecture

  • Agent: Decides what to show by calling MCP tools (e.g., create_surface, update_components).
  • Gateway: Translates between MCP (agent-facing) and A2UI (UI-facing). Validates agent output against catalog schemas.
  • Frontend: Renders A2UI JSON into React components and sends user actions back to the agent.

The Gateway is the central bridge between agents and frontends. It runs as a standalone Node.js process with two network-facing interfaces:

InterfaceProtocolPurpose
Agent-facingMCP Stdio or Streamable HTTPExposes tools, resources, and prompts to AI agents
UI-facingHTTP SSE + POSTStreams A2UI updates to the frontend, receives user actions
Terminal window
# HTTP mode (default) — agents connect via HTTP on port 3000
freesail run gateway --mcp-port 3000 --http-port 3001
# Stdio mode — agent spawns gateway as child process
freesail run gateway --mcp-mode stdio --http-port 3001

All gateway settings can be provided via a freesail-gateway.config.json file in the current working directory. CLI flags take precedence over config file values. A sample file is available at @freesail/gateway/freesail-gateway.config.sample.json.

{
"httpPort": 3001,
"httpHost": "0.0.0.0",
"mcpMode": "http",
"mcpPort": 3000,
"mcpHost": "127.0.0.1",
"sessionTimeout": 1800,
"reconnectGracePeriod": 180,
"log": {
"level": "info",
"file": "/var/log/freesail/gateway.log",
"filters": {
"express": "info",
"mcp": "warn"
}
}
}

Note: sessionTimeout and reconnectGracePeriod are specified in seconds in the config file (not milliseconds).

OptionDefaultDescription
--config <file>freesail-gateway.config.jsonPath to JSON config file
--mcp-mode <mode>httpMCP transport: http (standalone) or stdio (child process)
--mcp-port <port>3000Port for MCP Streamable HTTP server (http mode only)
--mcp-host <host>127.0.0.1Bind address for MCP server (http mode only)
--http-port <port>3001Port for A2UI HTTP/SSE server
--http-host <host>0.0.0.0Bind address for A2UI server
--session-timeout <s>1800Session idle timeout in seconds
--reconnect-grace-period <s>180Session resumption window in seconds
--webhook-url <url>Forward UI actions to this URL via HTTP POST
--log-file <file>Write logs to file (in addition to console)
--log-level <level>infoMinimum log level: fatal | error | warn | info | debug
--log-filter <f>Per-subsystem level override, e.g. express:debug. Repeatable.

Idle sessions are automatically removed after the configured timeout. When a browser tab closes or refreshes, the gateway suspends the session for a grace period — if the client reconnects within that window, the session (including all surface state) is seamlessly resumed.

{
"sessionTimeout": 3600,
"reconnectGracePeriod": 300
}

Fine-tune log verbosity per subsystem with --log-filter <subsystem>:<level>:

SubsystemCovers
expressSSE connections, incoming actions, catalog registration
mcpAgent MCP tool calls, session handshake
sessionSurface creates/updates, data-model writes, stale-session cleanup
session.agent-surfaceDownstream messages sent to agents
session.client-surfaceDownstream messages sent to browser clients

By default, the MCP server binds to 127.0.0.1 — only local processes can connect. The A2UI server binds to 0.0.0.0, making it accessible from browsers.

  1. Agent → Gateway (MCP): Agent calls tools like create_surface or update_components. The gateway validates the call against the catalog schema and pushes the result to the appropriate frontend session via SSE.

  2. Frontend → Gateway → Agent (Actions): When a user clicks a button, the frontend POSTs an action to the gateway. The gateway queues it as an MCP resource and notifies the agent via a resource-updated push notification.


Terminal window
npm install freesail @freesail/standard-catalog @freesail/chat-catalog

The FreesailProvider manages the connection to the gateway, registers available component catalogs, and applies the active theme. theme is a prop directly on FreesailProvider and accepts a full token object.

In the free version, the gateway and web app must share the same origin. Omit the gateway prop entirely — it defaults to '', using relative paths with no CORS required.

import { ReactUI } from 'freesail';
import { StandardCatalog } from '@freesail/standard-catalog';
import { ChatCatalog } from '@freesail/chat-catalog';
const CATALOGS: ReactUI.CatalogDefinition[] = [
StandardCatalog,
ChatCatalog,
];
function App() {
return (
<ReactUI.FreesailProvider
theme={ReactUI.defaultLightTokens}
catalogs={CATALOGS}
onConnectionChange={(connected) => console.log('Connected:', connected)}
onError={(error) => console.error('Freesail error:', error)}
>
<MainLayout />
</ReactUI.FreesailProvider>
);
}

To achieve same-origin in development (where the UI runs on port 5173 and the gateway on port 3001), configure a proxy in vite.config.ts that forwards each gateway endpoint:

vite.config.ts
export default {
server: {
proxy: {
'/sse': 'http://localhost:3001',
'/message': 'http://localhost:3001',
'/register-catalogs': 'http://localhost:3001',
'/register-surface': 'http://localhost:3001',
'/send': 'http://localhost:3001',
},
},
};

In production, configure nginx (or equivalent) to forward these same paths to the gateway process.

Cross-origin deployments — setting gateway to an explicit URL on a different domain — require Freesail Enterprise.

If two FreesailProvider instances on the same page connect to the same gateway, use the name prop to prevent them from sharing the same sessionStorage key:

<ReactUI.FreesailProvider name="sidebar" theme={ReactUI.defaultLightTokens} catalogs={[...]}>
...
</ReactUI.FreesailProvider>

A FreesailSurface is a designated area that the AI agent can control.

import { ReactUI } from 'freesail';
function MainLayout() {
return (
<div className="app-container">
{/* Client-managed surface (start with __) */}
<aside className="sidebar">
<ReactUI.FreesailSurface surfaceId="__chat" />
</aside>
{/* Agent-created surfaces rendered dynamically */}
<main className="content">
<SurfaceList />
</main>
</div>
);
}
TypeNamingWho creates it?Agent permissions
Agent-managedAlphanumeric (e.g., workspace)Agent via create_surfaceFull control
Client-managedStarts with __ (e.g., __chat)React appupdate_data_model and update_components

Note: Agents cannot call create_surface or delete_surface on client-managed surfaces. Calls allowed by the gatway can be restricted on the client side using interceptors.

Client-managed surfaces are created by the React app using surfaceManager from useFreesailContext(). The typical pattern is a bootstrapper component that runs once on mount:

function ChatBootstrapper() {
const { surfaceManager } = ReactUI.useFreesailContext();
useEffect(() => {
surfaceManager.createSurface({
surfaceId: '__chat',
catalogId: ChatCatalog.namespace,
sendDataModel: false,
});
surfaceManager.updateComponents('__chat', [
{
id: 'root',
component: 'ChatContainer',
title: 'Chat',
children: ['message_list', 'agent_stream', 'typing', 'chat_input'],
},
{
id: 'message_list',
component: 'ChatMessageList',
children: { componentId: 'msg_template', path: '/messages' },
},
{
id: 'msg_template',
component: 'ChatMessage',
role: { path: 'role' },
content: { path: 'content' },
timestamp: { path: 'timestamp' },
},
{
id: 'agent_stream',
component: 'AgentStream',
token: { path: '/stream/token' },
active: { path: '/stream/active' },
},
{
id: 'typing',
component: 'ChatTypingIndicator',
visible: { path: '/isTyping' },
text: 'Thinking...',
},
{ id: 'chat_input', component: 'ChatInput', placeholder: 'Type a message...' },
]);
surfaceManager.updateDataModel('__chat', '/', {
messages: [],
isTyping: false,
stream: { token: '', active: false },
});
}, [surfaceManager]);
return null;
}

Place <ChatBootstrapper /> as a direct child of FreesailProvider, before the layout that renders <FreesailSurface surfaceId="__chat" />.

Rather than hardcoding surface IDs, use useSurfaces() to render every surface the agent creates:

function SurfaceList() {
const allSurfaces = ReactUI.useSurfaces();
const agentSurfaces = allSurfaces.filter(s => !s.id.startsWith('__'));
return (
<div>
{agentSurfaces.map((surface) => (
<ReactUI.FreesailSurface key={surface.id} surfaceId={surface.id} />
))}
</div>
);
}

Theming in Freesail is handled entirely through the theme prop on FreesailProvider. There is no separate theme provider component. The prop accepts a Partial<ReactUI.FreesailThemeTokens> object — a plain record of design tokens that get injected as CSS custom properties (--freesail-*) into the document.

Two complete baseline token sets are exported from ReactUI:

import { ReactUI } from 'freesail';
// Light theme (default)
<ReactUI.FreesailProvider theme={ReactUI.defaultLightTokens} catalogs={CATALOGS}>
// Dark theme
<ReactUI.FreesailProvider theme={ReactUI.defaultDarkTokens} catalogs={CATALOGS}>

To customise, spread a baseline and override specific tokens:

const myTheme = {
...ReactUI.defaultLightTokens,
primary: '#e11d48',
primaryHover: '#be123c',
bgRaised: '#fff1f2',
radiusMd: '0px',
};
<ReactUI.FreesailProvider theme={myTheme} catalogs={CATALOGS}>
TokenCSS variableDescription
bgRoot--freesail-bg-rootPage background
bgSurface--freesail-bg-surfaceCard/panel background
bgMuted--freesail-bg-mutedSubtle backgrounds
bgRaised--freesail-bg-raisedElevated elements (buttons, popovers)
textForeground--freesail-text-foregroundPrimary text
textSecondary--freesail-text-secondarySecondary/muted text
primary--freesail-primaryBrand accent
primaryHover--freesail-primary-hoverHover state for primary
primaryForeground--freesail-primary-foregroundText on primary background
border--freesail-borderBorders and dividers
error--freesail-errorError states
success--freesail-successSuccess states
warning--freesail-warningWarning states
info--freesail-infoInformational states
radiusSm--freesail-radius-smSmall border radius
radiusMd--freesail-radius-mdMedium border radius
radiusLg--freesail-radius-lgLarge border radius
shadowSm--freesail-shadow-smSmall box shadow
shadowMd--freesail-shadow-mdMedium box shadow
typeCaption--freesail-type-captionCaption font size (fluid clamp)
typeLabel--freesail-type-labelLabel font size
typeBody--freesail-type-bodyBody font size
typeH5typeH1--freesail-type-h5h1Heading font sizes

Font size tokens use fluid clamp() values that scale with the container. To implement an accessibility font-size bump, multiply the clamp values rather than overriding them with fixed sizes.

Pass a state variable to the theme prop to support user-controlled light/dark toggling:

function App() {
const [themeMode, setThemeMode] = useState<'light' | 'dark'>('light');
const activeTheme = themeMode === 'dark'
? ReactUI.defaultDarkTokens
: ReactUI.defaultLightTokens;
return (
<ReactUI.FreesailProvider theme={activeTheme} catalogs={CATALOGS}>
<button onClick={() => setThemeMode(m => m === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
<MainLayout />
</ReactUI.FreesailProvider>
);
}

FreesailSurface accepts a theme prop to apply token overrides scoped to that surface only — useful for a dark chat panel alongside a light main area:

<div style={{ display: 'flex' }}>
{/* Chat always uses light tokens regardless of global theme */}
<ReactUI.FreesailSurface
surfaceId="__chat"
theme={{ ...ReactUI.defaultLightTokens }}
/>
{/* Main surfaces inherit the global theme */}
<ReactUI.FreesailSurface surfaceId="workspace" />
</div>

CSS variables injected by FreesailProvider are available anywhere in the document:

.my-component {
background: var(--freesail-bg-surface);
color: var(--freesail-text-foreground);
border: 1px solid var(--freesail-border);
border-radius: var(--freesail-radius-md);
}

Or inline in React:

<div style={{
background: 'var(--freesail-bg-muted)',
color: 'var(--freesail-text-secondary)',
borderRadius: 'var(--freesail-radius-sm)',
}}>
Status: connected
</div>

The agent connects to the gateway’s MCP endpoint and uses tools to drive the UI.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const mcpClient = new Client(
{ name: 'my-agent', version: '1.0.0' },
{ capabilities: { resources: { subscribe: true } } }
);
await mcpClient.connect(
new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))
);

Before creating a surface, call get_catalogs to find out which component catalogs the connected client supports. Each entry contains the catalogId needed for create_surface and an index of available components and functions.

const result = await mcpClient.callTool({
name: 'get_catalogs',
arguments: { sessionId: 'session_abc123' },
});
// result.content[0].text → JSON array: [{ catalogId, title, content }]
// catalogId is the exact string to pass to create_surface

If the array is empty, no catalogs are registered for this session. Call get_component_details and get_function_details for full property information before building any surface.

await mcpClient.callTool({
name: 'create_surface',
arguments: {
surfaceId: 'workspace',
catalogId: 'https://freesail.dev/catalogs/standard-catalog.json', // from get_catalogs
sessionId: 'session_abc123',
sendDataModel: false, // use get_data_model for on-demand retrieval instead
},
});
await mcpClient.callTool({
name: 'update_components',
arguments: {
surfaceId: 'workspace',
sessionId: 'session_abc123',
components: [
{ id: 'root', component: 'Column', children: ['greeting'] },
{ id: 'greeting', component: 'Text', text: 'Hello from the Agent!' },
],
},
});

Bind component properties to the data model for automatic UI updates:

// Agent sets up components with data bindings
await mcpClient.callTool({
name: 'update_components',
arguments: {
surfaceId: 'ticker',
sessionId,
components: [
{
id: 'price',
component: 'Text',
text: { path: '/currentPrice' }, // Binds to data model
},
],
},
});
// Set the data model
await mcpClient.callTool({
name: 'update_data_model',
arguments: {
surfaceId: 'ticker',
sessionId,
path: '/currentPrice',
value: '$150.00',
},
});

Update data without re-sending the component tree:

await mcpClient.callTool({
name: 'update_data_model',
arguments: {
surfaceId: 'ticker',
sessionId,
path: '/currentPrice',
value: '$155.50',
},
});

Use get_data_model to inspect the client’s current surface state at any time, without requiring sendDataModel to be enabled:

const result = await mcpClient.callTool({
name: 'get_data_model',
arguments: { surfaceId: 'workspace', sessionId },
});
// result.content[0].text → JSON: { success: true, dataModel: { ... } }

When a user interacts with a component, the SDK sends an Action back through the gateway:

  1. UI Event: User clicks a button in the browser.
  2. Action Payload: Freesail POSTs the action to the gateway with the surface’s data model.
  3. Agent Processing: The agent picks up the action via MCP resource notification and responds.
{
"version": "v0.9",
"action": {
"name": "submit_form",
"surfaceId": "workspace",
"sourceComponentId": "submit-btn",
"context": { "formData": "..." }
},
"dataModel": {
"surfaceId": "workspace",
"dataModel": { "items": [], "total": 99.99 }
}
}

The @freesail/agent-runtime package handles session lifecycle and action routing. See the Agent Runtime reference for full API details.

import { FreesailAgentRuntime, SharedCache } from '@freesail/agent-runtime';
const sharedCache = new SharedCache(mcpClient, () => getTools(mcpClient));
const runtime = new FreesailAgentRuntime({
mcpClient,
agentFactory: (sessionId) => new MySessionAgent(sessionId, sharedCache),
});
await runtime.start();

The easiest way to run everything is with the provided script:

Terminal window
export GOOGLE_API_KEY=your-api-key
cd example && bash run-all.sh

This starts three independent processes:

ProcessURLPurpose
Gatewayhttp://localhost:3001 (A2UI), http://127.0.0.1:3000 (MCP)Bridge between agent and UI
AgentConnects to MCP port 3000AI agent
UIhttp://localhost:5173Vite React dev server

  • The gateway assigns a sessionId to each SSE connection.
  • The React SDK attaches this ID to every HTTP POST via the X-Freesail-Session header.
  • The agent receives the sessionId via the __session_connected action when a session connects.

Access the current session ID in React with the useSessionId hook:

import { ReactUI } from 'freesail';
function SessionInfo() {
const sessionId = ReactUI.useSessionId();
if (!sessionId) return <div>Connecting...</div>;
return <div>Session: {sessionId}</div>;
}
SymptomCheck
update_components validation failedThis may be due to agent not passing mandatory fields. This is usually observed in logs at the start but the agent usually learns and adapts when the gateway returns error messages. In case of consecutive errors, check if the description in the catalog accurately reflects the properties.
Agent not receiving actionsCheck the gateway logs for upstream messages. Verify if the agent has subscribed to MCP resource notifications
Components not renderingCheck if agent is passing the correct session id and surface id in the tool call.

  • Catalog Selection: Only provide the catalogs necessary for a surface to keep the agent focused.
  • Network Security: In production, keep the MCP port bound to localhost and use a reverse proxy such as NGINX for the A2UI endpoints.
  • Session prompt discipline: Always inject sessionId into the agent prompt immediately before each user message to prevent the LLM reusing a stale sessionId from conversation history.
  • Same-origin deployment: Omit the gateway prop when the frontend and gateway share a domain — this avoids CORS and works transparently in both dev (via vite) and production (via reverse proxy).