Skip to content

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.

Terminal window
npm install @freesail/agent-runtime

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();
interface AgentRuntimeConfig {
mcpClient: Client;
agentFactory: AgentFactory; // (sessionId: string) => FreesailAgent
}

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.

Unsubscribes from all active MCP resource subscriptions. Call before closing the MCP client for clean shutdown.

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.


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>;
}
MethodWhen calledNotes
onSessionConnectedA new browser tab connectsGood place to invalidate the cache and send a welcome message
onSessionDisconnectedThe browser tab closesCalled after all in-flight onSessionNotification promises settle (drain guarantee)
onSessionNotificationAny UI action, chat message, or client error arrivesSingle dispatch point — use notification.type to distinguish actions from errors

Important: If onSessionNotification is 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, and delete_surface — per-surface when there are unread actions. The agent receives an error response directing it to call get_pending_actions before retrying. Implementing onSessionNotification avoids this by draining actions automatically via push.

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}`);
}

The discriminated union passed to onSessionNotification:

type SessionNotification =
| { type: 'action'; event: ActionEvent }
| { type: 'error'; event: ClientErrorEvent };
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)
}
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)
}

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 ResourceUpdated notifications — no polling.
  • Session ownership: the runtime calls claim_session when a session connects and release_session when 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: onSessionDisconnected is never called while an onSessionNotification call is still running for that session. The runtime waits using Promise.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, calling onSessionConnected for 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().

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 level
const 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();

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.

Fetches tool definitions via the provided toolsFactory. Concurrent callers share the same in-flight Promise.

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).


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);

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? }, ...]

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');

Calling get_catalogs is 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 defs

If 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);
}

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>);

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;

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);
});