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
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
npm install @helix-agents/agent-server expressConfigure AgentServer
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
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
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
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
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:
| Method | Path | Description |
|---|---|---|
| POST | /start | Start a new agent execution |
| POST | /resume | Resume an interrupted agent |
| GET | /sse | SSE stream of agent events |
| GET | /status | Get execution status |
| POST | /interrupt | Soft stop (resumable) |
| POST | /abort | Hard stop |
POST /start
Start a new agent execution.
Request:
{
"sessionId": "session-abc-123",
"agentType": "researcher",
"message": "Research TypeScript benefits",
"state": {},
"metadata": {}
}Response (200):
{
"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:
{
"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 streamfromSequence(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}
:heartbeatThe server sends a :heartbeat comment every 15 seconds to keep the connection alive.
GET /status
Get the current execution status.
Response:
{
"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:
{
"sessionId": "session-abc-123",
"reason": "User requested pause"
}POST /abort
Hard stop — the agent cannot be resumed.
Request:
{
"sessionId": "session-abc-123",
"reason": "Timeout exceeded"
}Error Responses
All endpoints return errors in this format:
{
"error": "Agent type not found: unknown-agent",
"code": "NOT_FOUND"
}| Code | HTTP Status | Description |
|---|---|---|
NOT_FOUND | 404 | Agent type or session not found |
ALREADY_RUNNING | 409 | Session is already executing |
ALREADY_COMPLETED | 409 | Session has already completed or failed |
INVALID_REQUEST | 400 | Missing required fields |
INTERNAL_ERROR | 500 | Server error |
How It Works
Step-by-step execution flow when a parent agent calls a remote sub-agent:
- Parent LLM calls tool — e.g.,
subagent__researcher({ query: "TypeScript benefits" }) - Runtime detects remote tool — The tool is marked with
_isRemoteSubAgent: true - Transport calls POST /start — Sends the message and agent type to the remote server
- Transport connects GET /sse — Subscribes to the event stream for the session
- Remote agent executes — The
AgentServerruns the agent usingJSAgentExecutor - Chunks stream back — SSE events flow through the transport back to the parent
- End event arrives — The transport returns the agent's output as the tool result
- Parent continues — The parent LLM receives the result and proceeds
Cross-Runtime Behavior
Remote sub-agent tools are first-class constructs across all runtimes:
| Runtime | Behavior |
|---|---|
| JS | Intercepts remote tool calls with enhanced handling: stream proxying to parent, SubSessionRef tracking, timeout management via AbortSignal, reconnection on resume |
| Temporal | Dedicated executeRemoteSubAgentCall activity with deterministic session IDs, crash recovery via transport.getStatus(), heartbeat-based reconnection, stream proxying, and interrupt propagation |
| Cloudflare | Dedicated 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:
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
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:
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
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:
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:
// 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:
app.use('/agents', authMiddleware);
app.use('/agents', createExpressAdapter(createHttpAdapter(agentServer)));Timeouts
Set appropriate timeoutMs on each remote sub-agent tool:
createRemoteSubAgentTool('researcher', {
// ...
timeoutMs: 120_000, // 2 minutes for complex research
});
createRemoteSubAgentTool('summarizer', {
// ...
timeoutMs: 30_000, // 30 seconds for summarization
});Local vs Remote Sub-Agents
| Feature | Local (createSubAgentTool) | Remote (createRemoteSubAgentTool) |
|---|---|---|
| Runs in | Same process | Separate HTTP service |
| Communication | Direct function calls | HTTP + SSE |
| State store | Shared instance | Independent instance |
| Hooks | beforeSubAgent/afterSubAgent fire with agentConfig | beforeSubAgent/afterSubAgent fire with agentConfig: undefined |
| Latency | Minimal | Network overhead |
| Scaling | Same process | Independent scaling |
| Runtime | Must match parent | Can differ from parent |
| Setup | Just the agent definition | Agent server + transport |
Patterns
Microservice Specialization
Run different agents with different models or configurations:
// 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:
// Temporal worker runs the orchestrator with crash recovery
// Express service runs specialist agents (researcher, summarizer)
// HttpRemoteAgentTransport bridges the twoSee 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:
const transport = new HttpRemoteAgentTransport({
url: 'https://agents.example.com',
headers: async () => ({
Authorization: `Bearer ${await getTenantToken()}`,
'X-Tenant-Id': getCurrentTenantId(),
}),
});Limitations
- No
agentConfigin hooks —beforeSubAgentandafterSubAgenthooks fire for remote sub-agents, butpayload.agentConfigisundefined(usepayload.call.agentTypeinstead) - 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 pluggable —
AgentServeraccepts anyAgentExecutorimplementation (JSAgentExecutoris the standard choice) - Interrupt/abort are single-instance —
AgentServertracks 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 viaPOST /resumesince state is persisted). For cross-instance lifecycle control, use a durable runtime (Temporal or Cloudflare) behind the agent server
Next Steps
- Sub-Agents Guide — Local sub-agent orchestration
- API Reference — Full
AgentServerand transport API - Remote Agents Example — Working Temporal + HTTP example
- Runtimes — How different runtimes handle sub-agents
- Streaming — Handle agent stream events