Agent Runtime Reference
The @freesail/agent-runtime package provides the session lifecycle, push-based action routing, shared caching, and utilities that agent implementations build on.
Installation
Section titled “Installation”npm install @freesail/agent-runtimeFreesailAgentRuntime
Section titled “FreesailAgentRuntime”The runtime connects to the gateway via MCP resource subscriptions, manages agent instances per session, and dispatches actions without polling.
import { FreesailAgentRuntime } from '@freesail/agent-runtime';
const runtime = new FreesailAgentRuntime({ mcpClient, agentFactory: (sessionId) => new MyAgent(sessionId),});
await runtime.start();
// On graceful shutdown:await runtime.stop();AgentRuntimeConfig
Section titled “AgentRuntimeConfig”interface AgentRuntimeConfig { mcpClient: Client; agentFactory: AgentFactory; // (sessionId: string) => FreesailAgent}runtime.start()
Section titled “runtime.start()”Subscribes to mcp://freesail.dev/sessions for push notifications and performs an initial read to pick up any already-connected sessions. The MCP client must be initialized with capabilities: { resources: { subscribe: true } }.
On startup, if sessions are already active (e.g. after an agent process restart), start() reads the sessions list and recovers them, calling onSessionConnected for each.
runtime.stop()
Section titled “runtime.stop()”Unsubscribes from all active MCP resource subscriptions. Call before closing the MCP client for clean shutdown.
runtime.pollPendingActions()
Section titled “runtime.pollPendingActions()”Drains pending actions for any sessions whose per-session subscription failed. Call this from a resources/list_changed notification handler as a polling fallback. In normal operation this is not needed — push subscriptions handle delivery automatically.
FreesailAgent
Section titled “FreesailAgent”The interface your agent class implements. All methods are optional — implement only what you need.
interface FreesailAgent { onSessionConnected?(sessionId: string): Promise<void>; onSessionDisconnected?(sessionId: string): Promise<void>; onSessionNotification?(notification: SessionNotification): Promise<void>;}| Method | When called | Notes |
|---|---|---|
onSessionConnected | A new browser tab connects | Good place to invalidate the cache and send a welcome message |
onSessionDisconnected | The browser tab closes | Called after all in-flight onSessionNotification promises settle (drain guarantee) |
onSessionNotification | Any UI action, chat message, or client error arrives | Single dispatch point — use notification.type to distinguish actions from errors |
Important: If
onSessionNotificationis not implemented, the runtime will not drain the session’s action queue. The gateway gates all write tools —create_surface,update_components,update_data_model, anddelete_surface— per-surface when there are unread actions. The agent receives an error response directing it to callget_pending_actionsbefore retrying. ImplementingonSessionNotificationavoids this by draining actions automatically via push.
Routing notifications
Section titled “Routing notifications”onSessionNotification receives a discriminated union. Route by type and by action.name for specific events:
async onSessionNotification(notification: SessionNotification): Promise<void> { if (notification.type === 'error') { const { event } = notification; console.warn(`Client error on ${event.surfaceId}: ${event.code} — ${event.message}`); return; }
const { event: action } = notification;
// Chat messages arrive as chat_send actions on the __chat surface if (action.name === 'chat_send' && action.surfaceId === '__chat') { const text = (action.context as { text?: string })?.text; if (text) await this.handleChat(text); return; }
// Capabilities set by the client on connect if (action.name === '__capabilities_set') { console.log('Capabilities:', action.context['capabilities']); return; }
// All other UI actions console.log(`Action: ${action.name} on ${action.surfaceId}`);}SessionNotification
Section titled “SessionNotification”The discriminated union passed to onSessionNotification:
type SessionNotification = | { type: 'action'; event: ActionEvent } | { type: 'error'; event: ClientErrorEvent };ActionEvent
Section titled “ActionEvent”interface ActionEvent { name: string; // e.g. "submit_form", "chat_send", "__capabilities_set" surfaceId: string; // which surface triggered it sourceComponentId: string; // which component within the surface context: Record<string, unknown>; // action-specific payload from the client clientDataModel?: Record<string, unknown>; // full UI data model snapshot (if sendDataModel enabled)}ClientErrorEvent
Section titled “ClientErrorEvent”interface ClientErrorEvent { code: string; // e.g. "VALIDATION_FAILED", "COMPONENT_RENDER_FAILED" message: string; // human-readable description surfaceId: string; // which surface the error occurred on path?: string; // JSON pointer to the field that failed (for VALIDATION_FAILED)}Session Lifecycle Guarantees
Section titled “Session Lifecycle Guarantees”sessions resource updated (session appears) → claim_session(sessionId) → subscribe to mcp://freesail.dev/sessions/{sessionId} → onSessionConnected() ↓per-session resource updated (action arrives) → readResource drains action queue → onSessionNotification() (fire-and-forget, tracked per session) ↓sessions resource updated (session disappears) → drain all in-flight onSessionNotification promises → onSessionDisconnected() → release_session(sessionId) → unsubscribe from mcp://freesail.dev/sessions/{sessionId} → agent instance GC'd- Push model: session connects/disconnects and per-session actions are all delivered via MCP
ResourceUpdatednotifications — no polling. - Session ownership: the runtime calls
claim_sessionwhen a session connects andrelease_sessionwhen it disconnects. Only one agent can hold a session at a time. - Ordering: lifecycle events for the same session are serialised via a per-session promise chain. Events across different sessions run concurrently.
- Drain on disconnect:
onSessionDisconnectedis never called while anonSessionNotificationcall is still running for that session. The runtime waits usingPromise.allSettled. - Clean shutdown: call
await runtime.stop()before closing the MCP client to unsubscribe from all active subscriptions. - Missed connect: if the agent restarts while sessions are active,
start()reads the sessions list and picks up existing sessions, callingonSessionConnectedfor each. - Subscription retry: if the per-session subscription fails (e.g. due to a stale keep-alive connection), the runtime retries up to twice with backoff before falling back to polling via
pollPendingActions().
SharedCache
Section titled “SharedCache”A process-level cache for MCP-fetched data (system prompt, tool definitions) that is shared across all session agents. Uses Promise deduplication to avoid thundering-herd fetches when many sessions start concurrently.
import { SharedCache } from '@freesail/agent-runtime';
// Create once at process levelconst cache = new SharedCache<DynamicStructuredTool[]>( mcpClient, () => LangChainAdapter.getTools(mcpClient) // optional third arg: systemPromptOverride (string));
// In each session agent:const systemPrompt = await cache.getSystemPrompt();const tools = await cache.getTools();
// Invalidate when upstream tools or catalogs change:cache.invalidate();cache.getSystemPrompt(): Promise<string>
Section titled “cache.getSystemPrompt(): Promise<string>”Fetches the a2ui_system prompt from the gateway. If systemPromptOverride was passed to the constructor, returns that instead. Concurrent callers share the same in-flight Promise; the underlying MCP fetch is issued exactly once.
cache.getTools(): Promise<TTools>
Section titled “cache.getTools(): Promise<TTools>”Fetches tool definitions via the provided toolsFactory. Concurrent callers share the same in-flight Promise.
cache.invalidate(): void
Section titled “cache.invalidate(): void”Marks both slots stale. In-flight callers retain their resolved values for the remainder of their current operation; the next caller after invalidation triggers a fresh fetch (also deduplicated).
Utilities
Section titled “Utilities”fetchFreesailSystemPrompt(mcpClient)
Section titled “fetchFreesailSystemPrompt(mcpClient)”Fetches the a2ui_system prompt from the gateway. Returns a fallback if the gateway doesn’t have one.
import { fetchFreesailSystemPrompt } from '@freesail/agent-runtime';
const prompt = await fetchFreesailSystemPrompt(mcpClient);listCatalogResources(mcpClient)
Section titled “listCatalogResources(mcpClient)”Lists all MCP resources registered on the gateway. Returns an array of McpResourceEntry objects, or an empty array on failure.
import { listCatalogResources } from '@freesail/agent-runtime';
const resources = await listCatalogResources(mcpClient);// [{ uri, name, mimeType?, description? }, ...]readCatalogResource(mcpClient, uri)
Section titled “readCatalogResource(mcpClient, uri)”Reads the content of a single MCP resource by URI. Throws on failure so the error can be surfaced to the LLM.
import { readCatalogResource } from '@freesail/agent-runtime';
const content = await readCatalogResource(mcpClient, 'catalog://my-catalog');Catalog discovery via get_catalogs
Section titled “Catalog discovery via get_catalogs”Calling
get_catalogsis mandatory for any agent that creates UI surfaces.
Discover available component catalogs by calling the get_catalogs MCP tool with a sessionId. Returns a slim index with component names, function names, type definitions, and the catalogId for create_surface. Use get_component_details and get_function_details for full property references before building surfaces.
const result = await mcpClient.callTool({ name: 'get_catalogs', arguments: { sessionId },});// result.content[0].text is JSON: [{ catalogId, title, content }]// catalogId → pass to create_surface// content → index of components, functions, and type defsIf the returned array is empty, no catalogs are registered for this session yet. Retry after a short delay or inform the user that no UI is available.
formatAction(sessionId, action, clientDataModel?)
Section titled “formatAction(sessionId, action, clientDataModel?)”Converts a raw ActionEvent into a natural-language string suitable for passing to an LLM as a user message.
import { formatAction } from '@freesail/agent-runtime';
async onSessionNotification(notification: SessionNotification) { if (notification.type === 'error') return; const message = formatAction(this.sessionId, notification.event, notification.event.clientDataModel); const reply = await this.llm.chat(message);}jsonSchemaToZod(schema)
Section titled “jsonSchemaToZod(schema)”Converts a JSON Schema object (from MCP tool definitions) to a Zod schema. Used when wrapping MCP tools for frameworks that require Zod schemas (e.g. LangChain).
import { jsonSchemaToZod } from '@freesail/agent-runtime';
const zodSchema = jsonSchemaToZod(mcpTool.inputSchema as Record<string, unknown>);AgentFactory
Section titled “AgentFactory”A function (sessionId: string) => FreesailAgent that the runtime calls when a new session connects. One agent instance is created per session; the runtime holds a reference until the session disconnects and then lets it be GC’d.
type AgentFactory = (sessionId: string) => FreesailAgent;Complete Example
Section titled “Complete Example”import { Client } from '@modelcontextprotocol/sdk/client/index.js';import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';import { FreesailAgentRuntime, FreesailAgent, SessionNotification, SharedCache,} from '@freesail/agent-runtime';
// ─── MCP client ─────────────────────────────────────────────────────────────
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')));
// ─── Shared cache ────────────────────────────────────────────────────────────
const cache = new SharedCache(mcpClient, () => myFramework.getTools(mcpClient));
// ─── Per-session agent ───────────────────────────────────────────────────────
class MySessionAgent implements FreesailAgent { private history: string[] = [];
constructor(private sessionId: string) {}
async onSessionConnected(sessionId: string) { cache.invalidate(); // pick up latest catalogs }
async onSessionDisconnected(sessionId: string) { this.history = []; // release memory }
async onSessionNotification(notification: SessionNotification) { if (notification.type === 'error') { const { event } = notification; console.warn(`Client error [${event.surfaceId}]: ${event.code} — ${event.message}`); return; }
const { event: action } = notification;
// Chat messages arrive as chat_send on __chat if (action.name === 'chat_send' && action.surfaceId === '__chat') { const text = (action.context as { text?: string })?.text; if (text) { const systemPrompt = await cache.getSystemPrompt(); const tools = await cache.getTools(); // Call LLM with systemPrompt, tools, history, text ... } return; }
// All other UI actions console.log(`Action: ${action.name} from ${action.sourceComponentId}`); }}
// ─── Runtime ─────────────────────────────────────────────────────────────────
const runtime = new FreesailAgentRuntime({ mcpClient, agentFactory: (sessionId) => new MySessionAgent(sessionId),});
await runtime.start();
process.on('SIGINT', async () => { await runtime.stop(); await mcpClient.close(); process.exit(0);});