Skip to content

React SDK Reference

The React SDK provides the rendering layer for Freesail. It connects to the gateway via SSE, renders A2UI surfaces as React component trees, and handles user actions and two-way data binding.


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

FreesailProvider manages the connection to the gateway, registers catalogs, applies the active theme, and provides context to all child components. There is no separate theme provider — theme is a prop directly on FreesailProvider.

import { ReactUI } from 'freesail';
import { StandardCatalog } from '@freesail/standard-catalog';
import { ChatCatalog } from '@freesail/chat-catalog';
<ReactUI.FreesailProvider
theme={ReactUI.defaultLightTokens}
catalogs={[StandardCatalog, ChatCatalog]}
onConnectionChange={(connected) => console.log('Connected:', connected)}
onError={(error) => console.error('Error:', error)}
>
<App />
</ReactUI.FreesailProvider>
PropTypeDefaultDescription
themeFreesailThemeTokens | Partial<FreesailThemeTokens>defaultLightTokensFull or partial token object. Use ReactUI.defaultLightTokens or ReactUI.defaultDarkTokens as a base, spread with overrides for customisation.
catalogsCatalogDefinition[][]Catalogs to register. Components are auto-registered on mount.
namestringOptional scope name. Only needed when two providers on the same page connect to the same gateway — scopes the sessionStorage key to prevent session collisions.
transportOptionsobjectAdditional transport configuration (reconnect delays, queue size, etc.)
additionalCapabilitiesRecord<string, unknown>Extra capability key/values merged into the standard catalog list advertised to the agent
onConnectionChange(connected: boolean) => voidCalled when the connection state changes
onError(error: Error) => voidCalled when a transport or parse error occurs
onBeforeCreateSurface(surfaceId, catalogId, sendDataModel, surfaceManager) => InterceptorResultCalled before honouring an agent createSurface. Return { allowed: false, message } to block.
onBeforeUpdateComponents(surfaceId, components) => InterceptorResultCalled before honouring an agent updateComponents. Return { allowed: false, message } to block.
onBeforeUpdateDataModel(surfaceId, path) => InterceptorResultCalled before honouring an agent updateDataModel. Return { allowed: false, message } to block.
onBeforeDeleteSurface(surfaceId) => InterceptorResultCalled before honouring an agent deleteSurface. Return { allowed: false, message } to block.

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. This is the standard setup when the gateway is reverse-proxied onto the same domain as the UI (nginx in production, Vite proxy in development).

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

Each onBefore* prop allows you to validate or block agent operations before they are applied to the surface manager. Return { allowed: false, message: '...' } to block — the message is sent back to the agent as an error payload over MCP. Return { allowed: true, message: '...' } to allow and optionally notify the agent with a validation message.

The onBeforeCreateSurface interceptor receives the surfaceManager as its fourth argument, which can be used to inspect existing surfaces:

<ReactUI.FreesailProvider
theme={ReactUI.defaultLightTokens}
catalogs={[StandardCatalog]}
onBeforeCreateSurface={(surfaceId, catalogId, sendDataModel, surfaceManager) => {
const surfaces = surfaceManager.getAllSurfaces().filter(s => s.id !== '__chat');
if (surfaces.length >= 3) {
return { allowed: false, message: 'Surface limit reached.' };
}
return { allowed: true, message: '' };
}}
>
...
</ReactUI.FreesailProvider>

The theme prop on FreesailProvider accepts a full FreesailThemeTokens object. Two complete baseline sets are exported:

// Light theme
<ReactUI.FreesailProvider theme={ReactUI.defaultLightTokens} catalogs={CATALOGS}>
// Dark theme
<ReactUI.FreesailProvider theme={ReactUI.defaultDarkTokens} catalogs={CATALOGS}>
// Custom overrides
const myTheme = {
...ReactUI.defaultLightTokens,
primary: '#e11d48',
primaryHover: '#be123c',
bgRaised: '#fff1f2',
};
<ReactUI.FreesailProvider theme={myTheme} catalogs={CATALOGS}>

Pass a state variable to the theme prop:

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

The full token interface. CSS custom properties (--freesail-*) are injected into the document.

Colour tokens:

TokenCSS variableDescription
bg--freesail-bgPage/surface root background
bgRaised--freesail-bg-raisedCard and panel backgrounds
bgMuted--freesail-bg-mutedSubtle background fills
bgOverlay--freesail-bg-overlayModal/dialog overlay backdrop
textForeground--freesail-text-foregroundPrimary text
textSecondary--freesail-text-secondarySecondary text
primary--freesail-primaryBrand accent colour
primaryHover--freesail-primary-hoverHover state for primary
primaryForeground--freesail-primary-foregroundText on primary background
error--freesail-errorError states
success--freesail-successSuccess states
warning--freesail-warningWarning states
info--freesail-infoInformational states
border--freesail-borderBorders and dividers

Structure tokens:

TokenCSS variableDescription
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

Fluid spacing tokens (container-relative via cqi):

TokenCSS variableDescription
spaceXs--freesail-space-xsExtra-small spacing
spaceSm--freesail-space-smSmall spacing
spaceMd--freesail-space-mdMedium spacing
spaceLg--freesail-space-lgLarge spacing
spaceXl--freesail-space-xlExtra-large spacing

Fluid typography tokens (container-relative via cqi):

TokenCSS variableDescription
typeCaption--freesail-type-captionCaption font size
typeLabel--freesail-type-labelLabel font size
typeBody--freesail-type-bodyBody font size
typeH5--freesail-type-h5Heading 5 font size
typeH4--freesail-type-h4Heading 4 font size
typeH3--freesail-type-h3Heading 3 font size
typeH2--freesail-type-h2Heading 2 font size
typeH1--freesail-type-h1Heading 1 font size

Fluid icon tokens (container-relative via cqi):

TokenCSS variableDescription
iconSm--freesail-icon-smSmall icon size
iconMd--freesail-icon-mdMedium icon size
iconLg--freesail-icon-lgLarge icon size
iconXl--freesail-icon-xlExtra-large icon size
icon2xl--freesail-icon-2xl2XL icon size
icon3xl--freesail-icon-3xl3XL icon size
icon4xl--freesail-icon-4xl4XL icon size

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

The subset of colour tokens an agent can set on container components (Row, Column, Card, Modal, TabularGrid) via the theme prop in the component definition. Spacing, typography, and radii are restricted to the host app.

TokenCSS variable
primary--freesail-primary
primaryHover--freesail-primary-hover
primaryForeground--freesail-primary-foreground
bg--freesail-bg
bgRaised--freesail-bg-raised
bgMuted--freesail-bg-muted
textForeground--freesail-text-foreground
textSecondary--freesail-text-secondary
border--freesail-border

Renders a single A2UI surface. The surface must have been created by the gateway (agent-managed) or bootstrapped by the React app (client-managed).

<ReactUI.FreesailSurface surfaceId="workspace" />
PropTypeDefaultDescription
surfaceIdstringRequiredThe surface to render
themeFreesailThemeTokens | Partial<FreesailThemeTokens>Per-surface token overrides. Useful for scoping a different visual treatment to one surface (e.g. a light chat panel in a dark app).
classNamestringCSS class for the wrapper element
loadingReactNodeFreesail spinnerShown while the surface is being initialised
errorReactNodeError messageShown if the catalog is not registered
emptyReactNodeFreesail spinnerShown while waiting for components
{/* Chat panel 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" />

Client-managed surfaces (prefixed with __) are created and wired up in React before the agent connects. The __chat surface is the primary example — the app creates the surface, defines its component tree, and sets the initial data model. The agent only writes to the data model.

Use useFreesailContext() to access the surfaceManager directly:

import { ReactUI } from 'freesail';
import { ChatCatalog } from '@freesail/chat-catalog';
const CHAT_CATALOG_ID = ChatCatalog.namespace;
function ChatBootstrapper() {
const { surfaceManager } = ReactUI.useFreesailContext();
useEffect(() => {
// 1. Create the surface
surfaceManager.createSurface({
surfaceId: '__chat',
catalogId: CHAT_CATALOG_ID,
sendDataModel: false,
});
// 2. Define the component tree
surfaceManager.updateComponents('__chat', [
{
id: 'root',
component: 'ChatContainer',
title: 'Chat',
height: '100%',
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...',
},
]);
// 3. Set the initial data model
surfaceManager.updateDataModel('__chat', '/', {
messages: [],
isTyping: false,
stream: { token: '', active: false },
});
}, [surfaceManager]);
return null;
}

Place <ChatBootstrapper /> inside FreesailProvider before any FreesailSurface that needs __chat.

FreesailProvider automatically persists surface state to sessionStorage on every significant change (components updated, data model updated, surface created or deleted). On reconnect within the gateway’s grace period, the provider restores this state so surfaces survive page refreshes without a round-trip to the agent. Client-managed surfaces bootstrapped by the app are overwritten with the saved state so conversation history is recovered correctly.


Returns the full Freesail context. Throws if used outside a FreesailProvider.

const { surfaceManager, transport, sendAction, getSurface, isConnected } = useFreesailContext();

Returns the current theme tokens and mode.

const { tokens, mode } = ReactUI.useFreesailTheme();
// tokens: FreesailThemeTokens — the resolved token values
// mode: 'light' | 'dark'

Subscribes to a surface and re-renders on any change (components, data model, deletion).

const surface = useSurface('workspace');
// surface.components, surface.dataModel, surface.catalogId, etc.

Subscribes to a specific path in a surface’s data model.

const price = useSurfaceData<string>('ticker', '/currentPrice');

Returns the current connection state.

const { isConnected } = useConnectionStatus();

Returns all active surfaces.

const surfaces = useSurfaces();
const agentSurfaces = surfaces.filter(s => !s.id.startsWith('__'));

Returns the session ID assigned by the gateway, or null until connected.

const sessionId = useSessionId();

Returns a dispatch function for sending actions on a surface.

const dispatch = useAction('workspace');
await dispatch('submit_form', 'submit_btn', { name: 'Alice' });

The interface for registering custom catalogs with FreesailProvider:

interface CatalogDefinition {
namespace: string; // catalogId URI
schema: object; // parsed catalog JSON
components: Record<string, ComponentType<FreesailComponentProps>>;
functions?: Record<string, FunctionImplementation>;
}

The namespace must exactly match the catalogId in your catalog JSON. Pass the catalog’s exported constant:

import { StandardCatalog } from '@freesail/standard-catalog';
// StandardCatalog.namespace === 'https://freesail.dev/catalogs/standard-catalog.json'

All catalog components receive this props object:

PropTypeDescription
componentA2UIComponentResolved component definition (data bindings already resolved)
childrenReactNodeRendered child components
metaComponentMetaMetadata object with getBinding(propName) for accessing raw data bindings
dataModelRecord<string, unknown>Full surface data model snapshot
scopeDataunknownCurrent item data when inside a dynamic list template
onAction(name, context) => voidDispatch a named action to the agent
onDataChange(path, value) => voidWrite a value to the local data model (two-way binding)
onFunctionCall(call) => voidExecute a client-side function (e.g. show, hide)

Input components use the meta object to resolve the bound data model path, then call onDataChange on user interaction:

export function MyInput({ component, meta, onDataChange }: FreesailComponentProps) {
const value = (component['value'] as string) ?? '';
const boundPath = meta.getBinding('value')?.path ?? `/input/${component.id}`;
return (
<input
value={value}
onChange={(e) => onDataChange?.(boundPath, e.target.value)}
/>
);
}

When inside a dynamic list template, relative paths are automatically converted to absolute paths, so onDataChange always writes to the correct location in the data model.

The visible property of components is checked before rendering. In addition to the agent-set value, the client-side show/hide functions (in the Standard Catalog) perform a visibility override. This override takes precedence over the agent’s value until the agent explicitly updates visible in the component definition, which clears the override.


The freesail package re-exports the React SDK under the ReactUI namespace:

import { ReactUI } from 'freesail';
// Provider and surface
ReactUI.FreesailProvider
ReactUI.FreesailSurface
// Hooks
ReactUI.useFreesailContext()
ReactUI.useFreesailTheme()
ReactUI.useConnectionStatus()
ReactUI.useSurfaces()
ReactUI.useSessionId()
ReactUI.useAction(surfaceId)
// Theme utilities
ReactUI.defaultLightTokens // FreesailThemeTokens
ReactUI.defaultDarkTokens // FreesailThemeTokens
ReactUI.tokensToCssVars(tokens, mode)
// Types
ReactUI.FreesailThemeTokens
ReactUI.FreesailSurfaceTheme
ReactUI.CatalogDefinition
ReactUI.FreesailComponentProps

The recommended way to create catalogs is using the Freesail CLI:

Terminal window
npx freesail new catalog

You can also use registerCatalog to register a catalog imperatively outside of FreesailProvider:

import { registerCatalog } from '@freesail/react';
registerCatalog(
'https://mycompany.com/catalogs/my-catalog.json',
{ MyCard, MyChart },
{ myFormat },
catalogSchema
);

Or use withCatalog to register a single component at import time:

import { withCatalog } from '@freesail/react';
export const MyCard = withCatalog(
'https://mycompany.com/catalogs/my-catalog.json',
'MyCard',
MyCardComponent
);