Building an Agent
This guide walks through building a production-shaped AI agent that connects to the Freesail gateway, manages per-session state, and drives the UI using MCP tools. The example uses LangChain, but the patterns apply to any agent framework.
Overview
Section titled “Overview”A Freesail agent is a Node.js process that:
- Connects to the gateway via MCP Streamable HTTP
- Receives push notifications when a browser session connects or an action arrives
- Handles user actions by calling the LLM and invoking MCP tools to update the UI
- Cleans up per-session state when the tab disconnects
The @freesail/agentruntime package handles the session lifecycle and action routing via MCP resource subscriptions — your agent only implements business logic.
Project Structure
Section titled “Project Structure”agent/ src/ index.ts # Entry point: MCP connection, LLM setup, runtime start langchain-agent.ts # Per-session agent implementing FreesailAgent langchain-adapter.ts # Wraps MCP tools as LangChain DynamicStructuredTools .env # API keys and configuration (never commit this) .env.example # Committed template with placeholder values package.jsonStep 1: Configure the LLM Provider
Section titled “Step 1: Configure the LLM Provider”The agent reads its LLM provider and API key from environment variables. Copy the example file and fill in your values:
cp .env.example .env.env.example:
# LLM Provider — choose 'gemini' (default), 'openai', or 'claude'LLM_PROVIDER=gemini
# Google GeminiGOOGLE_API_KEY=your-google-api-key-hereGEMINI_MODEL=gemini-2.5-pro
# OpenAI (uncomment to use)# OPENAI_API_KEY=your-openai-api-key-here# OPENAI_MODEL=gpt-4o
# Anthropic Claude (uncomment to use)# ANTHROPIC_API_KEY=your-anthropic-api-key-here# CLAUDE_MODEL=claude-sonnet-4-5-20250929
# Ports (these are the defaults — only set if you need to change them)# AGENT_PORT=3002# MCP_PORT=3000# GATEWAY_PORT=3001Never commit
.env. Add it to your.gitignore. The.env.examplefile should contain only placeholder values.
Step 2: Connect to the Gateway
Section titled “Step 2: Connect to the Gateway”In index.ts, create an MCP client and connect to the gateway. The client must declare resources: { subscribe: true } capability so the runtime can use MCP resource subscriptions for push-based action delivery:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';import { FreesailAgentRuntime, SharedCache } from '@freesail/agent-runtime';
const MCP_PORT = parseInt(process.env['MCP_PORT'] ?? '3000', 10);
const mcpClient = new Client( { name: 'my-agent', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
await mcpClient.connect( new StreamableHTTPClientTransport(new URL(`http://localhost:${MCP_PORT}/mcp`)));
const { tools } = await mcpClient.listTools();console.log('MCP tools:', tools.map(t => t.name).join(', '));Step 3: Initialise the LLM
Section titled “Step 3: Initialise the LLM”Select the LLM provider based on the LLM_PROVIDER environment variable:
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
const LLM_PROVIDER = (process.env['LLM_PROVIDER'] ?? 'gemini').toLowerCase();const LLM_TEMPERATURE = parseFloat(process.env['LLM_TEMPERATURE'] ?? '0.7');
let model: BaseChatModel;
if (LLM_PROVIDER === 'openai') { const { ChatOpenAI } = await import('@langchain/openai'); model = new ChatOpenAI({ apiKey: process.env['OPENAI_API_KEY'], model: process.env['OPENAI_MODEL'] ?? 'gpt-4o', temperature: LLM_TEMPERATURE, streaming: true, });} else if (LLM_PROVIDER === 'claude') { const { ChatAnthropic } = await import('@langchain/anthropic'); model = new ChatAnthropic({ anthropicApiKey: process.env['ANTHROPIC_API_KEY'], model: process.env['CLAUDE_MODEL'] ?? 'claude-sonnet-4-5-20250929', temperature: LLM_TEMPERATURE, streaming: true, });} else { // Default: Gemini const { ChatGoogleGenerativeAI } = await import('@langchain/google-genai'); model = new ChatGoogleGenerativeAI({ apiKey: process.env['GOOGLE_API_KEY'], model: process.env['GEMINI_MODEL'] ?? 'gemini-2.5-pro', temperature: LLM_TEMPERATURE, });}Step 4: Set Up the Shared Cache
Section titled “Step 4: Set Up the Shared Cache”The SharedCache fetches the system prompt and MCP tool definitions once at process level and shares them across all sessions. This avoids redundant MCP round-trips when many sessions are active simultaneously.
import { SharedCache } from '@freesail/agent-runtime';import { LangChainAdapter } from './langchain-adapter.js';import type { DynamicStructuredTool } from '@langchain/core/tools';
const sharedCache = new SharedCache<DynamicStructuredTool[]>( mcpClient, () => LangChainAdapter.getTools(mcpClient));The toolsFactory parameter is framework-specific — here it converts MCP tool definitions into LangChain DynamicStructuredTool objects. See Step 6 for the adapter implementation.
Step 5: Implement the Per-Session Agent
Section titled “Step 5: Implement the Per-Session Agent”Each browser session gets its own agent instance. All per-session state — conversation history, chat log — lives as instance fields.
The FreesailAgent interface includes an optional onClientError hook for handling client-reported errors (validation failures, render errors):
import { AIMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';import type { FreesailAgent, ActionEvent, ClientErrorEvent } from '@freesail/agent-runtime';import { SharedCache } from '@freesail/agent-runtime';import type { DynamicStructuredTool } from '@langchain/core/tools';
export class MySessionAgent implements FreesailAgent { private conversationHistory: (HumanMessage | AIMessage | ToolMessage)[] = [];
constructor( private sessionId: string, private mcpClient: Client, private model: BaseChatModel, private sharedCache: SharedCache<DynamicStructuredTool[]>, ) {}
// ── Lifecycle ────────────────────────────────────────────────────────────
async onSessionConnected(sessionId: string) { // Invalidate the cache so this session picks up the latest catalog list. // New sessions may have registered catalogs not present before. this.sharedCache.invalidate(); }
async onSessionDisconnected(sessionId: string) { // Explicitly clear large allocations before GC this.conversationHistory = []; }
// ── Action routing ──────────────────────────────────────────────────────
async onAction(action: ActionEvent) { // Route chat messages from 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; }
// All other UI actions become natural-language messages to the LLM const contextStr = Object.keys(action.context).length > 0 ? `\nAction data: ${JSON.stringify(action.context, null, 2)}` : ''; const dataModelStr = action.clientDataModel && Object.keys(action.clientDataModel).length > 0 ? `\nClient data model: ${JSON.stringify(action.clientDataModel, null, 2)}` : '';
const message = `[UI Action] The user triggered "${action.name}" on component ` + `"${action.sourceComponentId}" in surface "${action.surfaceId}".${contextStr}${dataModelStr}`;
await this.handleChat(message); }
// ── Client error handling ───────────────────────────────────────────────
async onClientError(error: ClientErrorEvent) { // Optionally log or react to client-side errors console.warn( `[${this.sessionId}] Client error on surface "${error.surfaceId}":`, error.code, error.message, error.path ?? '' ); }
// ── Chat handler ─────────────────────────────────────────────────────────
private async handleChat(userMessage: string) { // Include the sessionId in every prompt so the LLM uses the correct // sessionId in all tool calls (create_surface, update_components, etc.) const sessionPrompt = `[Session: "${this.sessionId}"] When calling any tool, you MUST use ` + `sessionId: "${this.sessionId}". Do NOT create or modify "__" surfaces.\n\n` + `User: ${userMessage}`;
const reply = await this.chat(sessionPrompt); // Push the reply to the __chat data model, etc. }
// ── LLM execution loop ──────────────────────────────────────────────────
private async chat(userMessage: string): Promise<string> { const systemPrompt = await this.sharedCache.getSystemPrompt(); const tools = await this.sharedCache.getTools(); const modelWithTools = (this.model as any).bindTools(tools);
this.conversationHistory.push(new HumanMessage(userMessage));
const messages = [new SystemMessage(systemPrompt), ...this.conversationHistory]; let response = await modelWithTools.invoke(messages); const turnMessages: (AIMessage | ToolMessage)[] = [];
// Agentic tool-use loop while (response.tool_calls?.length > 0) { turnMessages.push(new AIMessage({ content: typeof response.content === 'string' ? response.content : '', tool_calls: response.tool_calls, }));
for (const toolCall of response.tool_calls) { const tool = tools.find(t => t.name === toolCall.name); const result = tool ? String(await tool.invoke(toolCall.args)) : `Unknown tool: ${toolCall.name}`; turnMessages.push(new ToolMessage({ content: result, name: toolCall.name, tool_call_id: toolCall.id ?? toolCall.name, })); }
response = await modelWithTools.invoke([ new SystemMessage(systemPrompt), ...this.conversationHistory, ...turnMessages, ]); }
this.conversationHistory.push(...turnMessages);
const text = typeof response.content === 'string' ? response.content : Array.isArray(response.content) ? response.content.map((p: any) => p.text ?? '').join('') : '';
if (text.trim()) this.conversationHistory.push(new AIMessage(text)); return text; }}Session prompt discipline
Section titled “Session prompt discipline”Always inject the sessionId into the prompt immediately before the user message, as shown above. This prevents the LLM from accidentally reusing a sessionId from earlier in the conversation history when calling tools.
Blocking agent writes to __ surfaces
Section titled “Blocking agent writes to __ surfaces”Agents should never write to client-managed surfaces. The LangChain adapter enforces this by intercepting tool calls where surfaceId starts with __ and returning an error string instead of forwarding to MCP. See Step 6.
Step 6: Adapt MCP Tools for LangChain
Section titled “Step 6: Adapt MCP Tools for LangChain”The LangChainAdapter converts MCP tool definitions into LangChain DynamicStructuredTool objects using jsonSchemaToZod from @freesail/agent-runtime:
import { tool, type DynamicStructuredTool } from '@langchain/core/tools';import { Client } from '@modelcontextprotocol/sdk/client/index.js';import { jsonSchemaToZod } from '@freesail/agent-runtime';
export class LangChainAdapter { static async getTools(mcpClient: Client): Promise<DynamicStructuredTool[]> { const { tools: mcpTools } = await mcpClient.listTools();
return mcpTools.map(mcpTool => tool( async (args: Record<string, unknown>) => { // Block agent from writing to client-managed surfaces const surfaceId = (args as any).surfaceId as string | undefined; if (surfaceId?.startsWith('__')) { return `Error: "${surfaceId}" is a client-managed surface. ` + `Agents may not call ${mcpTool.name} on it.`; }
const result = await mcpClient.callTool({ name: mcpTool.name, arguments: args, });
return (result.content as Array<{ type: string; text?: string }>) .map(c => c.type === 'text' ? c.text ?? '' : JSON.stringify(c)) .join('\n'); }, { name: mcpTool.name, description: mcpTool.description || `MCP tool: ${mcpTool.name}`, schema: jsonSchemaToZod(mcpTool.inputSchema as Record<string, unknown>), } ) as unknown as DynamicStructuredTool ); }}Step 7: Start the Runtime
Section titled “Step 7: Start the Runtime”Wire everything together and start the runtime. The runtime uses MCP resource subscriptions (push notifications) for session and action delivery — no polling:
import { FreesailAgentRuntime } from '@freesail/agent-runtime';
const runtime = new FreesailAgentRuntime({ mcpClient, agentFactory: (sessionId) => new MySessionAgent(sessionId, mcpClient, model, sharedCache),});
await runtime.start();
// Graceful shutdownprocess.on('SIGINT', async () => { await runtime.stop(); await mcpClient.close(); process.exit(0);});Note that agentId is no longer a required field in AgentRuntimeConfig — the runtime derives the agent identity from the MCP transport session.
Step 8: Update the __chat Data Model
Section titled “Step 8: Update the __chat Data Model”The agent drives the chat UI by writing directly to the __chat surface’s data model via mcpClient.callTool() (bypassing the LangChain tool wrapper, which blocks __ surfaces):
// Inside MySessionAgent — helper to update chat stateprivate updateChatModel(path: string, value: unknown) { return this.mcpClient.callTool({ name: 'update_data_model', arguments: { surfaceId: '__chat', sessionId: this.sessionId, path, value }, });}
// Show typing indicator, stream tokens, then commit the final messageawait this.updateChatModel('/', { messages: [...this.chatMessages], isTyping: true, stream: { token: '', active: true },});
// ... stream LLM response, calling updateChatModel('/stream/token', token) per chunk ...
await this.updateChatModel('/', { messages: [...this.chatMessages], isTyping: false, stream: { token: '', active: false },});The __chat surface component tree is bootstrapped by the React app (see ReactUI reference — Bootstrapping Client-Managed Surfaces). The agent only writes data — it never changes the component structure.
Key Patterns Summary
Section titled “Key Patterns Summary”| Pattern | Why |
|---|---|
capabilities: { resources: { subscribe: true } } | Required for push-based action delivery via MCP resource subscriptions |
One SharedCache per process | Avoids redundant MCP round-trips across concurrent sessions |
Invalidate cache in onSessionConnected | Ensures new sessions see the latest catalog list |
Inject sessionId into every prompt | Prevents the LLM reusing a stale sessionId from conversation history |
Block __ surfaces in the tool wrapper | Agents should only control surfaces they created |
Update __chat via mcpClient.callTool() directly | Bypasses the tool wrapper’s __ guard for legitimate agent-initiated chat updates |
Clear conversationHistory in onSessionDisconnected | Explicitly releases large allocations before GC |
Implement onClientError | Handle client-side validation failures and render errors |