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: 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
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,
});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:
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 (
workspaceProviderKindsset): construction throws anAgentServerErrorwith 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 (
workspaceProviderKindsunset): construction succeeds, but alogger.warnfires when at least one registered agent declares workspaces. The runtime still catches missing providers at LLM tool-call time viaWorkspaceFailedError, 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
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
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
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": "{\"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):
{
"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, internal stream retry loop on drops, interrupt propagation, reconnection on resume |
| Temporal | Dedicated executeRemoteSubAgentCall activity with deterministic session IDs, crash recovery via transport.getStatus(), heartbeat-based reconnection, stream proxying, interrupt propagation, StreamDropError for activity retry |
| Cloudflare | Dedicated 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:
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, ...).
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):
- Checks
transport.getStatus()to see if the remote agent completed, failed, or is still running - If still running, reconnects with
fromSequenceto resume from the last received chunk - Retries up to
streamRetriestimes with exponential backoff (streamRetryBaseMs * 2^attempt)
Configure retry behavior per tool:
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 anendevent. ContainsremoteSessionIdandlastSequencefor 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.,getStatusreports a failed state). ContainsremoteSessionIdandremoteError.
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:
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