Skip to content

Remote Agents

Remote agents let a parent agent delegate tasks to agents running on a separate HTTP service. This enables microservice architectures, cross-runtime orchestration, and security boundaries between agents.

When to Use Remote Agents

Use remote agents when you need:

  • Different services — Specialist agents deployed as independent services
  • Different runtimes — A Temporal orchestrator delegating to agents on an Express server
  • Security boundaries — Isolate agents that handle sensitive data or credentials
  • Independent scaling — Scale specialist agents separately from the orchestrator
  • Different models — Run each agent with the most appropriate LLM

For agents that run in the same process, use local sub-agents instead.

Architecture Overview

mermaid
sequenceDiagram
    participant Parent as Parent Agent
    participant Transport as HttpRemoteAgentTransport
    participant Server as AgentServer (Express)
    participant Child as Child Agent

    Parent->>Transport: Tool call (subagent__researcher)
    Transport->>Server: POST /start
    Server->>Child: Execute agent
    Server-->>Transport: { sessionId, streamId }
    Transport->>Server: GET /sse?sessionId=xxx
    loop Streaming
        Child-->>Server: Stream chunks
        Server-->>Transport: SSE events
        Transport-->>Parent: Proxy chunks to parent stream
    end
    Server-->>Transport: SSE end event
    Transport-->>Parent: Tool result (agent output)

Setting Up the Server

The @helix-agents/agent-server package provides AgentServer — an HTTP server for hosting agents remotely.

Install

bash
npm install @helix-agents/agent-server express

Configure AgentServer

typescript
import { AgentServer, createHttpAdapter, createExpressAdapter } from '@helix-agents/agent-server';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { ResearcherAgent } from './agents/researcher.js';
import { SummarizerAgent } from './agents/summarizer.js';

const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());

const agentServer = new AgentServer({
  // Registry of agents this server can run
  agents: {
    researcher: ResearcherAgent,
    summarizer: SummarizerAgent,
  },
  stateStore,
  streamManager,
  executor,
});

Wire Up Express

typescript
import express from 'express';

const app = express();
app.use(express.json());

// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', agents: ['researcher', 'summarizer'] });
});

// Mount all 6 agent endpoints at root
app.use('/', createExpressAdapter(createHttpAdapter(agentServer)));

app.listen(4000, () => {
  console.log('Remote Agent Service listening on http://localhost:4000');
});

This exposes all 6 endpoints: /start, /resume, /sse, /status, /interrupt, /abort.

TIP

For non-Express frameworks, use createHttpAdapter(agentServer) directly — it returns a generic AgentHttpHandler function you can adapt to any HTTP framework. See the API reference for details.

Creating Remote Sub-Agent Tools

On the client side, create tools that delegate to the remote server.

Configure the Transport

typescript
import { HttpRemoteAgentTransport } from '@helix-agents/core';

const transport = new HttpRemoteAgentTransport({
  url: 'http://localhost:4000',
  // Optional: static or dynamic headers
  headers: { Authorization: 'Bearer my-api-key' },
  // Optional: retry config (defaults: 3 retries, 1s base delay)
  maxRetries: 3,
  retryBaseDelayMs: 1000,
});

Create Remote Sub-Agent Tools

typescript
import { defineAgent, createRemoteSubAgentTool } from '@helix-agents/core';
import { z } from 'zod';

const researcherTool = createRemoteSubAgentTool('researcher', {
  description: 'Delegate research to a remote specialist agent',
  inputSchema: z.object({
    query: z.string().describe('The research query'),
  }),
  outputSchema: ResearcherOutputSchema,
  transport,
  remoteAgentType: 'researcher', // Must match key in server's agents registry
  timeoutMs: 120_000, // 2 minute timeout
});

const summarizerTool = createRemoteSubAgentTool('summarizer', {
  description: 'Delegate summarization to a remote specialist agent',
  inputSchema: z.object({
    text: z.string().describe('The text to summarize'),
  }),
  outputSchema: SummarizerOutputSchema,
  transport,
  remoteAgentType: 'summarizer',
  timeoutMs: 60_000,
});

Use in a Parent Agent

typescript
const OrchestratorAgent = defineAgent({
  name: 'orchestrator',
  description: 'Orchestrates research via remote specialist agents',
  outputSchema: OrchestratorOutputSchema,
  tools: [researcherTool, summarizerTool],
  systemPrompt: `You are a research orchestrator.
1. Use the researcher to gather information
2. Use the summarizer to distill findings
3. Call __finish__ with your final output`,
  llmConfig: { model: openai('gpt-4o-mini') },
  maxSteps: 10,
});

The parent LLM sees subagent__researcher and subagent__summarizer as tools — it doesn't know they're remote.

HTTP Protocol Reference

The agent server exposes 6 endpoints:

MethodPathDescription
POST/startStart a new agent execution
POST/resumeResume an interrupted agent
GET/sseSSE stream of agent events
GET/statusGet execution status
POST/interruptSoft stop (resumable)
POST/abortHard stop

POST /start

Start a new agent execution.

Request:

json
{
  "sessionId": "session-abc-123",
  "agentType": "researcher",
  "message": "Research TypeScript benefits",
  "state": {},
  "metadata": {}
}

Response (200):

json
{
  "sessionId": "session-abc-123",
  "streamId": "stream-xyz",
  "runId": "run-456"
}

Start is idempotent — calling it again with the same sessionId returns the existing session if still running.

POST /resume

Resume an interrupted or paused agent, optionally with a new message.

Request:

json
{
  "sessionId": "session-abc-123",
  "message": "Continue with more detail"
}

GET /sse

Subscribe to agent events via Server-Sent Events.

Query params:

  • sessionId (required) — Session to stream
  • fromSequence (optional) — Resume from this sequence number (skips already-seen events)

Event format:

id: 42
event: chunk
data: {"chunk":{...},"sequence":42}

event: end
data: {"output":{...},"state":{...}}

event: error
data: {"error":"something failed","recoverable":false}

:heartbeat

The server sends a :heartbeat comment every 15 seconds to keep the connection alive.

GET /status

Get the current execution status.

Response:

json
{
  "sessionId": "session-abc-123",
  "runId": "run-456",
  "status": "running",
  "stepCount": 3,
  "isExecuting": true,
  "streamId": "stream-xyz",
  "latestSequence": 42
}

Status values: running, completed, failed, interrupted, paused.

POST /interrupt

Soft stop — the agent can be resumed later.

Request:

json
{
  "sessionId": "session-abc-123",
  "reason": "User requested pause"
}

POST /abort

Hard stop — the agent cannot be resumed.

Request:

json
{
  "sessionId": "session-abc-123",
  "reason": "Timeout exceeded"
}

Error Responses

All endpoints return errors in this format:

json
{
  "error": "Agent type not found: unknown-agent",
  "code": "NOT_FOUND"
}
CodeHTTP StatusDescription
NOT_FOUND404Agent type or session not found
ALREADY_RUNNING409Session is already executing
ALREADY_COMPLETED409Session has already completed or failed
INVALID_REQUEST400Missing required fields
INTERNAL_ERROR500Server error

How It Works

Step-by-step execution flow when a parent agent calls a remote sub-agent:

  1. Parent LLM calls tool — e.g., subagent__researcher({ query: "TypeScript benefits" })
  2. Runtime detects remote tool — The tool is marked with _isRemoteSubAgent: true
  3. Transport calls POST /start — Sends the message and agent type to the remote server
  4. Transport connects GET /sse — Subscribes to the event stream for the session
  5. Remote agent executes — The AgentServer runs the agent using JSAgentExecutor
  6. Chunks stream back — SSE events flow through the transport back to the parent
  7. End event arrives — The transport returns the agent's output as the tool result
  8. Parent continues — The parent LLM receives the result and proceeds

Cross-Runtime Behavior

Remote sub-agent tools are first-class constructs across all runtimes:

RuntimeBehavior
JSIntercepts remote tool calls with enhanced handling: stream proxying to parent, SubSessionRef tracking, timeout management via AbortSignal, reconnection on resume
TemporalDedicated executeRemoteSubAgentCall activity with deterministic session IDs, crash recovery via transport.getStatus(), heartbeat-based reconnection, stream proxying, and interrupt propagation
CloudflareDedicated executeRemoteSubAgentCall step with deterministic session IDs, crash recovery, stream proxying, interrupt propagation via abort-check interval, and timeout enforcement

TIP

All three runtimes provide stream proxying, SubSessionRef tracking with remote metadata, and sub-agent lifecycle hooks (beforeSubAgent/afterSubAgent). Remote sub-agents are routed through a dedicated execution path separate from regular tool calls, with subagent_start/subagent_end stream events.

Cloudflare DO and Local Sub-Agents

In the Cloudflare DO runtime, createSubAgentTool() (local sub-agents) are transparently converted to remote sub-agent calls using DOStubTransport — a RemoteAgentTransport implementation that routes to sibling DO instances instead of an external HTTP service. This means HttpRemoteAgentTransport is only needed when crossing service boundaries; within a single DO namespace, subAgentNamespace handles everything. See Sub-Agents in the DO Runtime.

Streaming Integration

Remote agent events appear in the parent stream using the same subagent_start/subagent_end pattern as local sub-agents:

typescript
for await (const chunk of parentStream) {
  switch (chunk.type) {
    case 'subagent_start':
      console.log(`Remote agent started: ${chunk.subAgentType}`);
      break;

    case 'text_delta':
      // Could be from parent or remote agent
      console.log(`[${chunk.agentType}]`, chunk.delta);
      break;

    case 'tool_start':
      // Tools used by the remote agent
      console.log(`[${chunk.agentType}] Tool: ${chunk.toolName}`);
      break;

    case 'subagent_end':
      console.log(`Remote agent finished: ${chunk.subAgentType}`);
      break;
  }
}

This means frontends don't need to distinguish between local and remote sub-agents — the stream protocol is identical.

Transport Configuration

Static Headers

typescript
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  headers: {
    Authorization: 'Bearer my-api-key',
    'X-Tenant-Id': 'tenant-123',
  },
});

Dynamic Headers

For tokens that need to be refreshed:

typescript
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  headers: async () => ({
    Authorization: `Bearer ${await getAccessToken()}`,
  }),
});

Retry Policy

The transport retries failed requests with exponential backoff:

  • 5xx errors — Retried with backoff
  • 4xx errors — Not retried (client error)
  • Network errors — Retried with backoff
typescript
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  maxRetries: 5, // Default: 3
  retryBaseDelayMs: 2000, // Default: 1000ms
});

Delay formula: baseDelay * 2^attempt (1s, 2s, 4s, ...).

Production Considerations

State Storage

Use RedisStateStore and RedisStreamManager for production. In-memory stores lose state on restart:

typescript
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';
import { JSAgentExecutor } from '@helix-agents/runtime-js';

const stateStore = new RedisStateStore({ url: process.env.REDIS_URL });
const streamManager = new RedisStreamManager({ url: process.env.REDIS_URL });
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());

const agentServer = new AgentServer({
  agents: { researcher: ResearcherAgent },
  stateStore,
  streamManager,
  executor,
});

Authentication

Protect your agent server with authentication headers:

typescript
// Static API key
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  headers: { Authorization: 'Bearer sk-xxx' },
});

// Dynamic JWT tokens
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  headers: async () => ({
    Authorization: `Bearer ${await fetchJWT()}`,
  }),
});

On the server side, add authentication middleware before the agent endpoints:

typescript
app.use('/agents', authMiddleware);
app.use('/agents', createExpressAdapter(createHttpAdapter(agentServer)));

Timeouts

Set appropriate timeoutMs on each remote sub-agent tool:

typescript
createRemoteSubAgentTool('researcher', {
  // ...
  timeoutMs: 120_000, // 2 minutes for complex research
});

createRemoteSubAgentTool('summarizer', {
  // ...
  timeoutMs: 30_000, // 30 seconds for summarization
});

Local vs Remote Sub-Agents

FeatureLocal (createSubAgentTool)Remote (createRemoteSubAgentTool)
Runs inSame processSeparate HTTP service
CommunicationDirect function callsHTTP + SSE
State storeShared instanceIndependent instance
HooksbeforeSubAgent/afterSubAgent fire with agentConfigbeforeSubAgent/afterSubAgent fire with agentConfig: undefined
LatencyMinimalNetwork overhead
ScalingSame processIndependent scaling
RuntimeMust match parentCan differ from parent
SetupJust the agent definitionAgent server + transport

Patterns

Microservice Specialization

Run different agents with different models or configurations:

typescript
// Server A: Runs expensive reasoning agents
const serverA = new AgentServer({
  agents: { analyzer: AnalyzerAgent }, // Uses Claude Opus
  // ...
});

// Server B: Runs fast utility agents
const serverB = new AgentServer({
  agents: { formatter: FormatterAgent }, // Uses GPT-4o-mini
  // ...
});

// Orchestrator delegates to both
const transportA = new HttpRemoteAgentTransport({ url: 'http://server-a:4000' });
const transportB = new HttpRemoteAgentTransport({ url: 'http://server-b:4001' });

Cross-Runtime Orchestration

Use Temporal for durable orchestration while specialist agents run on a lightweight Express service:

typescript
// Temporal worker runs the orchestrator with crash recovery
// Express service runs specialist agents (researcher, summarizer)
// HttpRemoteAgentTransport bridges the two

See the Remote Agents (Temporal) example for a full working implementation.

Multi-Tenant Agent Hosting

Host multiple tenants on a single agent server with dynamic headers:

typescript
const transport = new HttpRemoteAgentTransport({
  url: 'https://agents.example.com',
  headers: async () => ({
    Authorization: `Bearer ${await getTenantToken()}`,
    'X-Tenant-Id': getCurrentTenantId(),
  }),
});

Limitations

  • No agentConfig in hooksbeforeSubAgent and afterSubAgent hooks fire for remote sub-agents, but payload.agentConfig is undefined (use payload.call.agentType instead)
  • No shared state — Remote agents have completely independent state (same as local sub-agents)
  • Network latency — HTTP overhead compared to in-process local sub-agents
  • Remote agent must have outputSchema — Required for structured tool results
  • Executor is pluggableAgentServer accepts any AgentExecutor implementation (JSAgentExecutor is the standard choice)
  • Interrupt/abort are single-instanceAgentServer tracks execution handles in memory. Interrupt and abort only work on the server instance that started the execution. After a restart, in-flight handles are lost (sessions can still be resumed via POST /resume since state is persisted). For cross-instance lifecycle control, use a durable runtime (Temporal or Cloudflare) behind the agent server

Next Steps

Released under the MIT License.