seahorse-agent-bridge
v1.2.0
Published
JavaScript SDK for integrating Seahorse MCP Agent into parent applications
Maintainers
Readme
Seahorse Agent Bridge SDK
JavaScript/TypeScript SDK for integrating Seahorse MCP Agent into parent web applications.
Features
- Tool Registration: Register custom tools that the agent can call via MCP-like protocol
- Message Injection: Send messages to the agent's input area programmatically
- Window Management: Open/close agent in a new window
- Connection State Management: Real-time connection monitoring (disconnected, connecting, connected, reconnecting)
- Automatic Session Recovery: Survive parent window refresh without losing agent connection
- Heartbeat & Reconnection: Automatic health checks and reconnection on connection loss
- Bidirectional Communication: PostMessage + WebSocket-based secure communication
- TypeScript Support: Full type definitions included
- React-Friendly: Callback-based API integrates seamlessly with React hooks
Architecture Overview
The Seahorse Agent Bridge enables bidirectional communication between a parent web application and the Seahorse Agent through two distinct channels:
1. Message Injection (Parent → Agent)
Simple PostMessage-based communication for sending messages to the agent's input area.
2. Tool Calls (Agent ↔ Parent)
WebSocket + PostMessage hybrid architecture for executing parent-provided tools during agent's reasoning process.
graph TB
subgraph "Parent Window"
PB[SeahorseAgentBridge SDK]
TH[Tool Handlers]
end
subgraph "Agent Window"
AI[InputArea Component]
PWC[ParentWindowMCPClient]
PWS[ParentWindowWebSocket]
end
subgraph "Agent Backend"
WS[WebSocket Server]
PP[ParentWindowProxy]
AG[SeahorseAgent]
LLM[LLM]
end
PB -->|"1. inject_message<br/>(PostMessage)"| PWC
PWC -->|"2. CustomEvent"| AI
LLM -->|"3. Tool Call Decision"| AG
AG -->|"4. call_tool()"| PP
PP -->|"5. WebSocket Request"| WS
WS -->|"6. tool_call message"| PWS
PWS -->|"7. PostMessage<br/>tool_call"| PWC
PWC -->|"8. PostMessage<br/>tool_call"| PB
PB -->|"9. Execute"| TH
TH -->|"10. Result"| PB
PB -->|"11. PostMessage<br/>tool_response"| PWC
PWC -->|"12. PostMessage<br/>tool_response"| PWS
PWS -->|"13. WebSocket Response"| WS
WS -->|"14. MCPToolCallResult"| PP
PP -->|"15. Result"| AG
AG -->|"16. Continue"| LLMDetailed Communication Flow
Flow 1: Message Injection (Parent → Agent InputArea)
This is a simple one-way communication for injecting messages into the agent's input area.
sequenceDiagram
participant Parent as Parent Window<br/>(SeahorseAgentBridge)
participant Agent as Agent Window<br/>(ParentWindowMCPClient)
participant Input as InputArea<br/>Component
Parent->>Agent: PostMessage<br/>{type: "inject_message", payload: {message, autoSend}}
Note over Agent: Origin validation
Agent->>Input: CustomEvent<br/>("parent-inject-message")
Input->>Input: Set input value
alt autoSend = true
Input->>Input: Auto-submit message
endImplementation Details:
Parent Window (
SeahorseAgentBridge.injectMessage()):injectMessage(message: string, options?: { autoSend?: boolean }) { this.agentWindow.postMessage({ type: 'inject_message', payload: { message, autoSend: options?.autoSend } }, this.agentOrigin); }Agent Frontend (
ParentWindowMCPClient.handleInjectMessage()):private handleInjectMessage(payload: { message: string; autoSend?: boolean }) { window.dispatchEvent(new CustomEvent('parent-inject-message', { detail: { message, autoSend } })); }InputArea Component (Event Listener):
useEffect(() => { const handleParentMessage = (event: CustomEvent) => { const { message, autoSend } = event.detail; setInputValue(message); if (autoSend) { handleSendMessage(); } }; window.addEventListener('parent-inject-message', handleParentMessage); }, []);
Flow 2: Tool Calls (Agent ↔ Parent via WebSocket + PostMessage)
This is a complex bidirectional flow that enables the agent's LLM to call tools provided by the parent window.
sequenceDiagram
participant Parent as Parent Window<br/>(Bridge SDK)
participant PWC as Agent Frontend<br/>(ParentWindowMCPClient)
participant PWS as Agent Frontend<br/>(ParentWindowWebSocket)
participant WS as Agent Backend<br/>(WebSocket Server)
participant Proxy as Agent Backend<br/>(ParentWindowProxy)
participant Agent as Agent Backend<br/>(SeahorseAgent)
participant LLM as LLM
Note over Parent,LLM: 1. Tool Registration Phase
Parent->>Parent: registerTool({name, description, parameters})
Parent->>Parent: onToolCall(name, handler)
Parent->>PWC: PostMessage<br/>{type: "tools_list", payload: {tools}}
PWC->>Proxy: POST /api/parent-window/tools<br/>{tools}
Proxy->>Proxy: update_tools()
Note over Parent,LLM: 2. Session Creation & WebSocket Connection
PWC->>WS: WebSocket Connect<br/>/ws/parent-window/{session_id}
WS->>WS: connection_manager.connect(session_id, ws)
WS-->>PWC: WebSocket Connected
Note over Parent,LLM: 3. Agent Query Processing
Agent->>Agent: User sends query
Agent->>LLM: System Prompt + Available Tools<br/>(including parent-window tools)
LLM->>LLM: Reasoning: Need to call<br/>parent-window_get_sales_data
Note over Parent,LLM: 4. Tool Execution Request
LLM-->>Agent: Tool Call Decision<br/>{name: "parent-window_get_sales_data", args: {days: 30}}
Agent->>Proxy: call_tool(server="parent-window",<br/>tool_name="get_sales_data", args, session_id)
Proxy->>WS: connection_manager.call_tool()<br/>with request_id
Note over Parent,LLM: 5. WebSocket → PostMessage Bridge
WS->>PWS: WebSocket Message<br/>{type: "tool_call", request_id, tool_name, arguments}
PWS->>PWC: parentWindowMCPClient.callTool(tool_name, args)
PWC->>Parent: window.opener.postMessage()<br/>{type: "tool_call", call_id, tool_name, arguments}
Note over Parent,LLM: 6. Parent Tool Execution
Parent->>Parent: Validate origin
Parent->>Parent: Find tool handler
Parent->>Parent: Execute handler(arguments)
Parent->>Parent: Get result
Note over Parent,LLM: 7. Response Flow Back
Parent->>PWC: window.postMessage()<br/>{type: "tool_response", call_id, result, success}
PWC->>PWS: Resolve Promise with result
PWS->>WS: WebSocket Message<br/>{type: "tool_response", request_id, response}
WS->>Proxy: Resolve Future with response
Proxy-->>Agent: MCPToolCallResult{success, content}
Agent->>LLM: Tool result:<br/>{totalSales: $125k, period: "30 days"}
LLM->>LLM: Continue reasoning with result
LLM-->>Agent: Final response to userImplementation Details:
Backend Components
WebSocket Server (
app/routers/parent_window_websocket.py):class ParentWindowConnectionManager: active_connections: Dict[str, WebSocket] # session_id -> WebSocket pending_requests: Dict[str, asyncio.Future] # request_id -> Future async def call_tool(self, session_id: str, tool_name: str, arguments: Dict, timeout: float = 30.0) -> Dict: request_id = str(uuid.uuid4()) future = asyncio.Future() self.pending_requests[request_id] = future # Send via WebSocket await websocket.send_json({ "type": "tool_call", "request_id": request_id, "tool_name": tool_name, "arguments": arguments }) # Wait for response (blocking for agent) response = await asyncio.wait_for(future, timeout=timeout) return responseParentWindowProxy (
app/mcp/parent_window_proxy.py):async def call_tool(self, server: str, tool_name: str, arguments: Dict, session_id: str = None) -> MCPToolCallResult: from ..routers.parent_window_websocket import connection_manager # Actually execute tool via WebSocket (synchronous-looking async) response = await connection_manager.call_tool( session_id=session_id, tool_name=tool_name, arguments=arguments, timeout=30.0 ) if response.get("success"): return MCPToolCallResult(success=True, content=response.get("result")) else: return MCPToolCallResult(success=False, error=response.get("error"))SeahorseAgent (
app/agent.py):- Collects parent window tools in
available_tools - Routes
parent-window_*tool calls to ParentWindowProxy - Passes
session_idfor WebSocket routing
- Collects parent window tools in
Frontend Components
ParentWindowWebSocket (
frontend/src/services/ParentWindowWebSocket.ts):class ParentWindowWebSocketClient { private ws: WebSocket | null = null; private sessionId: string | null = null; async connect(sessionId: string): Promise<void> { const wsUrl = `${protocol}//${host}/ws/parent-window/${sessionId}`; this.ws = new WebSocket(wsUrl); this.ws.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type === 'tool_call') { this.handleToolCall(message); } }; } private async handleToolCall(request: ToolCallRequest): Promise<void> { // Call parent via PostMessage const result = await parentWindowMCPClient.callTool( request.tool_name, request.arguments ); // Send response back via WebSocket this.send({ type: 'tool_response', request_id: request.request_id, response: { success: true, result } }); } }ParentWindowMCPClient (
frontend/src/services/ParentWindowMCPClient.ts):async callTool(toolName: string, args: any): Promise<any> { return new Promise((resolve, reject) => { const callId = `call_${Date.now()}_${Math.random()}`; // Timeout const timeoutId = setTimeout(() => { reject(new Error(`Timeout: ${toolName}`)); }, this.timeoutMs); // Register pending call this.pendingCalls.set(callId, { resolve, reject, timeoutId }); // PostMessage to parent window.opener.postMessage({ type: 'tool_call', payload: { call_id: callId, tool_name: toolName, arguments: args } }, origin); }); } private handleToolResponse(payload: {call_id, result, success}) { const pending = this.pendingCalls.get(call_id); clearTimeout(pending.timeoutId); if (success) { pending.resolve(result); // Resolves Promise in callTool() } else { pending.reject(new Error(payload.error)); } }App.tsx (WebSocket Lifecycle):
useEffect(() => { if (currentSession?.session_id) { parentWindowWebSocket.connect(currentSession.session_id); return () => { parentWindowWebSocket.disconnect(); }; } }, [currentSession?.session_id]);
Parent Window SDK
SeahorseAgentBridge (sdk/seahorse-agent-bridge/src/index.ts):
class SeahorseAgentBridge {
private tools: ToolDefinition[] = [];
private toolHandlers: Map<string, ToolCallHandler> = new Map();
registerTool(tool: ToolDefinition) {
this.tools.push(tool);
this.sendToolsToAgent();
}
onToolCall(toolName: string, handler: ToolCallHandler) {
this.toolHandlers.set(toolName, handler);
}
private handleToolCall(payload: {call_id, tool_name, arguments}) {
const handler = this.toolHandlers.get(payload.tool_name);
try {
const result = await handler(payload.arguments);
// Send response back
this.agentWindow.postMessage({
type: 'tool_response',
payload: {
call_id: payload.call_id,
result: result,
success: true
}
}, this.agentOrigin);
} catch (error) {
this.agentWindow.postMessage({
type: 'tool_response',
payload: {
call_id: payload.call_id,
error: error.message,
success: false
}
}, this.agentOrigin);
}
}
}Key Design Decisions
Why WebSocket + PostMessage Hybrid?
- WebSocket: Backend-to-Frontend real-time communication (agent-initiated tool calls)
- PostMessage: Cross-origin communication between windows (secure parent-child communication)
- Combination enables bidirectional flow while maintaining security boundaries
Why Session-Based WebSocket Routing?
- Multiple agent frontends can connect to one backend
- Each WebSocket connection is mapped to a session_id
- Enables proper request routing in multi-user scenarios
Why asyncio.Future for Synchronous-Looking Async?
- Agent's tool execution needs to block until parent responds
- Future allows WebSocket response to resolve the waiting call_tool()
- Maintains clean sequential code flow in agent logic
Why request_id and call_id Matching?
- request_id: Backend WebSocket request/response matching
- call_id: Frontend PostMessage request/response matching
- Enables concurrent tool calls without response mix-up
Installation
npm install seahorse-agent-bridgeQuick Start
Basic Usage (Vanilla JavaScript/TypeScript)
import { SeahorseAgentBridge } from 'seahorse-agent-bridge';
// Initialize bridge with connection management
const bridge = new SeahorseAgentBridge({
agentOrigin: 'https://seahorse-agent.agent.seahorse.dnotitia.com',
autoRecover: true, // Enable automatic session recovery (default: true)
// Optional: Connection state callbacks
onConnectionChange: (state, previousState) => {
console.log(`Connection: ${previousState} → ${state}`);
},
onWindowClosed: () => {
console.log('Agent window closed by user');
},
onSessionRecovered: (window) => {
console.log('Session recovered after page refresh!');
}
});
// Register a tool
bridge.registerTools([{
name: 'get_user_info',
description: 'Get current user information',
parameters: {
type: 'object',
properties: {},
required: []
}
}]);
// Set tool handler
bridge.onToolCall('get_user_info', async (args) => {
return {
name: 'John Doe',
email: '[email protected]'
};
});
// Open agent window
bridge.openAgent();
// Inject a message
bridge.injectMessage('Get my user information', { autoSend: true });React Integration Example
import React, { useEffect, useRef, useState } from 'react';
import { SeahorseAgentBridge, ConnectionState } from 'seahorse-agent-bridge';
function MyApp() {
const bridgeRef = useRef<SeahorseAgentBridge | null>(null);
const [agentWindow, setAgentWindow] = useState<Window | null>(null);
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
useEffect(() => {
// Initialize bridge with callbacks
const bridge = new SeahorseAgentBridge({
agentOrigin: 'https://seahorse-agent.agent.seahorse.dnotitia.com',
autoRecover: true,
onConnectionChange: (state, previousState) => {
setConnectionState(state);
console.log(`Connection: ${previousState} → ${state}`);
},
onWindowClosed: () => {
setAgentWindow(null);
},
onSessionRecovered: (recoveredWindow) => {
setAgentWindow(recoveredWindow);
console.log('Session recovered!');
}
});
bridgeRef.current = bridge;
// Register tools (auto-recovery happens after this)
bridge.registerTools([{
name: 'get_user_data',
description: 'Get user data from parent app',
parameters: {
type: 'object',
properties: {
userId: { type: 'string', description: 'User ID' }
},
required: ['userId']
}
}]);
// Register handler
bridge.onToolCall('get_user_data', async (args) => {
const { userId } = args;
// Fetch from your API
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
// Cleanup
return () => {
bridge.destroy();
};
}, []);
const handleOpenAgent = () => {
if (bridgeRef.current) {
const window = bridgeRef.current.openAgent();
setAgentWindow(window);
}
};
return (
<div>
<button onClick={handleOpenAgent} disabled={connectionState === 'connected'}>
Open AI Assistant
</button>
<div>Status: {connectionState}</div>
</div>
);
}API Reference
Constructor
new SeahorseAgentBridge(config: BridgeConfig)Config Options:
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| agentOrigin | string | ✅ | - | Agent's origin for PostMessage security validation |
| agentUrl | string | ❌ | agentOrigin | Full URL to open agent window |
| heartbeatInterval | number | ❌ | 5000 | Interval between heartbeat pings (ms) |
| heartbeatTimeout | number | ❌ | 15000 | Time before declaring connection dead (ms) |
| autoRecover | boolean | ❌ | true | Auto-recover session after parent refresh |
| onConnectionChange | (state, previousState) => void | ❌ | - | Callback for connection state changes |
| onWindowClosed | () => void | ❌ | - | Callback when agent window closes |
| onSessionRecovered | (window) => void | ❌ | - | Callback when session is recovered |
Connection States:
'disconnected': No connection to agent'connecting': Opening agent window or establishing connection'connected': Active connection with heartbeat'reconnecting': Connection lost, attempting to reconnect
Methods
registerTool(tool: ToolDefinition): void
Register a single tool.
registerTools(tools: ToolDefinition[]): void
Register multiple tools at once. If autoRecover is enabled, this will automatically attempt session recovery.
onToolCall(toolName: string, handler: ToolCallHandler): void
Set execution handler for a tool. Handler can be async.
bridge.onToolCall('get_data', async (args) => {
const result = await fetchDataFromAPI(args);
return result;
});openAgent(): Window | null
Open agent in a new window. Returns window reference or null if popup blocked. Automatically starts heartbeat and connection monitoring.
closeAgent(): void
Close the agent window. Stops heartbeat and cleanup resources.
injectMessage(message: string, options?: MessageInjectionOptions): void
Inject a message into the agent's input area.
Options:
autoSend: If true, automatically sends the message after injection
isAgentOpen(): boolean
Check if agent window is currently open (not closed by user).
getConnectionState(): ConnectionState
Get current connection state: 'disconnected', 'connecting', 'connected', or 'reconnecting'.
reconnect(): boolean
Manually trigger reconnection attempt. Returns true if window recovered, false otherwise.
tryRecoverSession(): void
Attempt to recover existing agent window session. Called automatically by registerTools() if autoRecover is enabled.
destroy(): void
Cleanup and remove all event listeners. Note: Does NOT close the agent window to allow session recovery on parent refresh.
Example: Dashboard Integration
// Dashboard tools
const dashboardTools = [
{
name: 'get_dashboard_overview',
description: 'Get dashboard overview metrics',
parameters: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'get_sales_data',
description: 'Get sales data for date range',
parameters: {
type: 'object',
properties: {
days: { type: 'number', description: 'Number of days' }
},
required: ['days']
}
}
];
// Register tools
bridge.registerTools(dashboardTools);
// Set handlers
bridge.onToolCall('get_dashboard_overview', async () => {
return {
totalRevenue: 125000,
activeUsers: 1523,
conversionRate: 3.2
};
});
bridge.onToolCall('get_sales_data', async (args) => {
const { days } = args;
// Fetch real data from your backend
return {
period: `Last ${days} days`,
data: [/* sales data */]
};
});Connection Management
Connection State Flow
stateDiagram-v2
[*] --> disconnected: Initial State
disconnected --> connecting: openAgent()
connecting --> connected: Heartbeat Success
connecting --> disconnected: Window Blocked/Closed
connected --> reconnecting: Heartbeat Timeout
reconnecting --> connected: Recovery Success
reconnecting --> disconnected: Max Retries Exceeded
connected --> disconnected: User Closes Window
disconnected --> connecting: Parent Refresh + autoRecoverHeartbeat Mechanism
The SDK automatically monitors connection health using a heartbeat (ping/pong) system:
- Ping Interval: Every 5 seconds (configurable via
heartbeatInterval) - Pong Timeout: 15 seconds (configurable via
heartbeatTimeout) - Auto-Reconnection: Up to 5 attempts if connection lost
- Window Monitoring: Checks every second if agent window still open
How it works:
// Bridge sends ping every 5 seconds
bridge.sendPing() → Agent receives ping → Agent sends pong → Bridge receives pong
// If no pong received for 15 seconds
connection state: connected → reconnecting
attempt to recover window reference
if recovered: connected
if not recovered after 5 attempts: disconnectedSession Recovery
Problem: When parent window refreshes, it loses reference to the opened agent window, even though the window is still open.
Solution: The SDK automatically recovers the window reference using window.open('', 'seahorse-agent') trick.
Recovery Flow:
sequenceDiagram
participant User
participant Parent as Parent Window
participant Bridge as SeahorseAgentBridge
participant Agent as Agent Window
User->>Parent: Refresh page (F5)
Parent->>Parent: Page reloads
Parent->>Bridge: new SeahorseAgentBridge({autoRecover: true})
Parent->>Bridge: registerTools([...])
Note over Bridge: Auto-recovery triggered
Bridge->>Browser: window.open('', 'seahorse-agent')
Browser-->>Bridge: Returns existing window reference
Bridge->>Agent: Send tools_list via PostMessage
Agent-->>Bridge: Receive tools_list
Bridge->>Bridge: Start heartbeat
Bridge->>Bridge: setConnectionState('connected')
Bridge->>Parent: onSessionRecovered(window)
Parent->>User: ✅ Session recovered!Usage:
const bridge = new SeahorseAgentBridge({
agentOrigin: '...',
autoRecover: true, // ✅ Enables automatic recovery (default)
onSessionRecovered: (window) => {
console.log('Session recovered! Agent still connected.');
setAgentWindow(window);
}
});
// Register tools AFTER initialization
bridge.registerTools([...]); // Recovery happens hereImportant Notes:
- Recovery only works if agent window is still open
- Recovery happens automatically after
registerTools()is called - Window must be opened with name
'seahorse-agent'(SDK does this automatically) - Parent must register same tools after refresh for agent to continue working
- Recovery fails silently if window was closed - call
openAgent()to open new window
Backward Compatibility
The SDK maintains backward compatibility with event-based patterns:
// New callback pattern (recommended)
const bridge = new SeahorseAgentBridge({
onConnectionChange: (state, previousState) => {
console.log('Connection changed');
}
});
// Old event pattern (still works)
window.addEventListener('agent-connection-state-changed', (event) => {
const { previousState, currentState } = event.detail;
console.log('Connection changed');
});Both patterns work simultaneously - SDK dispatches CustomEvents AND calls callbacks.
Security
The bridge implements multiple security layers:
1. Origin Validation
// Agent validates all incoming PostMessage events
private isValidOrigin(origin: string): boolean {
return this.allowedOrigins.includes(origin);
}
private handleParentMessage(event: MessageEvent): void {
if (!this.isValidOrigin(event.origin)) {
console.warn('Message from unauthorized origin:', event.origin);
return;
}
// Process message...
}allowedOriginsconfigured in backendconfig.json- All PostMessage events are validated before processing
- Unauthorized origins are rejected silently
2. WebSocket Session Isolation
# Backend maintains session-based WebSocket connections
class ParentWindowConnectionManager:
active_connections: Dict[str, WebSocket] # session_id -> WebSocket
async def call_tool(self, session_id: str, ...):
websocket = self.active_connections.get(session_id)
# Each session has isolated WebSocket channel- Each agent session has its own WebSocket connection
- Tool calls are routed to correct session_id
- Prevents cross-session data leakage
3. Request/Response Matching
// Unique IDs prevent response confusion
const callId = `call_${Date.now()}_${Math.random().toString(36)}`;
this.pendingCalls.set(callId, { resolve, reject, timeoutId });- Each tool call gets unique
call_idandrequest_id - Responses matched to exact pending request
- Prevents response hijacking or mix-up
4. Timeout Protection
// 30-second timeout prevents hanging
const timeoutId = setTimeout(() => {
this.pendingCalls.delete(callId);
reject(new Error(`Timeout: ${toolName}`));
}, this.timeoutMs);- Default 30-second timeout for all tool calls
- Prevents resource exhaustion from hanging requests
- Configurable via backend settings
5. Parent-Controlled Execution
- Parent application maintains full control over tool handlers
- Tools only execute if handler is registered
- Parent validates tool arguments before execution
- No arbitrary code execution from agent
6. No Sensitive Data in Transit
- Tool definitions sent to agent (names, descriptions, parameters)
- Actual tool execution logic stays in parent
- Only structured JSON data exchanged
- No code or functions transmitted
Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
License
MIT
