Skip to content

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.


A Freesail agent is a Node.js process that:

  1. Connects to the gateway via MCP Streamable HTTP
  2. Receives push notifications when a browser session connects or an action arrives
  3. Handles user actions by calling the LLM and invoking MCP tools to update the UI
  4. 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.


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

The agent reads its LLM provider and API key from environment variables. Copy the example file and fill in your values:

Terminal window
cp .env.example .env

.env.example:

Terminal window
# LLM Provider — choose 'gemini' (default), 'openai', or 'claude'
LLM_PROVIDER=gemini
# Google Gemini
GOOGLE_API_KEY=your-google-api-key-here
GEMINI_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=3001

Never commit .env. Add it to your .gitignore. The .env.example file should contain only placeholder values.


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

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

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.


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

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.

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.


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

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 shutdown
process.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.


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 state
private 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 message
await 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.


PatternWhy
capabilities: { resources: { subscribe: true } }Required for push-based action delivery via MCP resource subscriptions
One SharedCache per processAvoids redundant MCP round-trips across concurrent sessions
Invalidate cache in onSessionConnectedEnsures new sessions see the latest catalog list
Inject sessionId into every promptPrevents the LLM reusing a stale sessionId from conversation history
Block __ surfaces in the tool wrapperAgents should only control surfaces they created
Update __chat via mcpClient.callTool() directlyBypasses the tool wrapper’s __ guard for legitimate agent-initiated chat updates
Clear conversationHistory in onSessionDisconnectedExplicitly releases large allocations before GC
Implement onClientErrorHandle client-side validation failures and render errors