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.
1. Core Concept
Section titled “1. Core Concept”Freesail uses a Triangle Pattern with three independent processes:
Architecture Overview
Section titled “Architecture Overview”- 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.
2. The Freesail Gateway
Section titled “2. The Freesail Gateway”The Gateway is the central bridge between agents and frontends. It runs as a standalone Node.js process with two network-facing interfaces:
| Interface | Protocol | Purpose |
|---|---|---|
| Agent-facing | MCP Stdio or Streamable HTTP | Exposes tools, resources, and prompts to AI agents |
| UI-facing | HTTP SSE + POST | Streams A2UI updates to the frontend, receives user actions |
Starting the Gateway
Section titled “Starting the Gateway”# HTTP mode (default) — agents connect via HTTP on port 3000freesail run gateway --mcp-port 3000 --http-port 3001
# Stdio mode — agent spawns gateway as child processfreesail run gateway --mcp-mode stdio --http-port 3001Config File
Section titled “Config File”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:
sessionTimeoutandreconnectGracePeriodare specified in seconds in the config file (not milliseconds).
CLI Options
Section titled “CLI Options”| Option | Default | Description |
|---|---|---|
--config <file> | freesail-gateway.config.json | Path to JSON config file |
--mcp-mode <mode> | http | MCP transport: http (standalone) or stdio (child process) |
--mcp-port <port> | 3000 | Port for MCP Streamable HTTP server (http mode only) |
--mcp-host <host> | 127.0.0.1 | Bind address for MCP server (http mode only) |
--http-port <port> | 3001 | Port for A2UI HTTP/SSE server |
--http-host <host> | 0.0.0.0 | Bind address for A2UI server |
--session-timeout <s> | 1800 | Session idle timeout in seconds |
--reconnect-grace-period <s> | 180 | Session 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> | info | Minimum log level: fatal | error | warn | info | debug |
--log-filter <f> | — | Per-subsystem level override, e.g. express:debug. Repeatable. |
Session Management
Section titled “Session Management”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}Logging Subsystems
Section titled “Logging Subsystems”Fine-tune log verbosity per subsystem with --log-filter <subsystem>:<level>:
| Subsystem | Covers |
|---|---|
express | SSE connections, incoming actions, catalog registration |
mcp | Agent MCP tool calls, session handshake |
session | Surface creates/updates, data-model writes, stale-session cleanup |
session.agent-surface | Downstream messages sent to agents |
session.client-surface | Downstream messages sent to browser clients |
Network Isolation
Section titled “Network Isolation”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.
How the Gateway Processes Requests
Section titled “How the Gateway Processes Requests”-
Agent → Gateway (MCP): Agent calls tools like
create_surfaceorupdate_components. The gateway validates the call against the catalog schema and pushes the result to the appropriate frontend session via SSE. -
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.
3. Setting Up the React Application
Section titled “3. Setting Up the React Application”Install the SDK
Section titled “Install the SDK”npm install freesail @freesail/standard-catalog @freesail/chat-catalogConfigure the FreesailProvider
Section titled “Configure the FreesailProvider”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:
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
gatewayto an explicit URL on a different domain — require Freesail Enterprise.
Scoping the Session (optional)
Section titled “Scoping the Session (optional)”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>Adding Surfaces
Section titled “Adding Surfaces”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> );}Surface Naming Rules
Section titled “Surface Naming Rules”| Type | Naming | Who creates it? | Agent permissions |
|---|---|---|---|
| Agent-managed | Alphanumeric (e.g., workspace) | Agent via create_surface | Full control |
| Client-managed | Starts with __ (e.g., __chat) | React app | update_data_model and update_components |
Note: Agents cannot call
create_surfaceordelete_surfaceon client-managed surfaces. Calls allowed by the gatway can be restricted on the client side using interceptors.
Bootstrapping Client-Managed Surfaces
Section titled “Bootstrapping Client-Managed Surfaces”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" />.
Rendering Agent Surfaces Dynamically
Section titled “Rendering Agent Surfaces Dynamically”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> );}4. Theming
Section titled “4. Theming”The theme Prop
Section titled “The theme Prop”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}>Theme Token Reference
Section titled “Theme Token Reference”| Token | CSS variable | Description |
|---|---|---|
bgRoot | --freesail-bg-root | Page background |
bgSurface | --freesail-bg-surface | Card/panel background |
bgMuted | --freesail-bg-muted | Subtle backgrounds |
bgRaised | --freesail-bg-raised | Elevated elements (buttons, popovers) |
textForeground | --freesail-text-foreground | Primary text |
textSecondary | --freesail-text-secondary | Secondary/muted text |
primary | --freesail-primary | Brand accent |
primaryHover | --freesail-primary-hover | Hover state for primary |
primaryForeground | --freesail-primary-foreground | Text on primary background |
border | --freesail-border | Borders and dividers |
error | --freesail-error | Error states |
success | --freesail-success | Success states |
warning | --freesail-warning | Warning states |
info | --freesail-info | Informational states |
radiusSm | --freesail-radius-sm | Small border radius |
radiusMd | --freesail-radius-md | Medium border radius |
radiusLg | --freesail-radius-lg | Large border radius |
shadowSm | --freesail-shadow-sm | Small box shadow |
shadowMd | --freesail-shadow-md | Medium box shadow |
typeCaption | --freesail-type-caption | Caption font size (fluid clamp) |
typeLabel | --freesail-type-label | Label font size |
typeBody | --freesail-type-body | Body font size |
typeH5–typeH1 | --freesail-type-h5 … h1 | Heading 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.
Switching Themes at Runtime
Section titled “Switching Themes at Runtime”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> );}Per-Surface Theme Overrides
Section titled “Per-Surface Theme Overrides”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>Using Theme Tokens in Custom Components
Section titled “Using Theme Tokens in Custom Components”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>5. Building the AI Agent
Section titled “5. Building the AI Agent”The agent connects to the gateway’s MCP endpoint and uses tools to drive the UI.
Connecting to the Gateway
Section titled “Connecting to the Gateway”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')));Discovering Catalogs
Section titled “Discovering Catalogs”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_surfaceIf 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.
Creating a Surface
Section titled “Creating a 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 },});Updating Components
Section titled “Updating Components”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!' }, ], },});6. Driving Interactivity (Data Models)
Section titled “6. Driving Interactivity (Data Models)”Data Binding
Section titled “Data Binding”Bind component properties to the data model for automatic UI updates:
// Agent sets up components with data bindingsawait mcpClient.callTool({ name: 'update_components', arguments: { surfaceId: 'ticker', sessionId, components: [ { id: 'price', component: 'Text', text: { path: '/currentPrice' }, // Binds to data model }, ], },});
// Set the data modelawait mcpClient.callTool({ name: 'update_data_model', arguments: { surfaceId: 'ticker', sessionId, path: '/currentPrice', value: '$150.00', },});Real-Time Updates
Section titled “Real-Time Updates”Update data without re-sending the component tree:
await mcpClient.callTool({ name: 'update_data_model', arguments: { surfaceId: 'ticker', sessionId, path: '/currentPrice', value: '$155.50', },});On-Demand Data Model Retrieval
Section titled “On-Demand Data Model Retrieval”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: { ... } }7. Handling User Actions
Section titled “7. Handling User Actions”When a user interacts with a component, the SDK sends an Action back through the gateway:
- UI Event: User clicks a button in the browser.
- Action Payload: Freesail POSTs the action to the gateway with the surface’s data model.
- 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 } }}8. Using the Agent Runtime
Section titled “8. Using the Agent Runtime”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();9. Running the Full Stack
Section titled “9. Running the Full Stack”The easiest way to run everything is with the provided script:
export GOOGLE_API_KEY=your-api-keycd example && bash run-all.shThis starts three independent processes:
| Process | URL | Purpose |
|---|---|---|
| Gateway | http://localhost:3001 (A2UI), http://127.0.0.1:3000 (MCP) | Bridge between agent and UI |
| Agent | Connects to MCP port 3000 | AI agent |
| UI | http://localhost:5173 | Vite React dev server |
10. Debugging
Section titled “10. Debugging”Session Identification
Section titled “Session Identification”- The gateway assigns a
sessionIdto each SSE connection. - The React SDK attaches this ID to every HTTP POST via the
X-Freesail-Sessionheader. - The agent receives the
sessionIdvia the__session_connectedaction 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>;}Common Issues
Section titled “Common Issues”| Symptom | Check |
|---|---|
update_components validation failed | This 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 actions | Check the gateway logs for upstream messages. Verify if the agent has subscribed to MCP resource notifications |
| Components not rendering | Check if agent is passing the correct session id and surface id in the tool call. |
11. Best Practices
Section titled “11. Best Practices”- 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
sessionIdinto the agent prompt immediately before each user message to prevent the LLM reusing a stalesessionIdfrom conversation history. - Same-origin deployment: Omit the
gatewayprop when the frontend and gateway share a domain — this avoids CORS and works transparently in both dev (via vite) and production (via reverse proxy).