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: Emit subagent_end + tool_end on parent stream
    Transport-->>Parent: Tool result (agent output)

The runtime emits a tool_end chunk on the parent's stream after the remote sub-agent completes (immediately following subagent_end). This is critical for AI SDK consumers — without tool_end, the parent's subagent__<name> UI tool part stays stuck in state: 'input-available'. See Sub-agent chunk ordering for the exact event sequence and rationale.

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

Workspace Wiring

If any of the registered agents declare workspaces, the underlying executor MUST be configured with matching workspaceProviders. The AgentServer validates this at construction:

typescript
import { CloudflareFileStoreProvider } from '@helix-agents/runtime-cloudflare';
import { InMemoryWorkspaceProvider } from '@helix-agents/workspace-memory';

const executor = new JSAgentExecutor(stateStore, streamManager, llm, {
  workspaceProviders: new Map([
    ['in-memory', new InMemoryWorkspaceProvider()],
    ['cloudflare-filestore', new CloudflareFileStoreProvider({ /* ... */ })],
  ]),
});

const agentServer = new AgentServer({
  agents: { researcher: ResearcherAgent /* declares workspaces with kind 'cloudflare-filestore' */ },
  stateStore,
  streamManager,
  executor,
  // Strict-mode validation: declare which provider kinds are wired.
  // Mismatches between agent.workspaces and this list throw at construction.
  workspaceProviderKinds: ['in-memory', 'cloudflare-filestore'],
});

Two validation modes:

  • Strict (workspaceProviderKinds set): construction throws an AgentServerError with code 'WORKSPACE_WIRING' if any registered agent's declared workspace provider kinds aren't covered. The error names the agent and the missing kind(s).
  • Soft (workspaceProviderKinds unset): construction succeeds, but a logger.warn fires when at least one registered agent declares workspaces. The runtime still catches missing providers at LLM tool-call time via WorkspaceFailedError, but you'll see them mid-execution rather than at startup.

Prefer strict mode for production deployments where the wiring is known statically; soft mode is fine for testing / dynamic agent registration.

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
  streamRetries: 3, // Retry on stream drop (default: 3)
  streamRetryBaseMs: 100, // Base delay between retries (default: 100ms)
});

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": "{\"query\":\"Research TypeScript benefits\"}",
  "state": {"query": "Research TypeScript benefits"},
  "metadata": {}
}

The message field contains the JSON-serialized tool input from the parent agent's LLM. The remote agent server is responsible for parsing this JSON and constructing the appropriate user message for its agent. The state field carries the same input as a parsed object for initializing the agent's custom state.

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, internal stream retry loop on drops, interrupt propagation, reconnection on resume
TemporalDedicated executeRemoteSubAgentCall activity with deterministic session IDs, crash recovery via transport.getStatus(), heartbeat-based reconnection, stream proxying, interrupt propagation, StreamDropError for activity retry
CloudflareDedicated executeRemoteSubAgentCall step with deterministic session IDs, crash recovery, stream proxying, interrupt propagation via abort-check interval, timeout enforcement, StreamDropError for step retry

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, ...).

Stream Recovery

Remote sub-agent calls use SSE for streaming. Network interruptions can drop the SSE connection mid-execution. The framework provides defense-in-depth recovery that varies by runtime:

JS Runtime — Internal Retry Loop

The JS runtime retries stream drops internally without surfacing errors to the caller. When a stream drops (ends without an end event):

  1. Checks transport.getStatus() to see if the remote agent completed, failed, or is still running
  2. If still running, reconnects with fromSequence to resume from the last received chunk
  3. Retries up to streamRetries times with exponential backoff (streamRetryBaseMs * 2^attempt)

Configure retry behavior per tool:

typescript
createRemoteSubAgentTool('researcher', {
  // ...
  streamRetries: 5, // Default: 3, max: 50 (0 disables retries)
  streamRetryBaseMs: 200, // Default: 100ms
});

Temporal Runtime — Activity Retry

The Temporal runtime throws StreamDropError on stream drops, which triggers Temporal's built-in activity retry. The heartbeat carries lastSequence so the retried activity can resume from the correct position.

Cloudflare Runtime — Step Retry

The Cloudflare runtime throws StreamDropError on stream drops, which triggers Cloudflare Workflows step retry. The lastSequence is persisted to the SubSessionRef for crash recovery.

Error Types

Two error types support remote agent failure handling across all runtimes:

  • StreamDropError — Thrown by Temporal and Cloudflare runtimes when the SSE stream drops without an end event. Contains remoteSessionId and lastSequence for retry coordination. The JS runtime handles stream drops internally and does not throw this error.
  • RemoteAgentFailedError — Thrown across all runtimes when the remote agent has definitively failed (e.g., getStatus reports a failed state). Contains remoteSessionId and remoteError.

Both errors support instanceof checks for reliable error discrimination in catch blocks.

Interrupt Propagation

When a parent agent is interrupted or aborted, all runtimes propagate the interrupt to running remote agents via transport.interrupt(). This is best-effort — if the interrupt call fails (e.g., network error), the failure is logged but does not prevent the parent from completing its interrupt flow.

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.