Cross-Service Remote Agents
The Remote Agents guide covers the protocol: a parent agent delegates to a child that runs somewhere else, over createRemoteSubAgentTool and a RemoteAgentTransport. This guide covers what changes when that "somewhere else" is a real service boundary — a separate deploy, a separate Cloudflare Worker, a separate team's API:
- exposing a producer on Node (
AgentServer) or Cloudflare Durable Objects (createRemoteAgentWorkerHandler), - transports that cross the boundary (HTTP, service bindings, injected
fetch), - getting state (
getSnapshot) and usage (getUsage) across the boundary, - fidelity mounts — exposing the same agents at two different levels of detail,
- and the consumer reconstruction contract: how an outside observer rebuilds a live agent tree's state without corrupting it.
┌─────────────────────────────────┐ ┌──────────────────────────────────────┐
│ CONSUMER service │ │ PRODUCER service │
│ │ │ │
│ parent agent │ │ Node: AgentServer │
│ └─ createRemoteSubAgentTool ──┼─ ─ ─ ─▶│ (any AgentExecutor: │
│ │ │ │ JS / Temporal / DBOS / CF) │
│ └─ RemoteAgentTransport │ │ │
│ • HttpRemoteAgent… │ │ Cloudflare DO: │
│ • serviceBinding… │ │ createRemoteAgentWorkerHandler│
│ │ │ └─ DurableObjectAgentBase │
│ (runtime-js / temporal / dbos │ │ │
│ / cloudflare / BYO-loop via │ │ routes: start resume sse status │
│ executeRemoteSubAgentDispatch)│ │ snapshot usage interrupt abort│
└─────────────────────────────────┘ └──────────────────────────────────────┘Two working examples exercise everything on this page end to end:
- Remote Agents (Cloudflare DO → DO) — two workers over a service binding, raw + projected mounts, nested children, the child-snapshot proxy, a frontend that reconstructs the whole tree.
- Remote Agents (Temporal) — a Temporal consumer against a Node
AgentServerproducer, with snapshot/usage/re-validation.
Exposing a producer
Node — AgentServer
AgentServer hosts any AgentExecutor: JSAgentExecutor, TemporalAgentExecutor, DBOSAgentExecutor, and CloudflareAgentExecutor all implement that interface, so the producer's runtime is a deployment choice, not a protocol one.
Cross-service work added these to the Node server:
| Addition | Effect |
|---|---|
GET /snapshot?sessionId= | Checkpoint-pinned customState baseline (see Snapshots). |
GET /usage?sessionId= | Recursive usage rollup. |
AgentServerConfig.usageStore | Required for /usage. Without it, /usage responds 404 FEATURE_UNAVAILABLE and consumers degrade gracefully (skip the usage fold, log a warning). Wiring it does not unlock end.usage on this producer — see the note below. |
projection / usageProjection | Per-mount fidelity (see Fidelity mounts). |
authenticate operation kinds | Now includes 'snapshot' and 'usage'. |
Honest isExecuting on /status | Derived from this replica's live handles, not a status === 'active' echo. |
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,
InMemoryUsageStore,
} from '@helix-agents/store-memory';
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const usageStore = new InMemoryUsageStore();
const agentServer = new AgentServer({
agents: { researcher: ResearcherAgent, summarizer: SummarizerAgent },
stateStore,
streamManager,
// Without this, GET /usage 404s (FEATURE_UNAVAILABLE) and the consumer's
// rollup would be missing the remote child's tokens entirely. It does NOT
// put `usage` on the SSE `end` frame for this (JS) executor — the JS run
// loop's `end` frame never carries `usage` today (see the note below);
// the store is still required so the `getUsage()` fallback round-trip
// works.
usageStore,
executor: new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter()),
// dev/test only; production deployments must wire `authenticate`.
allowUnauthenticated: true,
});
app.use('/', createExpressAdapter(createHttpAdapter(agentServer)));Wiring usageStore on the server is sufficient for the JS runtime: AgentServer forwards config.usageStore into the executor as ExecuteOptions.usageStore on every /start and /resume. (Temporal, DBOS, and Cloudflare Workflows executors bind their usage store at construction time instead.)
Multi-replica caveat on isExecuting
isExecuting is this replica's truth. A session executing on another replica of the same service also reports isExecuting: false here. Consumers only escalate after repeated attach failures, not on a single false — but if you run replicas, put sticky routing or a shared liveness check in front of the producer. See Producer liveness & zombies.
Cloudflare Durable Objects — createRemoteAgentWorkerHandler
DO agents already speak the protocol internally (/subagent/:agentType/* on DurableObjectAgentBase). createRemoteAgentWorkerHandler (from @helix-agents/runtime-cloudflare/do, the Durable Object runtime's entry; the main entry exports it too) lifts that onto a Worker fetch handler so a different worker — over a service binding or the public internet — can consume the DO service as a remote-agent producer.
import { consoleLogger } from '@helix-agents/core';
import { createRemoteAgentWorkerHandler } from '@helix-agents/runtime-cloudflare/do';
const mount = createRemoteAgentWorkerHandler<Env>({
// The DO namespace hosting your agents (the same binding the DOs use as
// `subAgentNamespace`).
namespace: (env) => env.AGENTS,
// agentType allowlist. Default: no allowlist — every agentType is forwarded to
// the DO, which validates it against the session's stored agent_type.
agents: ['planner', 'thread-researcher'],
// Route prefix. Default: '/agents'.
basePath: '/internal/agents',
// Gates ALL EIGHT routes. Return `true` to proceed, `false` for a 401
// envelope, or a `Response` to short-circuit with it.
auth: (request, env) => request.headers.get('Authorization') === `Bearer ${env.API_KEY}`,
// Set `true` to acknowledge + silence the no-auth warning instead.
allowUnauthenticated: false,
// Optional. A pure state mapper; its PRESENCE makes this a projected mount.
// projection: externalStateProjection,
// Optional. Maps the rollup served on /usage and end.usage.
// usageProjection: externalUsageProjection,
// The no-auth warning goes through this. Default: noopLogger (silent).
logger: consoleLogger,
});
export default {
fetch: (request: Request, env: Env) => mount(request, env),
};Routes. {basePath}/:agentType/(start|resume|sse|status|snapshot|usage|interrupt|abort) — eight actions. sessionId is read from the body for POSTs (start/resume/interrupt/abort) and from the query string for GETs (sse/status/snapshot/usage). The DO instance is resolved with namespace.idFromName(sessionId).
Headers are forwarded verbatim to the DO, so products can map bearer tokens to tenants producer-side.
Allowlist descendants
agents must include descendant agentTypes if consumers will fetch nested baselines. A consumer calling getSnapshot(grandchildSessionId) addresses the route {basePath}/thread-researcher/snapshot — the handler's own agents allowlist rejects any agentType not listed with a 404 NOT_FOUND (a separate check from the DO's own SQL validation of :agentType against the session's stored agent_type), so agents: ['planner'] alone would 404 the grandchild. The example's internal mount allowlists both: agents: ['planner', 'thread-researcher'].
The DO checks the agent type on every route (CP-183). Before any /subagent/:agentType/* route runs, the DO compares :agentType with the session's stored agent type. An entry (start / resume) for another type's session answers the typed 409 state_agent_type_mismatch. Every other route (sse, status, snapshot, usage, interrupt, abort) answers 404 NOT_FOUND for another type's session, never revealing that it exists. So a caller the mount admitted for one agent type can never read, stop or answer another type's session by naming its id.
Unknown session → 404 with a RemoteAgentErrorResponse envelope ({code: 'NOT_FOUND', error}), never null.
The envelope's text (SDK-5). An error envelope is { error, detail?, code, errorCode?, cause?, … }. error is a user-safe sentence (core userMessageFor of the typed errorCode); detail is the developer text (the failure's own message, scrubbed). A consumer that turns an envelope back into an error (core remoteProtocolError: HttpRemoteAgentTransport, serviceBindingTransport, DOStubTransport; the CF DO's companion calls) takes its message from detail ?? error, so the parent's tool result, which its model reads, carries the child's real failure. Producer and consumer services deploy together: a child service on this version behind a parent on an older one hands the parent's model the user sentence (error) instead of the failure.
Entry failures are typed. The DO's start / resume routes put the typed code in the envelope's errorCode (and a rejected run start's reason in cause): an unknown agent type is 404 NOT_FOUND with errorCode: 'framework_agent_not_found', a busy session 409 ALREADY_RUNNING, a refused resume 409 ALREADY_COMPLETED (completed session) or 409 INVALID_REQUEST with errorCode: 'state_not_resumable', and a transient failure 503 INTERNAL_ERROR. The start body's userId, tags and metadata become the child session's identity, and the forwarded request headers reach the DO's beforeEntry hook. See the DO drive pipeline upgrade guide. This is the reason the bridge handlers are new protocol-shaped handlers rather than the DO's native /snapshot and /usage routes: the native ones answer from in-memory execution state, can serve 200 null on a cold DO, and are not checkpoint-pinned.
Non-goals on this handler (stated plainly, so you don't go looking):
- No workspace wiring validation. DO producers wire workspaces in DO config; misconfiguration surfaces at runtime as
WorkspaceFailedError, not at construction. - No
/workspaceintrospection bridging. If you need workspace introspection across the boundary, build it as a product route.
Cloudflare Workflows
Supported, with caveats. A feasibility spike (recorded in full at docs/dev/remote-agents-cfw-producer-spike.md) verified that a plain Worker can host AgentServer + createHttpAdapter over a CloudflareAgentExecutor and serve the whole protocol: @helix-agents/agent-server has zero Node-only imports, CloudflareAgentExecutor implements AgentExecutor, and /status, /snapshot, /usage were verified live under workerd (they never touch the executor at all). The four recorded caveats:
- Interrupt latency, not correctness.
/interruptrecords a durable stop and answers at once (202stoppingwhile the run executes); the Workflow observes it at its next step boundary, so a slow in-flight step (a long LLM call) delays the end of the run, never the answer. - SSE connection cap applies (see Connection limits).
- A live run needs a real
AGENT_WORKFLOWbinding. The spike verified host-and-read; a live Workflow run over this topology is not covered by the repo's Miniflare setup. - Concurrent
/startcalls for one session.CloudflareAgentExecutor.execute()creates a new session's row write-once before it starts its Workflow instance, and a concurrent creator is not a conflict there: each call starts its own instance, and the instance's run-start commit decides the single winner. The losing call'sexecute()throwsRunStartConflictError(itsnameisAgentAlreadyRunningError, soinstanceof AgentAlreadyRunningErrormatches). It is a typed, attach-safe failure — treat it exactly like the protocol'sALREADY_RUNNINGrule below (attach, don't fail).
Service bindings & transports
serviceBindingTransport — zero-hop DO → DO
Cloudflare service bindings give you a worker-to-worker call with no network hop and no public exposure. serviceBindingTransport (from @helix-agents/runtime-cloudflare/do or the main entry) is a thin wrapper: it returns an HttpRemoteAgentTransport whose injected fetch is the binding's, pointed at a synthetic internal base URL (service bindings ignore the authority; only the path reaches the peer).
import { serviceBindingTransport } from '@helix-agents/runtime-cloudflare/do';
import { createRemoteSubAgentTool } from '@helix-agents/core';
const transport = serviceBindingTransport({
binding: env.PRODUCER, // the service binding
basePath: '/internal/agents', // must match the producer mount's basePath
agentType: 'planner', // REQUIRED against a worker-handler mount — see below
// headers: { 'x-tenant': tenantId }, // static object or async factory
});
const researchTool = createRemoteSubAgentTool('research', {
description: 'Delegate a research report to the remote planner service.',
inputSchema: z.object({ topic: z.string() }),
outputSchema: researchOutputSchema(),
transport,
remoteAgentType: 'planner',
timeoutMs: 120_000,
streamRetries: 3,
maxFoldStateBytes: 262_144, // default; see the fold section
resumeDeadProducer: false, // default; see producer liveness
});agentType is required against a createRemoteAgentWorkerHandler mount
ServiceBindingTransportConfig.agentType is optional in the type because a Node AgentServer behind a binding takes the agentType in the /start body. But a worker-handler mount's routes are {basePath}/:agentType/<action> — omit agentType and every request lands on {basePath}/<action>, which has no :agentType segment, misses the route regex, and 404s. Set it.
HttpTransportConfig.fetch — inject any fetch
HttpRemoteAgentTransport takes { url, headers?, maxRetries?, retryBaseDelayMs?, fetch? } (note: url, not baseUrl; maxRetries covers the reads and stop requests only, never /start or /resume). The fetch option is used for every request the transport issues — POSTs, /status, /snapshot, /usage, and the SSE stream — which is what makes Cloudflare service bindings, Miniflare's dispatchFetch in tests, and Node-side proxies all work through the same class. It defaults to globalThis.fetch, resolved lazily.
Entries sent once and the ALREADY_RUNNING → attach rule
Both transports send POST /start and /resume exactly once (CP-95): a re-sent entry whose first response was lost could run the child twice. A thrown request, or a 5xx that is not the producer's typed retryable answer (an untyped load balancer 503 included), rejects with the non-retryable RemoteEntryOutcomeUnknownError. The dispatch then reads the child's status once: a child that answers is attached to, otherwise the call fails with the error's detail. A typed retryable answer (a Helix host's 503, which it never sends once the input is committed) stays the producer's own typed error. Once a child has started (any /status answer, or the sub-session ref's remote.streamId), a dispatch retry re-attaches with stream(childSessionId, { fromSequence }) and never re-POSTs /start (the D-2 guard). See Remote agents: entries are sent once.
The protocol still handles a /start that meets a live session:
/startagainst a live session returns a typed error envelope{code: 'ALREADY_RUNNING', streamId}(surfaced asRemoteAgentAlreadyRunningError), and- every consumer treats it as "attach" — proceed to stream from the existing session, not fail.
The same rule absorbs the check-then-act race where a consumer's own getStatus wakes a child DO, and the Cloudflare Workflows /start race noted above.
The child binding on the protocol
A consumer's /start and /resume of a remote child carry the delegation's binding (ChildBinding: parentSessionId, parentRunId, parentToolCallId, dispatchId, kind: 'remote'), and the /start keys on the call's dispatch id (requestKey). The child's session id is minted by the consumer (child_<uuid>), never derived from the parent (Child identity). The producer stores the binding on the child session when it creates it, and from then on only that delegation enters the child:
- a
/resumewhose binding names another parent or dispatch, or that carries none, is refused (RunStartRejectedError, causechild_binding_mismatch; agent-server answers 409state_run_start_conflictwith thatcause, the CF DO's/subagentroutes their protocol envelope), and nothing is written; - the child is never created over a session id that belongs to another delegation or is a root session: the store refuses it (
ChildSessionTakenError,state_child_session_taken); - an invalid
bindingis a typed 400validation_error, with no schema detail.
The binding is new protocol surface: deploy consumer and producer together. HTTP authentication stays the app's (the binding is an identity check between a parent and its child, not an auth token).
The binding is caller-asserted
A producer cannot verify a binding: its parent session lives in the consumer's service. The producer stores whatever binding the first /start of a child session carries, and checks every later entry against it. So admit start and resume requests that carry a binding only from your parent services, in your auth hook (agent-server's authenticate receives the operation and the whole request, its body included; the worker handler's auth receives the request). The child's session id (child_<uuid>) and the dispatch id (d_<uuid>) are random and unguessable, but they are not secrets: they appear in streams, snapshots, logs and the parent's history, so never treat knowing them as proof of being the parent.
A /start against a child that already finished fails the call (CP-78)
The attach rule covers only ALREADY_RUNNING. An agent-server producer answers a /start on a completed or failed session with 409 ALREADY_COMPLETED. The transports turn that into a typed HelixError (with the server's errorCode when it sends one), but the parent reports the sub-agent call as failed although the child completed. See CP-78 in the conformance findings.
Custom transports
RemoteAgentTransport has eight methods: the original six plus getSnapshot(sessionId) and getUsage(sessionId). Both shipped transports (HttpRemoteAgentTransport, DOStubTransport) implement them and Zod-validate responses.
Consumers degrade gracefully when a producer can't serve a read: RemoteAgentNotFoundError and RemoteAgentFeatureUnavailableError (e.g. a /usage with no usageStore wired) both mean "skip that fold, log a warning" — they never fail the run. The same path also covers a producer that is genuinely unresponsive: each read is deadline-bounded, and a timeout degrades identically.
Snapshots: the state baseline
const snapshot = await transport.getSnapshot(sessionId);
// RemoteSnapshotResponse
// {
// sessionId: string;
// streamId: string; // the sequence space this snapshot is aligned to
// state: Record<string, unknown>;// THIS session's own customState (possibly projected)
// streamSequence: number; // checkpoint-pinned; -1 = NO POSITION
// oldestRetainedSequence: number;// retention floor; 0 = full log retained
// stepCount: number;
// checkpointId?: string;
// }The checkpoint-pinned contract
Patches reach the wire per tool, at stage time, but writes become visible to loadState() only at the step-boundary promote. So no live two-read ordering is sound: "state first, sequence second" pairs step N−1's state with a mid-step-N sequence and the in-flight step's patches are skipped forever; the inverse ordering double-applies them. Normatively, therefore:
- Non-terminal sessions: the producer serves the latest checkpoint row —
stateandstreamSequencecome from one atomically-written record. The state is exact at a step boundary, and the consumer re-receives the in-flight step's patches from the durable log. - Terminal sessions (
completed/failed): the final state withstreamSequence: -1.
fromSequence is exclusive everywhere (sequence > fromSequence). Pass snapshot.streamSequence directly — no +1, no -1.
streamSequence: -1 means NO POSITION — not "a low number"
This is the single easiest way to corrupt a reconstructed document. -1 is served when:
- the session is terminal (
completed/failed) — the producer serves the livecustomStaterow; - there is no checkpoint yet (a fresh session, before its first commit);
- the checkpoint's recorded sequence is
0or below (a legacy checkpoint's degraded0, or-1when the producer runs without a stream manager), or the pointer-verify loop was exhausted.
In every case the producer is handing you a state that pins a moment, not a position. There is no S to attach at, so you cannot know which patches on the wire are already baked into it. Comparing sequences against -1 admits every patch (they all exceed -1) onto a state that may already contain them — and array appends are emitted as non-idempotent add /<key>/- ops, so entries get duplicated.
Rule: against an unpinned baseline, suppress every patch for that document (including the document's own local patches — see the contract below) and re-snapshot at a boundary (that session's
subagent_end, or end-of-stream). Stale beats corrupt.
Baselines are required in all cases
Initial state is seeded server-side (RemoteStartRequest.state, stateSchema defaults) and is never on the wire. Patch streams alone are never sufficient — not for the root session, not for children. Nested baselines are obtained per-descendant: subagent_start chunks carry subSessionId; call getSnapshot(childSessionId).
Obtaining a baseline is uniform at any depth. Aligning it is not — see the reconstruction contract.
Post-terminal snapshots pin a moment, not an immutable value
A turn's terminal status and its terminal hooks' customState writes (onAgentComplete) land in one commit, but companion continuation can reopen a completed session (completed → active). "Terminal" is per-turn. And the three recovery reads — getStatus → getSnapshot → getUsage — are not atomic, so a snapshot may reflect a later turn than the output you just harvested. Fold it as-is; if you need exactness, gate on checkpointId.
Truncation
oldestRetainedSequence is the retention floor (0 = full log retained). Streams get trimmed — Redis maxChunks, a cleanupToStep inside the current run. If you ask /sse for a fromSequence below the floor, the producer emits a single terminal truncated { floor, errorDetail } frame (errorDetail.code: 'TRUNCATED'; or a stream_resync chunk) rather than silently skipping. Refetch the snapshot and resume from its sequence. Never assume a silent gap. On the Cloudflare producers (the agent DO, the StreamServer DO) the floor applies only while a cleanup marker is set: a resetStream / reactivateStream is a deliberate new run, so a reader from 0 is served that run (see Stream wire).
A /sse body always ends with one terminal frame (end, fail or truncated). A body that ends without one (a proxy cut, an eviction, a crash) is a drop: the transports throw the retryable StreamDropError, and the dispatch re-attaches from its cursor.
One name, three snapshots
Three different things in this codebase are called "snapshot". If you are integrating services, you want (1).
| # | Surface | What it is |
|---|---|---|
| 1 | transport getSnapshot() (this page) | Protocol-layer, customState-only, checkpoint-pinned, crosses services. The one the reconstruction contract is built on. |
| 2 | ai-sdk chat-handler getSnapshot / FrontendSnapshot | Frontend hydration: messages + root state for useChat, built from one checkpoint. A local, same-service surface. |
| 3 | DO-native /snapshot | The SnapshotView wire the chat handler builds a FrontendSnapshot from, for same-worker frontends. Checkpoint-anchored, but not protocol-shaped. See the upgrade guide. |
Usage across the boundary
const { sessionId, rollup } = await transport.getUsage(childSessionId);getUsage returns a RemoteUsageResponse — { sessionId, rollup } — where rollup is the producer's getRollup(sessionId, { includeSubAgents: true }). The recursion flag is forced by the protocol handlers, so the rollup is recursive by construction: the remote child's own grandchildren are folded on the producer before anything crosses the wire.
- On a running session: a partial rollup, as-of-now.
- Once terminal: at least as of the
endevent. - Without a
usageStore:404 FEATURE_UNAVAILABLE. Consumers skip the usage fold with a warning.
Only the Cloudflare DO producer attaches usage to the end frame
end.usage is not producer-uniform. The DO producer (createRemoteAgentWorkerHandler / DurableObjectAgentBase.endStreamWithTerminalInfo) synchronously calls getRollup(sessionId, { includeSubAgents: true }) before ending the stream, so its end frame carries usage whenever a usageStore is configured and the terminal persist committed.
The other three producers — Node/JS AgentServer (JSAgentExecutor), Temporal, and DBOS — never attach usage to the end frame today, regardless of whether a usageStore is wired:
computeTerminalUsage()inruntime-js's run loop (run-loop.ts) is an unimplemented stub that always returns{}— the JS runtime does not thread agetRollup-capableUsageStorehandle through to the stream-finalization call site.- The Temporal, DBOS and Cloudflare Workflows producers (
CloudflareAgentExecutoris hosted the same way as the Node server) end a run's stream with itsstatusonly, so neitherstatenorusagerides theirendframe.
The end frame's status is never omitted: endStream's terminal.status is required (CP-87), so every producer records the run's own completed or interrupted, and an interrupted child is never read as a completed one. A stream that ended with no recorded status (a custom StreamManager that drops it) is answered as a retryable transport_stream_drop and logged as core.stream.end_status_missing, never a guessed completed.
Omitting state / usage is correctness-neutral: the consumer reconstruction contract never treats end.usage as authoritative — it is a fast-path optimization. Whenever end.usage is absent, consumers transparently fall back to the getUsage() recovery round-trip (one extra request), which works identically on every producer as long as a usageStore is configured. Wiring usageStore is still required on every producer for /usage and getUsage() to work — it just doesn't (yet) unlock end.usage outside the DO producer.
How it reaches the parent's rollup. The consumer's executor records a SubAgentUsageEntry pointer with the child's rollup embedded as remoteRollup (plus remoteRollupOrigin: 'end' | 'recovery'). At read time, aggregation:
- dedupes by
subSessionId; - resolves locally first (if the child lives in the same usage store), else selects exactly one embedded
remoteRollupby a total order —originpriority (recovery>end, because recovery rollups are supersets), then timestamp, then store insertion ordinal.
Duplicate entries are expected under durable retries and the dual fast/recovery recording paths. They are deduped at read, never summed.
Orphan spend is counted — and that is intended
After a JS/DO parent crash, the parent truncates to its checkpoint and re-rolls the LLM with new toolCallIds — so two pointer entries with different child sessionIds can exist for one logical call. Aggregation counts both children. The orphaned child really did burn tokens on the producer. Spend is spend.
The DO usage store is strict
A DO producer's consumer usageStore factory has no fallback: a factory that throws stops the entry (framework_usage_store_unavailable, retryable: a request answers 503, a wake re-arms), and a /usage read whose factory throws answers 503 with that errorCode. Usage never splits into the DO's internal store. With no factory configured, the internal DOUsageStore is bound to the DO's session: it refuses a write for another session and its rollup reads only that session's rows.
See the Usage Tracking guide for the rollup shape itself.
Fidelity mounts & projections (v1)
Mount the same agents twice with different projection / usageProjection / auth, and you get two fidelities: a raw internal view and a projected external view. The producer example does exactly this — /internal/agents/* (raw, service-binding-only) and /agents/* (projected, bearer-auth).
projection is a pure mapper, (state) => state. It is applied producer-side to point-in-time full states only: getSnapshot().state and end.state.
export function externalStateProjection(state: Record<string, unknown>): Record<string, unknown> {
const parsed = plannerStateSchema().safeParse(state);
if (!parsed.success) return { phase: 'unknown', threadCount: 0, findingCount: 0 };
return {
phase: parsed.data.phase,
threadCount: parsed.data.threads.length,
findingCount: parsed.data.findings.length,
};
}On a projected mount, every state_patch frame is suppressed
All of them — root, descendant, and developer-authored CustomStateStreamer frames (those are state_patch chunks too). Passing them through would leak the raw state and defeat the whole point of the split. Projected-mount consumers get state via snapshot + end.state. Live incremental projected state streaming is explicitly v2: it needs a second durable projected chunk log to stay resumable.
All other frames (text, tool, step, sub-agent lifecycle) pass through untouched, with their original sequence numbers — so fromSequence resumption is unaffected.
usageProjection: (rollup: UsageRollup) => Record<string, unknown> applies to /usage and end.usage — e.g. mapping tokens to billing units. Absent = the raw rollup. Note that usageProjection alone does not suppress state_patch frames; only projection does.
Executors must consume RAW mounts
Executor-to-executor integrations (i.e. anything using createRemoteSubAgentTool) must point at a raw mount. A transport aimed at a projected mount folds the lossy projected view into the parent as the child's customState.
Mount isolation is auth-only
idFromName is mount-agnostic: raw and projected mounts reach the same Durable Objects. Fidelity separation exists only at the worker layer. Raw mounts must be auth-gated whenever they are not private by construction.
Tracing across the boundary
A remote call is one trace from the caller's run to the producer's run (#179). No app context manager is needed.
- Consumer. The runtime pins the call's transport to the call's
execute_toolspan (RemoteAgentTransport.withTraceContext, applied through coretransportFor).HttpRemoteAgentTransport(andserviceBindingTransport) makes each request a CLIENT span and sends its trace through itstelemetrypropagator.DOStubTransportdoes the same through the DO's traced namespace.- A custom transport without
withTraceContextstill works, but does not carry the trace. - A subclass of
HttpRemoteAgentTransportorDOStubTransportkeeps its behaviour, but is pinned only when it overridescopyForTraceContext(see the agent-server reference).
- Producer.
AgentServerwithtelemetryopens a SERVER span per request under the inbound trace and runs the child under it.- A Durable Object producer (
createRemoteAgentWorkerHandlerwithtelemetry, or a DO → DO call) does the same through its request span.
runtime-js and the Cloudflare DO pin their remote calls. CF Workflows, Temporal and DBOS carry the entry's parent in their durable input and pin their remote calls to it, but emit no spans of their own yet (FU-OTEL-CFW, FU-OTEL-TEMPORAL, FU-OTEL-DBOS). Whether a producer trusts a caller's trace headers is the app's call, as auth is.
Auth posture
The two servers differ deliberately:
| Without an auth hook and without the opt-in | |
|---|---|
AgentServer (Node) | Fail-closed — the constructor throws. Provide authenticate, or pass allowUnauthenticated: true to acknowledge the risk (which then logs a loud warning through the configured logger). |
createRemoteAgentWorkerHandler (CF) | Warn-and-serve — it logs once, through the injected Logger (default noopLogger), and proceeds. Worker handlers commonly sit behind private service bindings, where a hard throw would be a false alarm. allowUnauthenticated: true acknowledges and silences it. |
The worker handler's auth hook gates all eight routes — including GET /sse and GET /snapshot (the highest-exfiltration routes) and the spend-inducing /start and /resume. Return true to proceed, false for a 401 envelope, or a Response to short-circuit.
Stated plainly:
- Service bindings are private by construction. No auth hook there is a legitimate posture (opt in explicitly with
allowUnauthenticated: true). - Public mounts MUST set
auth. - There is no built-in rate limiting and no SSE connection bounding. Put those in front of the mount yourself.
See the Security guide for the broader posture.
The consumer reconstruction contract
This is the heart of the feature: how anything outside the producer rebuilds a session's customState from a snapshot plus a patch stream, without corrupting it. It applies to independent late subscribers, to parent frontends, and to any parent-stream state consumer.
Use the shipped helper
RemotePatchFilter / createRemotePatchFilter (exported from @helix-agents/ai-sdk) implements rules 3–4 below — agentId filtering, provenance dedup, unpinned-baseline suppression, and unalignable-descendant suppression — and signals via onUnalignableBaseline. It is the normative implementation of rules 3–4 — do not hand-roll those. Rules 1–2 (getting a baseline, and attaching the stream at that baseline's own S) remain your obligation as the consumer: the filter cannot detect a cursor-less or misaligned attach from the wire, since a data-state-patch part carries no stream sequence. Get rules 1–2 wrong and the filter's rules 3–4 do not save you — see the "Consumer obligation" note in remote-patch-filter.ts.
1. Get a baseline
getSnapshot(sessionId) → a baseline at streamSequence S. A baseline is required in all cases — initial state is never on the wire.
2. Attach at the baseline's own S — mandatory, not optional
Stream /sse?fromSequence=S (exclusive). This is what guarantees no delivered patch precedes the baseline, which is precisely what makes it safe to apply the document's own local patches.
A cursor-less attach (fromSequence omitted) replays the run from its startSequence and double-applies the document's own pre-snapshot patches. It is only ever valid when the baseline is unpinned — because then every patch is suppressed anyway (rule 4).
Corollary: one attach cursor aligns one document. If you maintain several documents from a single stream whose baselines pin different positions in the same sequence space, you cannot pin them all with one cursor — seed those from a boundary snapshot instead, or track them in origin mode.
3. Filter by agentId, dedup by provenance
Apply state_patch chunks filtered by agentId = the session whose document you maintain. A parent frontend must never apply a forwarded child patch to the parent's document.
Forwarded child chunks carry remoteSource: { childSessionId, childSequence, streamId }. Dedup on (remoteSource.childSessionId, remoteSource.childSequence) — re-forward windows exist (durable retries re-stream a bounded suffix) and this makes them harmless. Within one parent run the parent stream already writes each child chunk once (RM-86: the stream keeps a high-water per child run and drops a chunk it already holds). A parent's next run clears it, so a child re-attached across runs is forwarded again: keep this rule.
For an alignable child patch: apply it only when remoteSource.childSequence > childSnapshot.streamSequence and the streamId matches.
4. Alignment is exact for the DIRECT child only
This is the rule most likely to bite you.
Every hop's dispatch re-stamps remoteSource into that hop's own sequence space (the parent-stream dedup key must be this hop's), while chunk.agentId stays the emitting agent and is never rewritten. On any parent stream:
remoteSource.childSessionId == the DIRECT child's sessionId (always)
agentId == the EMITTING agent's sessionIdSo for a grandchild (depth ≥ 2), the patch carries agentId = grandchildSessionId but remoteSource.childSequence in the direct child's space — while getSnapshot(grandchildSessionId) is pinned to the grandchild's space. Two unrelated axes. And it fails unsafely: the direct child only checkpoints after its tools return, so its pinned sequence predates every grandchild chunk, and the comparison admits patches the grandchild's snapshot already contains.
Discriminator (normative): a patch is alignable iff
agentId === remoteSource.childSessionId.
When they differ, do not align. Instead:
- seed the descendant's document from
getSnapshot(descendantSessionId), - suppress its parent-stream patches, and
- re-snapshot at boundaries — that session's
subagent_end, or end-of-stream.
The shipped filter reports this as onUnalignableBaseline(sessionId, 'nested_descendant') (latched, and permanently so — no re-snapshot can make a foreign-stamped patch alignable). It is deliberately conservative: a local in-process sub-agent of the direct remote child also carries a foreign stamp and is also suppressed, even though it would in fact have been alignable. Suppressing goes stale; admitting corrupts.
The same suppression applies to an unpinned baseline (onUnalignableBaseline(sessionId, 'unpinned_baseline'), recoverable — a later pinned snapshot re-arms it), for the reasons in Snapshots.
5. Re-snapshot triggers (mandatory)
Discard the reconstructed document and re-getSnapshot() on any of:
- a
stream_resyncchunk, - a
step_discardedchunk (patches from a step that never committed are removed from the log), - a
truncated { floor }frame (or aStreamTruncatedError), - a
streamIdchange.
A session's sequence space is scoped to a per-session streamId that is stable for the session's lifetime, with a counter monotonic across runs, resumes, and resets. If streamId changes, your cursor is meaningless.
6. In-step weakening
Within a step, cross-sibling patch order on the wire is nondeterministic (parallel tools), and same-key parallel replaces may transiently diverge from the committed value. Coherence is guaranteed at step boundaries — not inside a step.
7. CustomStateStreamer producers MUST use a distinct agentId
Not a session id. Its document is not customState; mixing the two documents under one agentId corrupts reconstruction on the consumer side.
8. An end may omit oversized parts — recover them, never read them as absent
A producer's stream manager stores each part of the run's terminal payload (the output, the final state, the usage) as one value, under its chunk limit (StreamManager.limits.maxChunkBytes; 1.8 MB on the Cloudflare stores). A part over it is not stored: the stream still ends, and the end frame names it in omitted (for example { status: 'completed', state: {...}, omitted: ['output'] }). An omitted part is absent although the run produced it, so a consumer must not read the absence as a value:
omittedincludesstate→ take the baseline fromgetSnapshot()(rule 1), as for anendwithoutstate;omittedincludesusage→ read it fromgetUsage();omittedincludesoutput→ read it fromgetStatus(): acompletedstatus carries the output. A failed read, or a status without an output, is a failure, never anulloutput.
Every Helix runtime does this for you through processRemoteSubAgentCompletion (pass the end's omitted in its end source; an unrecoverable output fails with reason: 'output-unavailable'), and so does a remote sub-agent tool's own execute() (a typed RemoteAgentFailedError). A hand-written consumer reading TransportEvents must apply the same rule.
Late-joining parent frontends — the child-snapshot proxy
A frontend of the consumer has a problem: it cannot reach the producer (the service binding is private), and the parent's snapshot contains no child state.
The pattern: the consumer service — which does hold the transport — exposes a proxy route.
import { serviceBindingTransport } from '@helix-agents/runtime-cloudflare';
import { RemoteAgentNotFoundError, RemoteAgentFeatureUnavailableError } from '@helix-agents/core';
// consumer worker: GET /api/child-snapshot?sessionId=<childSessionId>&agentType=<agentType>
async function childProtocolRoute(env: Env, url: URL, route: 'snapshot' | 'usage') {
const childSessionId = url.searchParams.get('sessionId');
if (!childSessionId) return Response.json({ error: 'Missing sessionId' }, { status: 400 });
const transport = serviceBindingTransport({
binding: env.PRODUCER,
basePath: '/internal/agents', // the RAW mount
// A nested GRANDCHILD needs its own agentType — the producer validates the
// path :agentType against the session's stored agentType.
agentType: url.searchParams.get('agentType') ?? 'planner',
});
try {
return Response.json(
route === 'snapshot'
? await transport.getSnapshot(childSessionId)
: await transport.getUsage(childSessionId)
);
} catch (err) {
if (err instanceof RemoteAgentNotFoundError)
return Response.json({ error: err.message }, { status: 404 });
if (err instanceof RemoteAgentFeatureUnavailableError)
return Response.json({ error: err.message }, { status: 501 });
return Response.json({ error: String(err) }, { status: 502 });
}
}On the wire to the browser, ai-sdk data-state-patch parts carry agentId and remoteSource (StatePatchPartData), so the frontend can demux. Frontends MUST drop parts whose agentId ≠ the document they maintain — that is rule 3, and RemotePatchFilter.shouldApply() is its implementation.
Explicit limitation
In-flight remote-child state is not reconstructable from the parent surface alone in v1. A late-joining parent frontend sees child state from exactly three places:
- live patches that arrive after it joins,
- the proxy above, or
- parent-folded summaries —
afterSubAgent→childCustomState→ parentupdateState(), which makes them visible in the parent's snapshot.
Parent-log replay-from-0 is NOT a valid child-state oracle. The parent's log retains child patches from steps the child later discarded.
Folding child results into the parent
The child's final customState reaches the parent hook as AfterSubAgentPayload.childCustomState. It is populated from:
end.state— only whenend.status === 'completed', orgetSnapshot()on recovery paths, for terminal statuses (including interrupted-with-output), and when theendomitted an oversized state (rule 8).
Terminal-only by design. Mid-flight folding is an explicit non-goal: it would drag mid-flight sequence reconciliation into the parent. Parents that want live child state consume the stream.
The end handling rule: only status: 'completed' folds output/state/usage directly. paused and interrupted route through status-based recovery. (This discriminator is why a paused child no longer surfaces to the parent LLM as "completed, output null".)
hooks: {
afterSubAgent: (payload, context) => {
const child = payload.childCustomState;
if (!child) return;
context.updateState((draft: WriterState) => {
// Idempotent SET, not an append.
draft.research = { phase: child.phase, findingCount: child.findings.length };
draft.researchSessionId = payload.subSessionId;
});
},
}Fold guidance: idempotent set/mark, not append
On at-least-once runtimes, afterSubAgent can re-fire. state.results.push(...)duplicates in crash-retry windows. On JS/DO the exactly-once fold guarantee is only per run-attempt — a parent crash + resume re-rolls the step — so the same guidance applies there too. Prefer idempotent set/mark folds.
An end that omitted an oversized output (omitted: ['output'], rule 8) folds the output read from getStatus() instead; if that read fails or carries no output, the tool call fails (output-unavailable).
Output re-validation is default-on: end.output is safeParsed against the tool's outputSchema, and a failure surfaces as a structured tool error rather than flowing garbage into the parent's LLM context.
maxFoldStateBytes (default 262_144) caps the serialized child state routed through the fold. Over-cap: the state fold is skipped with a warning; output and usage are unaffected.
The compact-customState-summary pattern
Store-derived state views are deliberately out of the SDK — the protocol's resumable state view is customState, full stop. If your producer keeps rich derived state in an external store (a D1 research tree, say), the sanctioned pattern is to also maintain a compact summary in customState:
{ phase: 'researching', threadCount: 4, findingCount: 17 }That summary streams as patches, snapshots cheaply, folds into parents, and survives projection. Serve the rich view out-of-band from your product API.
A paused remote child: the parent parks (CP-157)
A remote child pauses when a call anywhere in its tree waits for an answer: a client-executed tool or an approval. On runtime-js, the CF Durable Object and CF Workflows, the parent then parks on it, the way it waits on a local child, if the root can answer the call:
- The producer lists the call. Its
GET /statuscarriespendingCalls: its whole tree's unanswered calls, oldest first, at most 100 (pendingCallsTruncatedpast that), each input at most 8 KiB (inputTruncated, still answerable). - The transport can forward an answer. It implements
submitToolResult; the HTTP transport andDOStubTransportdo.
What happens:
The park. The parent's call parks at once, with no stream retries spent:
- the run suspends
suspended_awaiting_childrenon achildKind: 'remote'wait; - the child's ref turns
paused_awaiting_clientand keeps its stream position; - the calls are recorded in the ROOT session's
remotePendingCalls, keyed by the call's own id.
- the run suspends
The render. The root's snapshot renders each one after a reload, as the live stream did:
- an approval: a
dynamic-toolpart,approval-requested, under its approval id; - a client call:
input-available.
A resume re-sends the approval request.
- an approval: a
The answer. A stock
addToolApprovalResponse(or a client tool's result, so a client-executed tool anywhere in the remote tree must be implemented by the ROOT UI) goes to the root's chat route. The root's submit router forwards it to the producer (submitToolResult, then aresumekeyed on the parked batch), and the root continues.The re-attach. The resumed parent re-attaches the recorded child: no second
/start, its stream from the recorded position. It pairs the child's real output once and clears its parked calls. A child that pauses again re-parks.
Deeper trees. A remote producer that itself parks on a remote child records the calls on ITSELF (the top of its own store's tree), lists them on its /status, and forwards an answer for one of them to that child. A producer carries its consumer's rootSessionId, so neither step uses that id as a local session.
The spin bound. A parked child that does not move after an answer could loop: the root re-parks, a client re-posts the answer, and the root resumes again. Two guards bound it:
- A re-sent answer the child already has (
already_completed) while its/statusstill lists that call on a pause does not continue the root: the answer isalready_completed/consumed, logged at error (client_tool.remote_answer_not_taken). If the status read fails, the root is continued as before, so the re-send of a lost answer still wakes it. - A second re-park on the same ANSWERED call. The root's router stamps a parked call's entry (
answeredAt) before it forwards an answer. A re-attach that finds the child parked on that same call again counts one no-progress re-park (RemotePendingCallEntry.reparks). A call nobody answered never counts, so a sibling still waiting for its own answer is never cancelled. Answered again after a no-progress re-park means the child's answers are not being taken. The stamp is written before the forward: if it fails (a store error, or a root that stays contended) the client's POST fails retryably with nothing forwarded, so a re-send never loses the approval. A forward the producer did not take (it threw, or answeredunknown_tool_call) clears the stamp again, so only a delivered answer counts. The second one is logged at error (cp157.remote_park_no_progress) and the delegating call fails typed with CP-45'sRemoteChildPausedError(transport_stream_drop/remote_child_paused): the child is interrupted, its ref settledfailed, and the run goes on with the failed call, as for a pause the root cannot answer. A child that pauses again on a NEW call is progress, and re-parks normally.
A stopped root refuses the answer. When the root's run has a recorded stop, an answer to a parked call is refused run_stopped, never forwarded or queued: the producer never runs an approved tool under a stopped parent. The root's run is settled through the runtime's own stop path. Every terminal write on runtime-js, the CF DO and CF Workflows (a stop's; the K5 of a run that completes, fails or is interrupted; the failed write after a K5 that never lands; and the failure of a run whose owner closed, such as a DO whose auto-resume ran out) also clears the session's remotePendingCalls, so an ended run's parked calls are gone from /status and the snapshot. So does every store's status compare-and-set to a status that is not live, which covers a status-only terminal write (an expired session's cleanup, a run that fails before its loop). An interrupted root that is resumed re-parks: the resumed run re-attaches the still-paused child (never a second /start) and re-records its calls from the child's /status, so a later answer is forwarded again.
Upgrading a custom
SessionStateStore.compareAndSetStatusmust clearremotePendingCallsin the same atomic write whenever the new status is notactive/paused, as it already clearsinterruptContext(the core contract suite checks it:remote-pending-calls.cleared-by-terminal-casandremote-pending-calls.kept-by-live-cas). The built-in stores (memory, Redis, Postgres, D1 and the CF DO's DO-SQLite) do.
An answer to a call whose park is gone is unknown_tool_call. The router forwards an answer only while the root session is live (active / paused; a root that failed, completed or was interrupted refuses it whatever its stored calls say), the call's parent still holds the remote wait on that child for the same delegating call, the parent's current run is live, and that delegating call is still one of the parent's trailing unpaired calls (the re-attach's own test). An entry left from a run that ended (a nested parent that failed while the root lives, or a parent whose call a new turn paired) is refused like any call that no longer exists: nothing is forwarded, stamped or continued, logged client_tool.submit_unknown with reason: 'parked_call_stale'. The chat snapshot renders a parked call by the same test (core isParkedEntryCurrent / parkedEntryIsCurrent), so the UI never offers an approval whose answer would be refused: a call under the snapshot's own session is tested against the snapshot's view, one under a nested parent reads that parent (an unreadable parent keeps the call, logged; the router stays the gate). A producer's /status lists its parked calls in pendingCalls by the same test (agent-server and the CF DO), so an upstream consumer never parks on a call whose answer this producer would refuse.
On CF Workflows the re-attach is a durable step only where it can matter. The scan (<agentType>-reattach-remote-scan-<step>) runs on a resume entry only for an agent with a remote sub-agent tool, or one whose drained state still holds a remote wait (a redeploy removed the tool: the delegating call then fails typed, Remote sub-agent tool not found). Every other agent's resume issues no such step. Each step.do is persisted, billed and counted toward the instance's step limit: "Maximum steps per Workflow" is 1,024 on Workers Free and 10,000 by default (configurable up to 25,000) on Workers Paid; step.sleep does not count (Workflows limits, last updated Sep 21, 2026).
Two children listing the same call id fail the step typed (framework_internal_error, non-retryable) at every merge point (the first park and a re-park), so an answer never reaches the wrong producer.
Every other pause keeps the typed failures: a parent on Temporal or DBOS (FU-CP157-TEMPORAL-DBOS-REMOTE-PARK), an older producer without pendingCalls, a truncated listing, or a transport without submitToolResult.
- A child's own client call:
RemoteSubAgentClientToolUnsupportedError. - Otherwise: CP-45's
RemoteChildPausedError(transport_stream_drop/remote_child_paused, retryable) once the retries run out. - Either way: the failed call interrupts its child first, so a paused child is never left parked on its producer behind a
failedref that every stop cascade skips.
Limits in this release:
- A call answered only on the producer's side (its own UI) does not wake the root: the root waits until a reload re-presents the call or a caller resumes it. A durable poll of the child's
/status(MR 2) closes this, and CP-185 with it. - A call id that already names another call on the root (another child's, or the root's own tree's) fails the step typed (
framework_internal_error, non-retryable). It is never overwritten, which could forward an answer to the wrong producer. - The trust boundary is the transport.
pendingCallscarries the same tool inputs the proxiedtool_start/tool_approval_requestframes already carry. The answer is validated by the tool's owner, the producer.
Producer liveness & zombies
Node producers have no self-heal. Unlike the DO wake ladder, if the process dies mid-run, the session stays running forever. This is why /status.isExecuting became honest (this replica's live handles).
The shared dispatch escalates after N attach attempts against status === 'running' && isExecuting === false:
- with
RemoteSubAgentConfig.resumeDeadProducer: true, it callstransport.resume({ mode: 'continue' })— opt-in, because a blind resume is wrong for a multi-replica producer whereisExecuting: falsemay just mean "executing on another replica"; - otherwise it fails fast with a distinct
'producer-dead'failure reason, instead of burning the whole retry budget.
Run a cleanup sweep on Node producers (expiredSessionCleanup from @helix-agents/agent-server) so zombie sessions eventually become failed.
Consumer-side reconciliation (JS/DO). On parent resume after a crash, the executor reads every remote SubSessionRef still running (or paused_awaiting_client) through the transport's getStatus and applies one transitions table (core remoteChildVerdict; the full table is in the remote agents guide):
- a terminal child's real outcome (an
interruptedchild is a typed failure) is written as its call's result, and the ref settles in that same commit (Invariant S: one result per call, so a crash or a failed write can never pair the call twice); - a non-terminal child whose call did NOT survive truncation is an orphan — reattach is impossible (a new LLM roll produces new toolCallIds) — so the executor issues a best-effort
transport.interrupt()to bound producer-side spend and marks the reffailed; - a non-terminal child whose call did survive is left
running: the LLM re-calls the tool and the deterministic child sessionId reconnects to the existing session; - a child still running past its deadline (
startedAt+ the runtime's watch budget + 60 s; see the remote agents guide), not known to the producer, refused typed, or unreadable past the deadline is lost: its call gets a typedframework_subagent_lostresult and the child is interrupted, best-effort; - a child whose status cannot be read inside its deadline is never settled: reconcile reads it again later, and an entry carrying a new message is refused with the retryable
framework_subagent_status_unavailable(HTTP 503) rather than orphaning a child that may have finished.
Only the transport's answer decides: a failed write to the parent's own store is never read as a lost child.
The orphan's tokens still count — see Usage.
Interrupt propagation is best-effort per hop.
Connection limits & fan-out
A Workers request context allows roughly 6 simultaneous open connections, and each in-flight remote child holds a long-lived SSE fetch. A parent that fans out many parallel remote children over bindings or stubs can stall — the connections are also competing with the parent's own LLM call and any other subrequests.
Bound your parallel remote fan-out. Sequence threads, or batch to ~4–5 concurrent remote children, leaving headroom.
Limitations
These are v1 constraints, stated so you don't discover them in production.
- Client-executed tools and approval-gated tools inside remote children are not supported. Both suspend via
pendingClientToolCalls, and both hit the consumer's fail-fast guard (which names both cases:RemoteSubAgentClientToolUnsupportedError, failure reason'client-tool-unsupported'). Cross-service suspension is a separate project. Theend.status: 'paused'discriminator is what makes the guard's recovery deterministic instead of a silent success-null. - Persistent / companion remote children are not expressible.
RemoteSubAgentConfighas nomode; remote refs are ephemeral. Extending the companion protocol across services is future work. - No distributed trace continuity. Remote children start unlinked traces. Thread trace ids yourself via
RemoteStartRequest.metadata— every consumer forwards the parent'suserId/tags/metadatato the child — plus producer-side hooks. - No packaged late-subscriber client. The reconstruction contract on this page (and
RemotePatchFilter) is the documented pattern; a fully packaged independent-subscriber client is future work. - No protocol version on the wire. There is no version field and no negotiation: every producer and consumer ships in this repo and deploys in lockstep. A breaking wire change is a coordinated redeploy, not a runtime handshake.
- No automatic tree snapshots. Per-descendant
getSnapshot(childSessionId)is the uniform way to obtain a baseline; recovery is not uniform (rule 4 above). - Live incremental projected state streaming is v2. Projected mounts serve state via snapshot +
end.state.
Next steps
- Remote Agents — the base protocol,
createRemoteSubAgentTool,HttpRemoteAgentTransport. - Cloudflare DO Runtime —
createAgentServer,DurableObjectAgentBase, and the DO producer surface. @helix-agents/agent-serverreference — every route, config field, and auth hook.@helix-agents/runtime-cloudflarereference —createRemoteAgentWorkerHandler,serviceBindingTransport.@helix-agents/corereference — the wire-protocol types and schemas, theRemoteAgentTransportinterface, and the completion-fold helpers (processRemoteSubAgentCompletion,validateRemoteOutput).- Usage Tracking — the rollup shape and the aggregation contract.
- Example: Remote Agents (Cloudflare DO → DO) — everything on this page, running.
- Example: Remote Agents (Temporal) — the Node
AgentServerproducer path.