Building an outbound-only WebSocket bridge for local AI agents
Architecting Secure Remote Control for Local AI Runtimes via Outbound WebSockets
Current Situation Analysis
Modern development workflows increasingly rely on local AI agents like Claude Code, Copilot CLI, and Gemini CLI. These tools execute directly on developer workstations, maintaining direct read/write access to filesystems, version control repositories, and local toolchains. The operational gap emerges when developers need to trigger, monitor, or schedule these agents outside their physical workspace. Remote orchestration is no longer a luxury; it's a baseline requirement for continuous integration, overnight batch processing, and cross-device workflow continuity.
The industry's default response to this gap has been reverse tunneling. Tools that create public endpoints pointing to localhost are widely adopted because they require minimal configuration. This approach fundamentally inverts the security model. Exposing a local runtime to the public internet creates a persistent attack surface. Every tunnel endpoint is reachable by anyone who discovers the URL, and authentication failures immediately grant network-level access to the host machine. Furthermore, traffic routing through third-party relay infrastructure introduces data exposure risks, latency variability, and operational dependencies on external services that cannot be audited or controlled.
The core misunderstanding lies in treating local AI runtimes as traditional servers. They are not. They are privileged execution environments that should never accept unsolicited inbound connections. The correct architectural pattern mirrors IoT gateways and CI/CD runners: the local machine initiates an outbound connection to a control plane, and the cloud pushes commands down that established channel. This flips the trust boundary, ensures the local firewall remains intact, and aligns with zero-trust networking principles.
Operational data reinforces this shift. Cloud load balancers routinely terminate idle WebSocket connections within 30β60 seconds. Without explicit keepalive mechanisms, tunnels drop silently, causing command loss and state desynchronization. Unbounded message buffering during transient network failures leads to memory exhaustion. Auth-rejected connections require distinct retry logic to prevent endpoint hammering. These are not edge cases; they are production realities that dictate the reliability of any remote agent orchestration system.
WOW Moment: Key Findings
The architectural pivot from inbound tunneling to outbound bridging fundamentally changes how local AI infrastructure behaves under real-world conditions. The following comparison highlights the operational divergence between the two approaches:
| Approach | Attack Surface | Data Path Control | Connection Reliability | Infrastructure Complexity |
|---|---|---|---|---|
| Reverse Proxy Tunnel | Publicly exposed endpoint | Routed through third-party relay | Degrades with idle timeouts | Requires daemon management + tunnel auth |
| Outbound WebSocket Bridge | Zero inbound ports | Direct client-to-cloud TLS | Maintained via heartbeat + backoff | Single process, firewall-friendly |
This finding matters because it decouples remote accessibility from local exposure. Developers gain the ability to launch tasks, stream live output, and schedule multi-step pipelines from any browser or mobile device, while the local machine remains strictly behind the firewall. The cloud control plane never initiates connections; it only responds to the outbound channel. This pattern enables parallel agent execution, guarantees auditability through filesystem isolation, and ensures that sensitive prompts, file contents, and agent context never leave the host environment. The control plane only observes task metadata and lifecycle events.
Core Solution
Building a production-ready outbound bridge requires careful orchestration of connection lifecycle, message routing, and execution isolation. The following implementation demonstrates a class-based architecture that separates concerns, enforces strict boundaries, and handles real-world network instability.
1. Connection Orchestration & Auth Handshake
The relay must establish a secure outbound WebSocket connection with explicit authentication headers. Unlike traditional servers, it never binds to a local port.
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from './logger';
export interface RelayConfig {
endpoint: string;
apiKey: string;
deviceId: string;
heartbeatIntervalMs: number;
maxQueueSize: number;
}
export class AgentRelay {
private socket: WebSocket | null = null;
private config: RelayConfig;
private logger: Logger;
private reconnectTimer: NodeJS.Timeout | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private messageBuffer: string[] = [];
constructor(config: RelayConfig, logger: Logger) {
this.config = config;
this.logger = logger;
}
public start(): void {
this.establishConnection();
}
private establishConnection(): void {
const wsUrl = `${this.config.endpoint}?device_id=${this.config.deviceId}`;
this.socket = new WebSocket(wsUrl, {
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
'X-Device-Id': this.config.deviceId,
'X-Session-Id': uuidv4()
}
});
this.attachLifecycleHandlers();
}
// ... lifecycle handlers defined below
}
Rationale: Explicit session and device identifiers enable the control plane to route commands to the correct runtime. Bearer token authentication ensures that only authorized bridges can establish channels. The connection is initiated outbound, guaranteeing the local firewall never requires inbound rule modifications.
2. Connection Lifecycle & Backoff Strategy
Network instability is inevitable. The relay must distinguish between transient failures and authentication rejections, applying appropriate retry logic.
private attachLifecycleHandlers(): void {
if (!this.socket) return;
this.socket.on('open', () => {
this.logger.info('Relay channel established');
this.flushBuffer();
this.startHeartbeat();
});
this.socket.on('message', (raw: WebSocket.Data) => {
const payload = JSON.parse(raw.toString()) as InboundCommand;
this.dispatchCommand(payload);
});
this.socket.on('close', (code: number, reason: Buffer) => {
this.stopHeartbeat();
const reasonStr = reason.toString();
if (this.isAuthFailure(code, reasonStr)) {
this.logger.warn(`Auth rejected (${code}). Delaying retry.`);
this.scheduleReconnect(30000); // 30s cooldown for auth failures
} else {
this.logger.info(`Channel closed (${code}). Scheduling backoff.`);
this.scheduleReconnect(this.calculateBackoff());
}
});
this.socket.on('error', (err: Error) => {
this.logger.error(`Transport error: ${err.message}`);
});
}
private isAuthFailure(code: number, reason: string): boolean {
const authCodes = [1008, 1002];
const authKeywords = ['401', '403', 'Unauthorized', 'Forbidden'];
return authCodes.includes(code) || authKeywords.some(k => reason.includes(k));
}
private scheduleReconnect(delayMs: number): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => this.establishConnection(), delayMs);
}
private calculateBackoff(): number {
const base = 2000;
const max = 30000;
return Math.min(base * Math.pow(2, Math.random() * 3), max);
}
Rationale: Authentication failures indicate a configuration or credential issue. Retrying immediately creates log noise and wastes resources. A fixed 30-second cooldown prevents endpoint hammering. Transient network drops use exponential backoff with jitter to avoid thundering herd scenarios during widespread outages.
3. Heartbeat Mechanism for Load Balancer Compatibility
Cloud proxies (AWS ALB, Cloudflare, Nginx) terminate idle TCP connections aggressively. A lightweight heartbeat maintains channel liveness without consuming bandwidth.
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
this.sendRaw({ type: 'ping', ts: Date.now() });
}, this.config.heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private sendRaw(payload: Record<string, unknown>): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.bufferMessage(payload);
return;
}
this.socket.send(JSON.stringify(payload));
}
Rationale: A 20-second interval safely sits below the 30β60 second idle timeout threshold used by most managed WebSocket proxies. The payload is minimal, ensuring negligible bandwidth consumption while keeping the TCP keepalive state active.
4. Transient Message Buffering
Agent output continues during reconnect windows. Dropping events creates gaps in live logs and breaks pipeline state tracking.
private bufferMessage(payload: Record<string, unknown>): void {
if (this.messageBuffer.length < this.config.maxQueueSize) {
this.messageBuffer.push(JSON.stringify(payload));
} else {
this.logger.warn('Message buffer full. Dropping oldest event.');
this.messageBuffer.shift();
this.messageBuffer.push(JSON.stringify(payload));
}
}
private flushBuffer(): void {
while (this.messageBuffer.length > 0) {
const msg = this.messageBuffer.shift()!;
this.socket!.send(msg);
}
}
Rationale: A strict cap prevents memory exhaustion during prolonged outages. The ring-buffer behavior (drop oldest) ensures the most recent state is preserved, which aligns with how live monitoring dashboards consume data. Flushing occurs immediately upon reconnection to restore state continuity.
5. Filesystem-Isolated Task Routing
Running multiple agents (Claude Code, Copilot, Gemini CLI) on the same host requires strict context isolation. Shared directories cause prompt pollution, race conditions, and audit failures.
import { exec } from 'child_process';
import { mkdir, writeFile, readFile } from 'fs/promises';
import path from 'path';
export class TaskRouter {
private workspaceRoot: string;
constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
}
public async routeTask(taskId: string, provider: string, instructions: string): Promise<void> {
const taskDir = path.join(this.workspaceRoot, 'tasks', taskId);
const inputDir = path.join(taskDir, 'input');
const outputDir = path.join(taskDir, 'output');
await mkdir(inputDir, { recursive: true });
await mkdir(outputDir, { recursive: true });
await writeFile(path.join(inputDir, 'TASK.md'), instructions);
const command = this.buildProviderCommand(provider, taskDir);
return new Promise((resolve, reject) => {
exec(command, { cwd: taskDir }, (error, stdout, stderr) => {
if (error) reject(error);
writeFile(path.join(outputDir, 'result.md'), stdout);
resolve();
});
});
}
private buildProviderCommand(provider: string, dir: string): string {
switch (provider) {
case 'claude': return `claude --print --input ${path.join(dir, 'input/TASK.md')}`;
case 'copilot': return `gh copilot suggest --file ${path.join(dir, 'input/TASK.md')}`;
case 'gemini': return `gemini run --prompt-file ${path.join(dir, 'input/TASK.md')}`;
default: throw new Error(`Unsupported provider: ${provider}`);
}
}
}
Rationale: Each task receives a UUID-named directory with dedicated input/output partitions. Agents execute in isolated subprocesses with explicit working directories. This guarantees zero context leakage, enables true parallel execution, and creates a persistent audit trail on disk. The control plane never accesses file contents; it only tracks task lifecycle events.
6. Provider Action Gating & Startup Orchestration
Not all commands apply to every agent. The relay must enforce strict action boundaries and maintain process stability during initialization.
private dispatchCommand(cmd: InboundCommand): void {
const allowedActions = this.getProviderActions(cmd.provider);
if (!allowedActions.has(cmd.action)) {
this.logger.warn(`Action ${cmd.action} blocked for provider ${cmd.provider}`);
return;
}
switch (cmd.action) {
case 'execute_task':
this.taskRouter.routeTask(cmd.taskId, cmd.provider, cmd.payload.instructions);
break;
case 'update_config':
this.applyConfigUpdate(cmd.payload);
break;
default:
this.logger.warn(`Unknown action: ${cmd.action}`);
}
}
private getProviderActions(provider: string): Set<string> {
const map: Record<string, Set<string>> = {
claude: new Set(['execute_task', 'update_config', 'stream_chunk']),
copilot: new Set(['execute_task', 'update_config']),
gemini: new Set(['execute_task', 'update_config', 'reset_context'])
};
return map[provider] || new Set();
}
public async initialize(): Promise<void> {
this.start();
// Keep event loop alive without blocking graceful shutdown
const keepalive = setInterval(() => {}, 1 << 30);
keepalive.unref();
process.on('SIGINT', () => this.gracefulShutdown());
process.on('SIGTERM', () => this.gracefulShutdown());
}
private gracefulShutdown(): void {
this.logger.info('Initiating graceful drain...');
this.stopHeartbeat();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.socket?.close(1000, 'Shutdown');
process.exit(0);
}
Rationale: Explicit action allowlists prevent misconfigured cloud deployments from sending incompatible commands to local agents. The unref() pattern maintains the Node.js event loop during async initialization without preventing clean process termination. Signal handlers ensure in-flight tasks complete and buffers drain before exit.
Pitfall Guide
1. Treating Auth Failures Like Network Errors
Explanation: Retrying authentication failures with the same backoff as transient network drops causes rapid endpoint hammering, triggering rate limits and cloud-side IP blocks. Fix: Detect HTTP 401/403 or WebSocket close codes 1008/1002. Apply a fixed 30-second cooldown before retrying. Log credential rotation requirements separately.
2. Unbounded In-Memory Queues
Explanation: During prolonged network partitions, buffering every outbound event without a cap causes heap growth, eventually triggering OOM kills and corrupting the relay state. Fix: Implement a strict queue limit (e.g., 100β500 messages). Use ring-buffer semantics to drop the oldest events when full. Monitor queue depth via metrics and alert if saturation exceeds 80%.
3. Shared Workspace Directories
Explanation: Routing multiple agent tasks to a single directory causes file overwrites, prompt context leakage, and race conditions during parallel execution.
Fix: Generate a UUID for each task. Create isolated input/ and output/ subdirectories. Enforce working directory constraints in subprocess execution. Never allow agents to traverse outside their task boundary.
4. Blocking the Event Loop on Startup
Explanation: Async WebSocket initialization returns immediately. Without an explicit keepalive, Node.js exits before the connection establishes, causing silent startup failures.
Fix: Use setInterval(() => {}, 1 << 30).unref() or setImmediate() to maintain the event loop. Ensure the keepalive does not prevent SIGINT/SIGTERM from terminating the process.
5. Ignoring Provider-Specific Action Boundaries
Explanation: Cloud control planes may send provider-specific commands (e.g., context reset, streaming chunks) to incompatible agents, causing CLI crashes or undefined behavior. Fix: Maintain an explicit allowlist per provider. Validate incoming commands against the active provider before dispatch. Reject and log mismatches immediately.
6. Missing Graceful Shutdown Handlers
Explanation: Abrupt process termination drops buffered messages, leaves subprocesses orphaned, and corrupts task directories.
Fix: Register SIGINT and SIGTERM handlers. Stop heartbeats, clear reconnect timers, close the WebSocket with a standard code, and allow in-flight subprocesses to complete before exiting.
7. Hardcoding Heartbeat Intervals
Explanation: Fixed intervals may fall outside the idle timeout window of specific cloud providers or load balancers, causing silent disconnections. Fix: Make heartbeat intervals configurable. Default to 20 seconds, but allow overrides based on target infrastructure. Implement adaptive backoff if the cloud side sends explicit timeout warnings.
Production Bundle
Action Checklist
- Verify outbound-only architecture: Ensure no local ports are bound or exposed to public interfaces.
- Implement distinct retry logic: Separate auth failure cooldowns from transient network backoff.
- Cap message buffers: Enforce strict queue limits with ring-buffer overflow behavior.
- Isolate task directories: Generate UUID-based workspaces with dedicated input/output partitions.
- Enforce action allowlists: Validate inbound commands against provider capabilities before execution.
- Register signal handlers: Implement graceful shutdown with buffer drain and subprocess cleanup.
- Monitor queue depth: Expose metrics for buffer saturation, reconnect frequency, and heartbeat latency.
- Audit filesystem access: Restrict agent subprocesses to their task directory using chroot or working directory constraints.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Single developer, local testing | Reverse proxy tunnel | Fast setup, acceptable for ephemeral environments | Low infrastructure cost, high security risk |
| Multi-agent production pipeline | Outbound WebSocket bridge | Zero inbound ports, strict isolation, cloud-agnostic | Moderate dev effort, minimal cloud egress cost |
| High-compliance environment (HIPAA/SOC2) | Outbound bridge + filesystem encryption | Data never leaves host, audit trails on disk, zero third-party relay | Higher storage cost, strict access controls required |
| Cross-region agent orchestration | Outbound bridge + regional cloud endpoints | Reduces latency, maintains outbound-only trust model | Increased cloud deployment complexity, moderate cost |
Configuration Template
// relay.config.ts
export const relayConfig = {
endpoint: 'wss://control-plane.example.com/v1/relay',
apiKey: process.env.RELAY_API_KEY || '',
deviceId: process.env.DEVICE_ID || `local-ai-${require('os').hostname()}`,
heartbeatIntervalMs: 20_000,
maxQueueSize: 150,
workspaceRoot: '/var/lib/ai-relay/tasks',
logLevel: 'info',
providers: {
claude: { binary: 'claude', args: ['--print'] },
copilot: { binary: 'gh', args: ['copilot', 'suggest'] },
gemini: { binary: 'gemini', args: ['run'] }
},
security: {
enforceTaskIsolation: true,
restrictSubprocessCwd: true,
auditFileAccess: true
}
};
Quick Start Guide
- Initialize the relay process: Install dependencies, set
RELAY_API_KEYandDEVICE_IDenvironment variables, and run the initialization routine. The process will establish an outbound WebSocket connection and begin listening for commands. - Verify channel liveness: Check logs for
Relay channel establishedand monitor heartbeat intervals. Confirm the control plane registers the device as online. - Submit a test task: Trigger a
execute_taskcommand from the cloud dashboard. Verify the relay creates a UUID-named directory, writes the instruction file, spawns the correct agent subprocess, and streams output events back to the control plane. - Validate isolation and cleanup: Confirm that subsequent tasks generate separate directories, that agent outputs are written to the correct
output/partition, and that graceful shutdown drains buffers without data loss.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
