@helix-agents/runtime-cloudflare
Cloudflare runtime for Helix Agents with two execution approaches: Workflows for step-level durability, and Durable Objects for unlimited streaming.
Breaking: the File Store workspace has its own entry
CloudflareFileStoreWorkspaceProvider, CloudflareFileStoreWorkspace, CloudflareFileStoreFileSystem and CloudflareFileStoreWorkspaceConfig are exported from @helix-agents/runtime-cloudflare/filestore, no longer from the main entry. The new /do entry exports the Durable Objects runtime without the Workflows runtime. See the upgrade guide.
Breaking: stream wire contract v2
The DO's /sse speaks wire contract v2: fail replaces the error frame, a cursor below the retention floor is a terminal truncated { floor } frame, a cleanup while attached is rewound, and the : connected comment is a caught-up { head } frame. Every reader (DOStreamManagerClient, the DOFrontendExecutor handle's stream(), DOStubTransport) throws the retryable StreamDropError when a body ends with no terminal frame, and DOStreamManagerClient reads that fail throw instead of reading as "absent" (CP-28). DOStubTransport sends /start / /resume once (CP-95). Deploy writers and readers together. See the upgrade guide.
Breaking: run-start commits and checkpoint-anchored snapshots
Every entry (DO and Workflows) now goes through a run-start commit; stores must implement the new commit contract (D1 migrations V17 / V18, DO-SQLite migrations 11 / 12). The DO GET /snapshot wire is the SnapshotView shape, a missing session answers 404, and DOStateStoreClient throws a typed DOPeerError. On Workflows, WorkflowInstance.terminate() is required and instance ids changed: let in-flight instances finish, or terminate them, before you deploy. Deploy the StreamServer worker before, or with, the workers that run agents. See the upgrade guide.
Breaking: stop as one signal (WA MR2)
Every stop records a run-scoped StopRequest and answers at once. The DO's /interrupt, /abort and EndpointContext.stop answer 202 stopping / 200 interrupted | failed / 409 state_not_stoppable / 404 (no more 400 on a paused session, no wait for the run); stopDeadlineMs (default 30 s) ends a run that ignores its stop, a stop through AfterEntryContext.handle included; DO client handles throw typed stop errors and stop only their own run (the routes take an optional runId); a stop cascade reaches only the stopped run's children (and those an earlier run left running). The interrupt flag is gone: D1 migration V20 and DO-SQLite migration 14 create the stop tables, drop the flag tables and add sub_session_refs.parent_run_id. See the upgrade guide.
Breaking: DOStateStore.deleteSession refuses with a typed error
DOStateStore.deleteSession throws a HelixError with code: 'framework_not_supported' (non-retryable) instead of a plain Error; catch it on code. DO-SQLite migration 15 adds sub_session_refs.location (SubSessionRef.location, now required). See the upgrade guide.
Breaking: stop conditions and verbatim string tool results
On the DO and on Workflows, a maxSteps cut-off of an agent without outputSchema fails with framework_max_steps_exhausted (and retry() continues it), Workflows now honours stopWhen, and a string tool result is stored verbatim with metadata.helixContentEncoding: 'text'. DO-SQLite migration 13 adds runs.completion_reason; Workflows child instance ids are keyed on the parent run, so drain in-flight instances before you deploy. See the upgrade guide.
Breaking: Workflows terminal settle, entry stall, pre-commit failures
On Workflows, abort() writes the terminal commit and can reject (retryable) when that write fails; every executor reader settles a run whose instance closed without its terminal commit; and the entry fail-safe throws core RunStartStalledError for a run that committed but never started, instead of returning a handle. On the DO (and runtime-js), an entry that fails before its run-start commit no longer marks a session that already has a run failed, and /start / /retry answer it as a typed { error, code } HTTP error (core typedErrorResponse, with the httpStatusForHelixError status; never 503 once the entry's run exists) instead of started; DOFrontendExecutor throws such a body as a typed error carrying the code (DO client errors). A failed stream's getStreamInfo().metadata now carries errorDetail on every CF stream manager. See the upgrade guide.
Breaking: the Durable Object drive pipeline
Every DO run (/start, /resume, /retry, a completing submit, a wake, the /subagent routes) goes through one pipeline. The six lifecycle hooks and onComplete are replaced by beforeEntry / afterEntry / onRunSettled; the "interrupt inside beforeStart" pattern by concurrentEntry: 'interrupt_previous'; ExecutionState is read-only. The resolver may be async and, like llmAdapter({ ... }) and usageStore({ ... }), takes one context with the session's identity. The usage store is strict. /resume takes DoResumeOptions. The native busy answer is 409 state_already_running, an unknown agent type is 404 framework_agent_not_found, and AgentNotFoundError is removed in favour of core AgentResolutionError. See the upgrade guide.
Breaking: the DO companion consult protocol (spec 2026-10-15, MR A)
A DO parent records its persistent companion's outcome only from the child's own committed run for the ref's consult: a child-settled notice (POST /companion/child-settled, DO to DO) and the parent's verified read of the child's /subagent/:type/status. A child the parent cannot read is answered { status: 'unreachable' } (a successful tool result, on the blocking spawn and companion__waitForResult) and is no longer recorded failed and aborted. Every companion entry carries parentToolCallId. DO-SQLite migration 17 adds four sub_session_refs columns. The parent and its companions must share one subAgentNamespace: it is also the route back. See Companion consult protocol.
Breaking: request keys on the Durable Object (D-2)
The DO's /start, /resume, /retry and /subagent/:type/start|resume read X-Helix-Request-Key. A keyed duplicate is answered from its key before the concurrency check: a new 200 status: 'attached' naming the run that holds the key, or 409 state_request_key_conflict / state_request_already_completed. StartResponseBody.status gains 'attached', the /snapshot run gains requestKey / requestDigest, and DOStateStore adds getRunByRequestKey (DO-SQLite migration 16). DOFrontendExecutor declares supportsRequestKey and sends the key; CloudflareAgentExecutor (Workflows) declares supportsRequestKey: false. See Request keys and the upgrade guide.
Breaking: companion entry outcomes and deliver-then-continue (spec 2026-10-15, MR B)
companion__spawnAgent answers core CompanionSpawnResult on the DO: a non-blocking spawn or continue reports its entry outcome ({ name, status: 'spawned', delivered: true } or { name, status: 'not_spawned', delivered: false, reason, errorDetail? }, a successful tool result; the 409 "already running" advice error and the continue's tool errors are gone), a blocking one the child's committed word with a failed answer's errorDetail. A continue over a result the model has not seen delivers it as priorResult beside its own answer and runs the new consult (before, the continue cleared it unseen). Every /start is bounded (CHILD_START_TIMEOUT_MS, 60 s) and an unconfirmed start is re-probed, never respawned. DO-SQLite migration 18 adds the held-prior columns. See Companion consult protocol and the upgrade guide.
Breaking: a parent owes its child the stop (CP-217)
Every stop the DO owes a child, a persistent companion or a remote child, lives on the child's ref (SubSessionRef.stopOwed) and is retried by the stop-cascade alarm until the child confirms it or the retry window ends (owedStopSchedule, default 30 s doubling to 1 h, 24 h window). MR C1's per-child KV records and childStopRetryBudgetMs are removed. The cascade's mark is written once every child is confirmed or durably owed. A consult stop of a run that serves another consult answers 409 state_stop_target_mismatch (was state_not_stoppable); a /subagent/:type/interrupt|abort must name its delegation. See the upgrade guide.
Breaking: a companion paused on client calls (spec 2026-10-17 MR C §2, CP-40 (C2))
A companion whose tree can call a client-executed or approval-gated tool is refused on the DO: the spawn answers { status: 'not_spawned', reason: 'child_not_supported', errorDetail } with the new ErrorCode framework_companion_not_supported (before any write), and the child's own companion entry refuses it with the same code (materialize step 2c; a re-spawn wipes nothing). A remote sub-agent (CP-157) is not a companion and is never refused: its root answers its calls. A child that suspends anyway is answered { name, status: 'suspended', pendingCalls, message } by the blocking park and companion__waitForResult (its ref paused_awaiting_client), companion__sendMessage answers child_awaiting_client instead of throwing, and a same-name spawn of a live companion answers not_spawned (child_busy, child_interrupted, child_awaiting_client) instead of the "already running" error. CF Workflows answers the same suspended arm. See the guide and the upgrade guide.
Breaking: DO companion stop truth (spec 2026-10-17, MR C1)
A parent's stop never writes a companion ref. A blocking companion__spawnAgent parked when the parent is stopped answers { name, status: 'parent_stopped', kind } (never committed) and the ref stays running until the child's own committed outcome reaches it. A blocking spawn whose child was interrupted on its own answers { name, status: 'interrupted', message } after three observations. Every parent-to-child stop carries parentToolCallId: the child stops only the run serving that consult, else answers 409 state_not_stoppable. The stop cascade is confirmed per companion: an unconfirmed child is owed its stop, retried by the new stop-cascade alarm for childStopRetryBudgetMs (default 1 h), then given up with the ref left live (stop.child_unconfirmed_given_up, error). New config: childStopRetryBudgetMs, companionStartTimeoutMs. DOStateStore.getStopCascadeRefs returns StopCascadeRef. No migration. See the upgrade guide.
Installation
npm install @helix-agents/runtime-cloudflareFor Workflows approach, also install:
npm install @helix-agents/store-cloudflareEntry points
| Import | What it exports |
|---|---|
@helix-agents/runtime-cloudflare | Both runtimes (Durable Objects and Workflows), the script workspace and the shared utilities. |
@helix-agents/runtime-cloudflare/do | The Durable Objects runtime alone: createAgentServer, DurableObjectAgentBase, the DO stores and clients, AgentRegistry, AlarmScheduler, createRemoteAgentWorkerHandler, serviceBindingTransport, DOStubTransport. Loads none of the Workflows runtime. |
@helix-agents/runtime-cloudflare/do-clients (also /direct-access) | The Worker-side DO clients (DOFrontendExecutor, DOStateStoreClient, DOStreamManagerClient), without partyserver or cloudflare:* imports, for code that cannot resolve them (e.g. Next.js). |
@helix-agents/runtime-cloudflare/filestore | The File Store workspace (CloudflareFileStoreWorkspaceProvider, …); loads @cloudflare/shell. |
@helix-agents/runtime-cloudflare/sandbox | The Sandbox workspace (needs the optional peer @cloudflare/sandbox). |
@helix-agents/runtime-cloudflare/testing | FakeSandbox, a test double for @cloudflare/sandbox (not a stable API). |
Everything /do and /do-clients export is also exported by the main entry, as the same objects (instanceof holds across entries). A Durable Object host can import from /do to load less at startup; see Startup Cost → Import from /do.
Durable Objects Runtime API
The DO approach executes agents directly inside a Durable Object, bypassing the 1000 subrequest limit by streaming directly to WebSocket/SSE connections.
Composition API (Recommended)
The composition API using createAgentServer() is the recommended approach for creating AgentServer instances.
createAgentServer
Factory function that creates a Durable Object class with composition-based configuration.
import { createAgentServer, AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
const registry = new AgentRegistry();
registry.register(myAgent);
export const MyAgentServer = createAgentServer<Env>({
llmAdapter: ({ env }) =>
new VercelAIAdapter({
anthropic: createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }),
}),
agents: registry,
// "Last message wins": a new entry stops the run this DO is executing.
concurrentEntry: 'interrupt_previous',
hooks: {
onRunSettled: async ({ runId, status, deliveryId }) => {
// At least once per (runId, status): deduplicate on deliveryId.
},
},
});Every run the DO starts or recovers goes through one drive pipeline: /start, /resume, /retry, a client-tool submit that completes a batch, the onStart / alarm wake, and the /subagent/:type/start|resume protocol routes all resolve the agent, build the executor, apply the hooks and settle the run the same way. See Cloudflare DO → The drive pipeline for the behaviour, and the upgrade guide for what changed.
DurableObjectAgentConfig
Configuration for createAgentServer(). The main fields:
interface DurableObjectAgentConfig<TEnv = unknown> {
/** Builds the LLM adapter for each drive. May be async. Required. */
llmAdapter: LLMAdapterFactory<TEnv>;
/** An AgentRegistry (or any AgentResolverInterface) or a resolver function. Required. */
agents: AgentResolverInterface<TEnv> | AgentResolver<TEnv>;
/** Host hooks: beforeEntry, afterEntry, onRunSettled, transformRequest, transformResponse. */
hooks?: ServerHooks<TEnv>;
/** Custom endpoints, checked BEFORE the built-in routes. */
endpoints?: Record<string, EndpointHandler<TEnv>>;
/**
* Consumer usage store. Strict: a throw is `framework_usage_store_unavailable`
* (retryable), never a fallback to the internal store. Unset: the DO-local DOUsageStore.
*/
usageStore?: UsageStoreFactory<TEnv>;
/** What an entry does while another drive executes a run on this DO (default 'reject'). */
concurrentEntry?: ConcurrentEntryPolicy;
/**
* Bound on the `interrupt_previous` settle wait when the stop it records has no deadline, on a
* submit's continuation wait, and on a keyed request's wait for the producer claim (D-2)
* (default 30 000 ms).
*/
concurrentEntryTimeoutMs?: number;
/**
* The stop deadline for both stop kinds (default 30 000 ms, `DEFAULT_STOP_DEADLINE_MS`): a run still
* live this long after a stop is ended with the stop's terminal and its stuck drive detached. A
* positive integer, else the constructor throws a typed `validation_error`.
*/
stopDeadlineMs?: number;
/**
* The DO state store's size limit, in UTF-8 bytes of the JSON it binds: one tool's staged writes,
* the folded `customState` of a step or state commit, and one message row (MA-31). Default
* `DEFAULT_MAX_STATE_BYTES` (1.8 MB, 200 KB under Cloudflare's 2 MB SQL value cap). A tool whose
* write is over it gets a typed failed result (`helixSynthetic.kind: 'state_write_rejected'`); a
* step / terminal / message commit over it fails the run `state_size_exceeded`. A positive integer
* or `Infinity`; anything else makes the DO's constructor throw a typed `validation_error`.
*/
maxStateBytes?: number;
/**
* The DO stream's size limit (RM-90), in UTF-8 bytes of the JSON it binds for one chunk and for
* each part of an end's terminal payload. Default `DEFAULT_MAX_STATE_BYTES` (1.8 MB). Over it a
* chunk is refused typed `state_size_exceeded` (operation `stream_chunk`) before a sequence is
* allocated; an end's oversized part is omitted (the end frame's `omitted`). A positive integer at most
* `CF_SQL_MAX_VALUE_BYTES` (2 MB); anything else makes the DO's constructor throw a typed
* `validation_error`. Model deltas are split at the producer, so only an unsplittable chunk meets it.
*/
maxStreamChunkBytes?: number;
/**
* CP-217: how a stop this DO owes a child is retried after the child did not confirm it (`{ baseMs, maxMs,
* windowMs }`, each optional, over core's `DEFAULT_OWED_STOP_SCHEDULE`: 30 s doubling to 1 h, given up after
* 24 h; a typed `validation_error` for a bad member).
*/
owedStopSchedule?: Partial<OwedStopSchedule>;
/**
* The bound on a persistent companion's `/start` (default 60 000 ms; a positive integer, else a typed
* `validation_error`). At the bound the spawn answers `not_spawned` / `child_start_unconfirmed`.
*/
companionStartTimeoutMs?: number;
/**
* DO namespace for sub-agent and persistent-agent (companion) DOs. A companion also notifies its parent
* through it (`idFromName(parentSessionId)`), so the parent and its companions share one namespace.
* Required by an agent with sub-agent tools (`createSubAgentTool`) or `persistentAgents`: a DO store holds
* one session, so without it the drive refuses the agent typed (`framework_not_supported`, CP-144).
*/
subAgentNamespace?: (env: TEnv) => SubAgentNamespace;
/** Workspace providers handed to every executor the DO builds. */
workspaceProviders?: (env: TEnv, ctx: DurableObjectState) => Map<string, WorkspaceProvider>;
/**
* MA-26: retry a model call whose error is classified `retryable: true`, each retry a new attempt of
* the same step (as the Workflows executor's `llmStepRetry`). Default: core `DEFAULT_LLM_STEP_RETRY`
* (3 retries, 1 s, exponential); `{ limit: 0, delayMs: 0 }` turns it off.
*/
llmStepRetry?: LLMStepRetryPolicy;
/** Eviction recovery: auto-resume a stranded run on wake (default true). */
autoResumeOnWake?: boolean;
/** Eviction recovery: wake auto-resumes without progress before the run fails (default 5). */
maxWakeResumeAttempts?: number;
/**
* #179: OpenTelemetry and the DO's logger, resolved ONCE per instance (constructor). Without it:
* the global provider (a no-op unless the app registered one) and no logging. See "Telemetry" below.
*/
telemetry?: (env: TEnv, ctx: DurableObjectState) => TelemetryOptions;
// Also: submitToolResultSchema (the submit size limit, passed to every executor the DO
// builds, DI-51; a maxResultBytes over maxStateBytes makes the constructor throw a typed
// validation_error, MA-31), maxRequestBytes (the largest /start, /resume, /retry and
// /subagent/* body it reads; default 10 MB, core DEFAULT_MAX_REQUEST_BYTES; a larger
// one is the typed 413 validation_payload_too_large, never buffered in full),
// verboseValidationErrors, workspaceMetrics, workspaceMaxConcurrentOpens,
// companionPollFailureWindowMs.
}Factory contexts
Every factory sees the session's persisted identity and the reason it is called.
/** The session's identity, fixed when the session is created. */
interface AgentIdentity {
userId?: string;
tags?: string[];
metadata?: Record<string, string>;
parentSessionId?: string;
rootSessionId?: string;
}
type DriveKind = 'start' | 'resume' | 'retry' | 'continue' | 'recover';
/** A run is about to be driven under `runId`. */
interface DriveFactoryContext<TEnv> {
purpose: 'drive';
env: TEnv;
sessionId: string;
runId: string;
drive: DriveKind;
identity: AgentIdentity;
}
/** The DO needs the agent's config but starts nothing (submit validation, retention). */
interface LookupFactoryContext<TEnv> {
purpose: 'lookup';
env: TEnv;
sessionId: string;
identity: AgentIdentity;
reason: 'submit_validation' | 'retention' | 'stop_cascade';
}
type AgentFactoryContext<TEnv> = DriveFactoryContext<TEnv> | LookupFactoryContext<TEnv>;
/** A usage-store call that reads usage and drives nothing. */
interface UsageReadContext<TEnv = unknown> {
purpose: 'usage_read';
env: TEnv;
sessionId: string;
identity: AgentIdentity;
}continue is a wake drive that resumes a paused session (a submit that completed its client-tool batch, the durable wake for an unconsumed submit, or a stranded active session); recover re-enters a running run under its own run id after an eviction. A resolver sees a run id only with purpose: 'drive'.
LLMAdapterFactory and UsageStoreFactory
type LLMAdapterFactory<TEnv = unknown> = (
context: DriveFactoryContext<TEnv> & { agentType: string }
) => LLMAdapter | Promise<LLMAdapter>;
type UsageStoreFactory<TEnv = unknown> = (
context: DriveFactoryContext<TEnv> | UsageReadContext<TEnv>
) => UsageStore | Promise<UsageStore>;A throw from the adapter factory becomes framework_agent_resolution_failed with the cause's retryability. A throw from the usage-store factory becomes framework_usage_store_unavailable (retryable). usage_read calls come from the protocol /subagent/:type/usage route and from the end-of-stream usage rollup when no drive of this DO instance built the store.
AgentResolver
type AgentResolver<TEnv = unknown> = (
agentType: string,
context: AgentFactoryContext<TEnv>
) => AnyAgentConfig | Promise<AnyAgentConfig>;Throw core AgentResolutionError.notFound(agentType, availableTypes) for an unknown type: a request answers 404 framework_agent_not_found, and a wake ends the run typed. Any other throw becomes framework_agent_resolution_failed with the original as cause, keeping its retryability: an unclassified throw is transient, so a request answers 503 and a wake re-arms under the crash-loop budget.
Telemetry
telemetry: (env, ctx) => TelemetryOptions (#179) is resolved once per instance, in the constructor. It gives the tracer provider (default: the global one), the flush (() => provider.forceFlush()), content capture (default off) and the DO's logger. Helix never registers a context manager: every span below has an explicit parent.
| Span | Kind | Parent | Attributes |
|---|---|---|---|
{METHOD} {route} (e.g. POST /start, GET /subagent/{agentType}/sse) | SERVER | the caller's traceparent, else a root | http.request.method, http.route, http.response.status_code, faas.coldstart; a /snapshot adds helix.snapshot.rows, helix.snapshot.bytes, helix.session.message_count |
helix.do.start | INTERNAL | the request or alarm that started the instance | |
helix.do.init | INTERNAL | helix.do.start | |
helix.do.wake.redeliver, helix.do.wake.reconcile, helix.do.wake.recovery | INTERNAL | helix.do.start, or the alarm | helix.do.wake.source (on recovery) |
helix.do.entry | INTERNAL | a /start, /resume, /retry or /subagent/{agentType}/start|resume request | helix.do.drive, http.response.status_code; events materialized, before_entry, executor_built, entered |
invoke_agent {agent} (runtime-js) | INTERNAL | helix.do.entry, or the wake step that re-drives the run | see runtime-js |
helix.do.stop | INTERNAL | an /interrupt or /abort request | helix.stop.kind, helix.stop.answer |
helix.do.alarm | SERVER | none (a root), linked to the session's last run | faas.trigger=timer, faas.coldstart |
{METHOD} {route} | CLIENT | the hop's parent | every SDK-owned call to a DO (the DO clients, DO → DO); its trace goes in traceparent |
Every DO span also records the instance's SQLite work while it was open: helix.db.statements, helix.db.rows_read, helix.db.rows_written. The counts are exact for helix.do.start and its children (blockConcurrencyWhile admits nothing else). A request span's counts include work the instance did concurrently for other requests.
A request span ends when its response is returned; a stream's body is not part of it. The run goes on detached, under helix.do.entry. The flush goes to ctx.waitUntil when a request, an alarm or a run ends.
A DO → DO call the DO makes while it holds a span parents on that span:
- a companion tool's calls to its child, on the tool's
execute_toolspan; - a forwarded client-tool submit, on the submit's request span;
- a stop's cascade to the children, on
helix.do.stop; - a run's settle tail (its stop cascade, the child-settled notice), on the drive's
helix.do.entry; - the alarm's owed child stops and owed notices, on
helix.do.alarm.
Telemetry never changes behaviour. A telemetry call that throws is reported at warn instead of thrown: once per telemetry instance and event, with the error's detail. The work, request or hop it was tracing goes on unchanged. That covers a throwing propagator or span processor, or a span update that throws:
| Event | What threw | What happens instead |
|---|---|---|
core.telemetry.inject_failed | the propagator's inject on an outbound hop | the request goes out with its own headers |
core.telemetry.extract_failed | the propagator's extract on a request | the request is read as carrying no trace |
core.telemetry.span_start_failed | starting a span (a processor's onStart) | the work runs under a non-recording span |
core.telemetry.span_end_failed | ending a span (a processor's onEnd) | nothing: the span is dropped |
core.telemetry.span_update_failed | an event, attribute or link on an open span | nothing: that update is dropped |
A deferred wake that several requests asked for (a submit that lands while a run is executing) runs under a helix.do.wake.continue span: its parent is the first requester's span, and each later requester's span is a link.
A run a wake continues nests under that wake's span: the alarm's for an expired client-tool deadline, and the first requester's for a wake deferred behind a live execution. The alarm's link to the session's last run is best-effort: a failed read is logged (telemetry.alarm_link_failed) and never skips the alarm's stranded-run recovery.
- a sub-agent call's
DOStubTransporthops (/start, the/ssestream, status reads, its stops), on the call'sexecute_toolspan (RemoteAgentTransport.withTraceContext, applied through coretransportFor); - a stop's cascade to remote children, and the alarm's owed stops to them, on the stop's, the settle tail's or the alarm's span.
The ids helix persists (tracingContext.otel) and hands to hooks (traceContext) follow whether telemetry is configured, not whether a span is sampled. With no provider they are absent, even under a remote parent. A sampled-out run still records its ids.
ServerHooks
Host hooks. All are optional.
interface ServerHooks<TEnv = unknown> {
/** Admission for /start, /resume, /retry and /subagent/:type/start|resume. */
beforeEntry?(ctx: BeforeEntryContext<TEnv>): Promise<void | { abort: true; response: Response }>;
/** Once per drive, after the drive owns its run. A throw is logged. */
afterEntry?(ctx: AfterEntryContext<TEnv>): Promise<void>;
/** At least once per (runId, status) when nothing executes the run any more. */
onRunSettled?(ctx: RunSettledContext<TEnv>): Promise<void>;
/** Transform a native /start body into the standard shape (native /start only). */
transformRequest?: (rawBody: unknown, env: TEnv) => Promise<StartBody>;
/** Transform the native /start `started` answer. */
transformResponse?: (
response: StartResponseBody,
context: { sessionId: string; agentType: string }
) => Promise<unknown>;
}beforeEntryis admission only (auth, rate limits, quotas). It runs after the busy check, the identity / type gate and agent materialization, so an entry refused by any of them never reaches it. It sees the entry's input and identity, never mutable state. On/startitsbodyis the transformed, validated start body (aftertransformRequest); the raw body stays readable atorigin.request: the original request rebuilt with the body the DO read once, undermaxRequestBytes. Returning{ abort: true, response }answers that response verbatim and writes nothing. A throw answersframework_hook_failed, retryable unless its cause is classified non-retryable. Wake drives never run it./submit-tool-result(caller-supplied tool results and approval decisions) does not go throughbeforeEntry: authorise it in the worker in front of the DO.afterEntryfires once per drive, after its run-start commit (or, forrecover, after the in-memory claim of the run). It is not awaited by the request: a throw is logged at error.onRunSettledfires whenever a run reaches a status at which nothing executes for it (SettledStatus), on every path: requests, submits, wakes and recovery. The settle path ends the run's own stream and releases the execution registration first, so a new entry is never blocked by a slow hook. Delivery is at least once per(runId, status): a durable mark (__helix_settle_delivered:{runId}:{status}in DO KV) is written after the hook returns, and a wake redelivers a settled run whose mark is missing. A throwing hook is logged and still marked. A paused run that is later stopped settles twice (suspended_client_tool, theninterrupted). A race (a stop inside the suspension window, or a wake beside an entry) can deliver one(runId, status)twice, possibly with a differenterrorDetail, and a stop in the suspension window can skip thesuspended_*delivery. Deduplicate ondeliveryIdand keep the first delivery. With noonRunSettledconfigured, no mark is written.transformRequest: a thrownHelixErrorkeeps its code; a Zod failure (recognised by shape) answers 400validation_error; any other throw isframework_hook_failed, retryable (503) unless its cause says otherwise.
Hook contexts
interface RequestOrigin {
/** The original request: headers preserved, body unread. */
request: Request;
route: 'native' | 'subagent';
}
type BeforeEntryContext<TEnv> =
| { drive: 'start'; env: TEnv; origin: RequestOrigin; body: StartBody; identity: AgentIdentity }
| {
drive: 'resume';
env: TEnv;
origin: RequestOrigin;
options: DoResumeOptions;
identity: AgentIdentity;
}
| {
drive: 'retry';
env: TEnv;
origin: RequestOrigin;
checkpointId?: string;
/** The retry's new user message, exactly as /retry parsed it. */
message?: string | UserInputMessage[];
identity: AgentIdentity;
};
interface AfterEntryContext<TEnv> {
env: TEnv;
sessionId: string;
runId: string;
drive: DriveKind;
handle: AgentExecutionHandle<unknown>;
}
type SettledStatus =
| 'suspended_client_tool'
| 'suspended_awaiting_children'
| 'suspended_step_partial'
| 'completed'
| 'failed'
| 'interrupted'
| 'superseded';
interface RunSettledContext<TEnv> {
env: TEnv;
sessionId: string;
runId: string;
drive?: DriveKind;
status: SettledStatus;
result?: AgentResult<unknown>;
errorDetail?: ErrorDetail;
/** `${runId}:${status}` */
deliveryId: string;
}Every request drive exposes all the user input it carries: a start its body.input (message, state, history), a resume its options (a with_message message), a retry its message. origin.route tells a remote-protocol call ('subagent') from a native request.
AbortResult
Return type of a beforeEntry that refuses the entry.
interface AbortResult {
abort: true;
response: Response;
}ConcurrentEntryPolicy
type ConcurrentEntryPolicy = 'reject' | 'interrupt_previous';It applies to /start, /resume and /retry (native and /subagent) while another drive executes a run on this DO.
'reject'(default) answers 409state_already_running(ALREADY_RUNNINGon/subagent).'interrupt_previous'first runs the new entry's gate, materialization andbeforeEntry. Only then does it stop the executing run (the DO's one stop, an interrupt with reasonsuperseded_by_new_entry), wait for that run to settle, check that nothing executes, and proceed. The wait is bounded by the recorded stop'sdeadlineAtplus 5 s (the stop deadline ends the run by then), else, for a stop with no deadline, byconcurrentEntryTimeoutMs(default 30 000 ms). A timeout answers 409state_already_running; the stop still applies, and, like every stop that ends a run, it cascades to the run's live children (background companions included). An entry that is refused or not admitted never stops the live run.
A keyless chat send (createCloudflareChatHandler / handleChatStream through DOFrontendExecutor, with no request key) is answered 409 state_already_running while a run executes, under either policy (CP-13): DOFrontendExecutor marks keyless entries with the internal x-helix-reject-concurrent-entry header, because a network retry of a keyless send would stop its own run and append the message twice. A keyed send (useHelixChat and createHelixChatTransport key every send) carries no marker. A duplicate is answered from its key before the concurrency check and attaches to its run; a new keyed send follows the DO's policy like a native entry, so under interrupt_previous it stops the executing run and starts its turn (CP-13's interrupt half, D-2 MR 1.5). Its response carries only its own run's chunks (handleChatStream streams from the run's start, pinned to its runId), so the stopped run's tail never renders in the new reply.
A drive whose handle.result() has settled is in its tail and is not executing: a new entry waits for its settle (same bound) under either policy.
A /start never supersedes a suspended run (O-70). With nothing executing, a /start (native, /subagent or a chat send) over a session whose current run is live but suspended (suspended_awaiting_children, suspended_step_partial or suspended_client_tool) is refused under either policy. A new run would supersede it and lose what it waits for. The answer is 409 state_already_running with the run's real status and a hint, from core suspendedRunRefusalHint(status):
| Run status | Hint |
|---|---|
suspended_awaiting_children | The run continues on its own when its children deliver; send again after it ends, or stop it |
suspended_step_partial | The run continues on its own when its partial step resumes; send again after it ends, or stop it |
suspended_client_tool | The run continues when its pending client-tool results are submitted (submitToolResult() / resume()), or stop it |
The message reads Agent session '<id>' is already running (status: <status>). <hint> (AgentAlreadyRunningError, currentStatus and hint). runtime-js execute() and CF Workflows execute() answer the same. A pending client-tool call is continued through /resume or /submit-tool-result, never a new /start.
ExecutionState
A read-only view of what the DO executes, given to custom endpoints.
interface ExecutionState {
readonly sessionId: string | null;
readonly runId: string | null;
/**
* A drive's execution is registered: from just before its executor call until the
* settle path releases it, which happens before `onRunSettled` is delivered.
*/
readonly isExecuting: boolean;
readonly status: 'idle' | 'running' | 'paused' | 'interrupted' | 'completed' | 'failed';
}To stop a run, use EndpointContext.stop or the /interrupt / /abort routes. To replace a live run with a new native entry, set concurrentEntry: 'interrupt_previous' (a chat send is refused instead; see above).
EndpointHandler
Handler function for custom endpoints.
type EndpointHandler<TEnv> = (
request: Request,
context: EndpointContext<TEnv>
) => Promise<Response>;
interface EndpointContext<TEnv> {
env: TEnv;
executionState: ExecutionState;
logger: Logger;
/** Stop the run this DO executes: 'interrupt' (soft, resumable) or 'abort' (hard). */
stop(kind: 'interrupt' | 'abort', reason?: string): Promise<StopAnswer>;
}stop is the /interrupt / /abort mechanism (core stopRun on the session's current run) and answers at once with core's StopAnswer, never waiting for the run: { status: 'stopping', runId, kind } (recorded and signalled; the run is executing), { status: 'interrupted' | 'failed', runId } (nothing executed the run, so this call committed its terminal), or { status: 'not_stoppable', currentStatus } (no live run, nothing written; 'none' with no session on the DO). reason defaults to the kind; it rejects only for a store failure. Core stopAnswerResponse(answer) gives the routes' status and body.
Request bodies
StartBody (native POST /start)
interface StartBody {
agentType: string;
input: {
message: string | UserInputMessage[];
state?: Record<string, unknown>;
history?: Message[];
}; // a plain string is accepted as { message }
sessionId: string;
userId?: string;
tags?: string[];
metadata?: Record<string, string>;
parentSessionId?: string; // set when a parent DO spawns this DO
rootSessionId?: string;
branch?: { fromSessionId: string; checkpointId?: string; messageIndex?: number };
}A session's identity is fixed when it is created. A /start on an existing session must name the same agentType (else 409 state_agent_type_mismatch), and a userId, tags or metadata it sends must equal the session's (else 400 validation_error; the error's field names the first that differs). Omitted fields keep the session's values.
DoResumeOptions (native POST /resume)
// body: { agentType: string; options: DoResumeOptions }
type DoResumeOptions =
| { mode: 'continue' }
| { mode: 'with_message'; message: string | UserInputMessage[]; abandonPending?: true }
| { mode: 'from_checkpoint'; checkpointId: string };abandonPending: true (W1 MR2b, D11) makes a new message over pending client calls ONE entry: the embedded runtime-js resume() answers every unanswered awaited call client_tool_abandoned, commits the results and the message after them, and runs one model call (see the client-tool guide). DOFrontendExecutor declares supportsAbandonOnResume, so handleChatStream's path 4 sends it. Each variant is strict: an unknown key (for example appendMessages or modifyState) answers 400. /retry is its own route, a branch is a /start with branch, and approvals go through /submit-tool-result. agentType must equal the session's.
Native POST /retry
// body:
{ agentType: string; checkpointId?: string; message?: string | UserInputMessage[] }StartResponseBody
The native /start, /resume and /retry started answer (and transformResponse's input):
interface StartResponseBody {
sessionId: string;
streamId: string;
runId: string;
status: 'started' | 'resumed' | 'attached'; // attached: a keyed duplicate (D-2)
startSequence?: number; // the stream head at the run-start commit; always sent with 'attached'
}attached names the run that holds the request's key (runId) and that run's RunMetadata.startSequence: this request started and wrote nothing. transformResponse sees only a started /start answer, never an attached one.
Entry wire answers
An entry is answered only once its run-start commit landed. A request that fails before that commit writes nothing to a session that already has a run.
Native route (/start, /resume, /retry) | Answer |
|---|---|
| started | 200 StartResponseBody |
| a keyed duplicate (D-2): the key's run is live, or ended and still current | 200 StartResponseBody with status: 'attached', runId and startSequence of that run |
| a keyed request: its run ended and a later run exists | 409 { error, code: 'state_request_already_completed', runId } |
| a keyed request: its key used for other input | 409 { error, code: 'state_request_key_conflict', runId } |
a malformed X-Helix-Request-Key | 400 { error, code: 'validation_error' }, before any read; the key is not echoed |
beforeEntry refused | the hook's Response |
| busy (another run owns the session) | 409 { error, code: 'state_already_running' } |
| a rejected run start | 409 { error, code: 'state_run_start_conflict', cause }, cause a RunStartRejectedError cause (runStartRejectCauseSchema()) |
| the session's status refuses the resume | 409 { error, code: 'state_not_resumable' }; a partial client-tool batch's message names the awaited call ids first |
| unknown agent type | 404 { error, code: 'framework_agent_not_found' } |
| anything else | core typedErrorResponse: 400 caller errors, 503 retryable (409 / 500 once the input committed), 409 state_*, else 500 |
framework_not_supported (for example sub-agent tools or persistent agents without subAgentNamespace) is 500. On the /subagent/:type/start|resume routes the same answers use the remote-protocol envelope RemoteAgentErrorResponse, with the typed code in errorCode and a rejected run start's reason in cause: busy is 409 ALREADY_RUNNING, a refused resume is 409 ALREADY_COMPLETED (completed session) or INVALID_REQUEST, an unknown agent type or session is 404 NOT_FOUND, a 400 is INVALID_REQUEST, and any other status is INTERNAL_ERROR. A keyed duplicate answers 200 with status: 'attached' (the same body as started); state_request_already_completed is 409 ALREADY_COMPLETED and state_request_key_conflict 409 INVALID_REQUEST, each with the typed code in errorCode and the key's run in runId. Every message is scrubbed (core sanitizeErrorMessage); the code and shape are kept.
Request keys
The DO honours the request key (D-2) on every request drive: the native /start, /resume and /retry and the protocol /subagent/:type/start|resume. One route mapper reads X-Helix-Request-Key for both route kinds, before any read; a malformed key is the 400 above.
- Before the concurrency check. A keyed drive looks its key up (
getRunByRequestKey, then the current run) and runs coreplanKeyedEntrybeforecheckConcurrency. A duplicate of a live run therefore attaches and never gets the busy 409, andinterrupt_previousnever stops the original run for its own duplicate. A duplicate of a finished run that is still the session's current run is answeredattachedtoo (the caller replays it). Thealready_completedandconflictplans are the typed 409s. None of them writes anything. - A keyed request waits for the producer claim. A keyless request that finds the claim held answers busy at once. A keyed one waits for it, bounded by
concurrentEntryTimeoutMs(then busy as before), and then plans from its key, so a concurrent duplicate attaches to the run the first entry committed. - The race backstop. The key lookup is two async reads under the claim. The run-start commit's key rule (
request_key_taken, checked insiderunStartCommitSync's transaction) and the embedded runtime-js executor's own keyed plan close the gap: when the executor returns an attached handle, the drive re-reads the key and answersattached. - New entries record the key. A
newplan runs the entry withrequestKeyon itsexecute()/resume()/retry(), and the run-start commit records it with the digest (coreexecuteRequestDigest/resumeRequestDigest/retryRequestDigest, so the DO and runtime-js digest one request the same way). A wake carries no key. - Eviction. The key lives on the DO-SQLite run row (migration 16, a unique index on
(session_id, request_key)), so it survives eviction.
DOFrontendExecutor marks only keyless entries with x-helix-reject-concurrent-entry; a keyed send follows the DO's concurrentEntry policy (see ConcurrentEntryPolicy). A started or resumed answer carries the new run's startSequence, which its handle exposes and streams from.
AgentRegistry
Maps agent type names to configurations. Supports both static registration and factory functions.
import { AgentRegistry, defaultAgentRegistry } from '@helix-agents/runtime-cloudflare/do';
const registry = new AgentRegistry();
// Static registration
registry.register(MyAgent);
// Factory registration (a factory may be async)
registry.registerFactory<Env>('brainstorm', async (ctx) =>
createBrainstormAgent({ apiKey: ctx.env.API_KEY, userId: ctx.identity.userId })
);
// Resolve (handles both static and factory): a static agent synchronously,
// a factory's result as it returns it
const agent = await registry.resolve('brainstorm', ctx);
// Get a STATIC agent only
const agent = registry.get('researcher');
// Other methods
registry.has('researcher'); // Check if registered
registry.unregister('researcher'); // Remove registration
registry.getRegisteredTypes(); // Get all type names
registry.clear(); // Remove all (for testing)An unknown type throws core AgentResolutionError.notFound (framework_agent_not_found, non-retryable) from both resolve() and get(), synchronously. resolve() lists every registered type in availableTypes; get() lists only the static registrations, and for a factory-registered type its message says to use resolve().
AgentFactory
type AgentFactory<TEnv = unknown> = (
context: AgentFactoryContext<TEnv>
) => AgentConfig<z.ZodType, z.ZodType> | Promise<AgentConfig<z.ZodType, z.ZodType>>;AgentResolverInterface
Interface implemented by AgentRegistry and custom resolvers.
interface AgentResolverInterface<TEnv = unknown> {
resolve(
agentType: string,
context: AgentFactoryContext<TEnv>
): AnyAgentConfig | Promise<AnyAgentConfig>;
has?(agentType: string): boolean;
}AgentResolutionError
The registry and the DO's materialization throw core AgentResolutionError (from @helix-agents/core):
class AgentResolutionError extends HelixError {
readonly agentType: string;
readonly availableTypes?: string[];
static notFound(agentType: string, availableTypes: string[], options?: { reason?: string });
static failed(agentType: string, cause: unknown); // framework_agent_resolution_failed
}Match it with the core guards, which check name and code and so also recognise an error from another copy of core:
import { isAgentNotFoundError, isAgentResolutionError } from '@helix-agents/core';
try {
const agent = await registry.resolve('unknown', ctx);
} catch (error) {
if (isAgentNotFoundError(error)) {
console.error(`Unknown agent type: ${error.agentType}`);
console.error(`Available types: ${error.availableTypes?.join(', ')}`);
}
}DOFrontendExecutor
The Worker-side client of a session's AgentServer DO (createCloudflareChatHandler builds one per chat route). It addresses the session's DO with idFromName(sessionId), an absolute https://internal/… URL and the x-partykit-room header, like the other DO clients.
import { DOFrontendExecutor } from '@helix-agents/runtime-cloudflare/do'; // or '/do-clients' outside a DO Worker
const executor = new DOFrontendExecutor({ namespace: env.AGENTS, agentName: 'my-agent' });
const handle = await executor.execute(agent, 'Hello', { sessionId, requestKey }); // POST /start
const existing = await executor.getHandle(agent, sessionId); // null for a session the DO never saw
await executor.resume(agent, sessionId, { mode: 'continue', requestKey }); // POST /resume (DoResumeOptions)
await executor.submitToolResult({ kind: 'client-tool-result', sessionId, toolCallId, result }); // POST /submit-tool-resultA handle has stream({ fromSequence?, signal? }), abort(reason?) and interrupt(reason?). The LLM adapter, hooks and usage store are configured on the DO (createAgentServer), not on the client.
Every DO client (DOFrontendExecutor, DOStateStoreClient, DOStreamManagerClient) takes telemetry (TelemetryOptions) and telemetryParent (an OpenTelemetry Context, typically the app's request span). Each call to the DO is then a CLIENT span under telemetryParent whose ids travel as traceparent, so the DO's spans join the app's trace. A Worker has no context manager unless the app registers one, so pass telemetryParent (construct the client per request); without it the parent is the active context. See Telemetry.
DOFrontendExecutor's execute, resume and submitToolResult also take a per-call telemetryParent (EntryTraceOptions; the submit takes it as its 2nd argument). It overrides the client-level parent for that call's hops and for its handle's later hops. It is never sent in the request body. handleChatStream passes the request's span this way, so a client shared across requests still puts each turn's run in that request's trace.
DOFrontendExecutor declares submitLimits (DI-51), the limits a chat host in front of it pre-checks every submitted answer against: core resolveSubmitLimits of its submitToolResultSchema option, default core's (1 MB), which is also the DO's default. A DO configured with a different submitToolResultSchema needs the same schema here, so the edge and the DO agree:
const submitToolResultSchema = createSubmitToolResultSchema({ maxResultBytes: 4 * 1024 * 1024 });
// The DO: createAgentServer({ ..., submitToolResultSchema })
// The Worker:
const executor = new DOFrontendExecutor({
namespace: env.AGENTS,
agentName: 'my-agent',
submitToolResultSchema,
});A mismatch fails safe: a lower edge limit refuses first; a lower DO limit answers its own typed 413, which the route relays unchanged.
DOFrontendExecutor declares supportsRequestKey (D-2). execute() and resume() take an optional requestKey and send it as the X-Helix-Request-Key header (never in the strict DoResumeOptions body). An attached answer gives a handle with attached: true on the run that holds the key, whose stream() starts at that run's startSequence. An attached answer without startSequence is a non-retryable transport_error.
handle.stream() reads the DO's /sse (stream wire contract v2) like every other runtime's handle reads its stream manager: a run that FAILED (a fail frame) ends with one error chunk carrying its ErrorDetail as the stream stored it (sanitized, like all error text the stream stores), then the iteration ends (it never throws for a failed run); a truncated { floor } frame, or a rewound frame that leaves the read behind the head, throws StreamTruncatedError; a body that ends with no terminal frame (an eviction, a dropped connection, a crash) throws the retryable StreamDropError (re-attach from your cursor) unless options.signal aborted the read (CP-102), which ends quietly; a 404 is null; any other non-2xx answer, a failed request or a malformed frame is a typed error. DOStreamManagerClient.createResumableReader reads the same way (an EOF after its own close() is a plain end), returns null for a stream that does not exist or has already failed, and forwards waitForLive (/sse?waitForLive=1). Its getStreamInfo reports the stream's own status (the /snapshot's streamStatus), never the run-derived snapshot status, and null when the DO has no stream (RM-73).
A failed read is not "absent" (CP-28). DOStreamManagerClient.getStreamInfo and getChunks* return null / [] only when the stream does not exist (a 404, or no stream on the DO). A failed request, any other non-2xx answer or a body that fails its schema throws a typed DOPeerError (the peer's code, else transport_error). See Reading a DO's stream from a Worker.
DOWorkflowExecutor is removed
It never reached a Durable Object (relative URLs, no x-partykit-room header, a session:-prefixed id no other client used), and every capability it claimed beyond DOFrontendExecutor had no caller. Use DOFrontendExecutor (or the chat route), and read usage with the DO's /usage route.
DOStateStore
State store implementation using Durable Object's built-in SQLite.
import { DOStateStore } from '@helix-agents/runtime-cloudflare/do';
// Created automatically inside AgentServer
// For manual use:
const stateStore = new DOStateStore({
sql, // PartyServer's sql tagged template
logger, // Optional
});It implements getRunByRequestKey (D-2). DO-SQLite migration 16 (the request-key migration) adds runs.request_key / runs.request_digest and a unique partial index on (session_id, request_key). runStartCommitSync reads the key's holder inside its own transactionSync and refuses a held key with RunStartRejectedError('request_key_taken').
DOStateStoreOptions
interface DOStateStoreOptions {
sql: SqlTaggedTemplate;
transactionSync: TransactionSyncFn; // ctx.storage.transactionSync, bound
kv?: DOSyncKv; // ctx.storage.kv: the parent-side ref generation floor (CP-161); DOAB passes it
logger?: Logger;
maxStateBytes?: number; // default DEFAULT_MAX_STATE_BYTES (1.8 MB)
}DOSyncKv ({ get(key): unknown; put(key, value: number): void }) is the part of the DO's synchronous KV the store uses; its writes take part in the surrounding transactionSync. Without kv the floor is 0 and a respawn wipe that deletes refs logs do.sub_session_ref_floor_unavailable at error: only right for a DO that hosts no companion parent.
DEFAULT_MAX_STATE_BYTES (1.8 MB) and CF_SQL_MAX_VALUE_BYTES (2 MB) are exported from the package root. DOStateStore.limits is uniformStoreLimits(maxStateBytes): one limit for staging, the folded state (and the state row's output and client-tool maps) and each message row. Over it the store throws core's StateSizeExceededError before it binds anything; a driver error after a bind over CF_SQL_MAX_VALUE_BYTES (2 MB) is mapped to the same error by the measured bytes. maxStateBytes must be a positive integer or Infinity (else a typed validation_error). A DO sets it through DurableObjectAgentConfig.maxStateBytes. See State size limit.
DOStreamManager
Stream manager that broadcasts directly to connected WebSocket/SSE clients. It stores and broadcasts only sanitized error text (core): each written chunk, and failStream's message and errorDetail, so the stored stream meta, the SSE / WebSocket fail frames and the WebSocket close reason carry no raw text. The raw text stays in the session row and the log.
import { DOStreamManager } from '@helix-agents/runtime-cloudflare/do';
// Created automatically inside AgentServer
// For manual use:
const sseConnectionManager = new SSEConnectionManager({ logger });
const streamManager = new DOStreamManager({
sql,
getConnections,
broadcast,
sseConnectionManager,
logger,
});DOStreamManagerOptions
interface DOStreamManagerOptions {
sql: SqlTaggedTemplate;
getConnections: () => Map<string, WebSocket>;
broadcast: (msg: string) => void;
sseConnectionManager: SSEConnectionManager;
logger?: Logger;
maxChunkBytes?: number; // RM-90: default DEFAULT_MAX_STATE_BYTES (1.8 MB), at most CF_SQL_MAX_VALUE_BYTES
}DOStreamManager.limits is { maxChunkBytes } (RM-90): a chunk over it is refused with core StateSizeExceededError (operation: 'stream_chunk') before any sequence is allocated. A part of an end's terminal payload over it (the final output, state or usage) is not stored: the end still lands, and getEndOmitted(), getStreamInfo().metadata.endOmitted, the /snapshot's streamEndOmitted and every SSE end frame's omitted name it.
getStreamInfo(streamId) returns null when the stream was never created, and for a streamId that is not the DO's own stream (RM-71). SSE connections get stream wire contract v2 frames (encodeStreamFrame); WebSocket connections keep the core StreamEvent JSON shape.
DOStreamWriter
Writer returned by createWriter() for emitting chunks. Implements the StreamWriter interface.
class DOStreamWriter implements StreamWriter {
write(chunk: StreamChunk): Promise<void>;
close(): Promise<void>;
}Connection Types
// WebSocket connection state (survives hibernation)
interface ConnectionState {
fromSequence: number;
}
type StreamStatus = 'active' | 'paused' | 'ended' | 'failed';DOUsageStore
Usage store implementation using Durable Object's built-in SQLite.
import { DOUsageStore } from '@helix-agents/runtime-cloudflare/do';
// Created automatically inside AgentServer
// For manual use:
const usageStore = new DOUsageStore({
sql,
sessionId,
logger,
});
// Record usage
await usageStore.recordEntry(entry);
// Get entries
const entries = await usageStore.getEntries(sessionId);
// Get rollup
const rollup = await usageStore.getRollup(sessionId);
// Recursive fold — now aggregates sub-agents
const rollup = await usageStore.getRollup(sessionId, { includeSubAgents: true });The store is strict about its session. recordEntry for any other session throws a HelixError validation_error (non-retryable) and inserts nothing, and getEntries / getRollup read only rows whose sessionId is the store's (filtered in SQL; a row whose data is not valid JSON is excluded and counted in a warning). The DO uses this store only when config.usageStore is unset; a configured factory that throws stops the entry (framework_usage_store_unavailable) instead of falling back to it.
getRollup(sessionId, { includeSubAgents: true }) aggregates sub-agents (it previously skipped them entirely). A DO owns exactly one session, so a child's entries can never resolve locally — each sub-agent group instead resolves via the embedded SubAgentUsageEntry.remoteRollup that the consumer executor recorded on the parent when the child completed (from the child's end.usage, or getUsage() on recovery). Embedded rollups are producer-side includeSubAgents: true results, so grandchildren arrive pre-folded; exactly one rollup is selected per subSessionId (by origin 'recovery' > 'end', then timestamp, then insertion ordinal) and never summed. Sibling-DO children and cross-service remote children both resolve through this path.
DO HTTP wire: /snapshot and /messages
DurableObjectAgentBase serves the read side the chat helpers use:
GET /snapshot(queryincludeReasoning, defaulttrue) returns the session's checkpoint-anchored view, read in one synchronoustransactionSync:sessionId, status, head, streamSequence, legacy, checkpointId, stepCount, checkpoint {id, kind, runId, keepCount, stepCount, messageCount, streamSequence}, messages, state, run {runId, status, startUIMessageCount, startSequence, turn, requestKey?, requestDigest?}, plus the session fields (streamId, sessionStatus, createdAt, updatedAt, agentType, version, error, errorDetail, interruptContext, the client-tool maps, root / parent ids,streamError,streamErrorDetail(a failed stream's structured detail),streamFinalOutput), andstreamStatus: the stream's own status ('active' | 'paused' | 'ended' | 'failed' | 'none','none'with no stream), read in the same transaction.statusis the snapshot's run-derived status (a live run readsactivebefore its stream is claimed), so a stream reader must usestreamStatus.messagesis the checkpoint's[0, messageCount)prefix; nothing uncommitted is appended. Every error text on it (error,errorDetail,streamError,streamErrorDetail) is sanitized with coresanitizeErrorMessage/sanitizeErrorDetail; the raw text stays in the session row. A missing session is 404{ error: 'Session not found', code: 'state_session_not_found' }; a read failure is{ error, code, cause? }with coretypedErrorResponse's status: 409 for a non-retryable one (e.g.state_history_incompletewithcause: 'corrupt_row'), 503 for a retryable one, 500 for an unknown error.DOStateStoreClientthrows it as aDOPeerErrorthat is retryable only for a 5xx or 429 (and for a failed fetch), so a host re-serving it keeps the retryability. Removed:timestampand the top-levelstartSequence/startMessageCount/startUIMessageCount/runId/turn.GET /messages(queryoffset,limitup to 1000,includeThinking) returns{ messages, hasMore, total, sessionId }. A read failure is an untyped 500{ error: 'Internal server error' }.POST /submit-tool-resultanswers every failure typed, and writes nothing on a refusal: a submit for a run another entry superseded is 409{ error, code: 'state_run_superseded' }(errorsanitized); a result that fails the owning tool'soutputSchemais 400{ error, code: 'INVALID_RESULT', toolName, toolCallId }(a fixederror; the Zodissuesand the detailed message only withverboseValidationErrors, the same gate asvalidation_error'sdetails, both redacted with coresanitizeToolErrorTextsince they can echo the submitted value); an agent that cannot be resolved or materialized for the submit (its resolver, adapter or usage-store factory throws) is the entry table's answer (404framework_agent_not_found, 503 for a transient failure); a submit forwarded to the owning DO relays that DO's typed refusal verbatim. A sub-agent-owned call is validated by the DO that owns it./submit-tool-resultdoes not runbeforeEntry: authorise tool results and approval decisions in the worker in front of the DO.
DOStateStoreClient and DOPeerError
DOStateStoreClient (exported here and from @helix-agents/ai-sdk/cloudflare) is the front-end read view the chat helpers use against a DO namespace. It implements SnapshotViewSource:
readSnapshotView(sessionId)makes ONE/snapshotrequest (the DO's read is already consistent, so no client fence is needed).buildSnapshotprefers it.getMessagespagesGET /messages(an explicitlimitcapped at 1000; with nolimitit reads every page to the end as one consistent read, corereadMessagesToEnd, RM-01).getLatestCheckpoint/getCurrentRunreturn the real checkpoint and run the DO reports (with the run's realstartSequenceandturn, and itsrequestKey/requestDigestwhen the run's entry was keyed), ornullwhen there is none.handleChatStreamreads the current run's key to attach a keyed duplicate send.nullmeans "no such session" (a 404) and nothing else. Any other non-2xx answer throwsDOPeerError.
class DOPeerError extends HelixError {
constructor(
message: string,
code: ErrorCode, // the peer's code, else 'transport_error'
reason?: string, // the peer body's `cause`, e.g. 'corrupt_row'
status?: number // the HTTP status
); // always retryable
}A failed fetch or a response that fails the wire schema is a DOPeerError with transport_error. snapshotErrorResponse(error) (from @helix-agents/ai-sdk/cloudflare) re-serves it with its code and cause. DOPeerError is the read error of the store client and of DOStreamManagerClient's getStreamInfo / getChunks* (CP-28): the executors below never throw it.
DO client errors
DOFrontendExecutor (execute, resume, submitToolResult) reads a DO's typed answer with core peerErrorBodySchema() (never by matching the message) and throw the error a caller acts on:
| DO answer | Client error |
|---|---|
409 state_already_running | AgentAlreadyRunningError |
404 framework_agent_not_found | AgentResolutionError for the agent type the client sent (AgentResolutionError.fromNotFoundAnswer: the DO's message, no availableTypes) |
409 state_run_start_conflict with a known cause (core runStartRejectCauseSchema()) | RunStartRejectedError(sessionId, cause) |
409 state_request_already_completed with runId (D-2) | RequestAlreadyCompletedError(sessionId, runId) |
409 state_request_key_conflict with runId (D-2) | RequestKeyConflictError(sessionId, runId) (the run that holds the key) |
any other typed body (including 409 state_not_resumable) | core helixErrorFromPeerBody: a HelixError with the body's code (its detail keeps the code and cause), retryable when the body says so, else exactly on a 503 |
a body with no code (not JSON, unreadable, empty) | a transport_error (<route> answered <status> with an untyped body), retryable exactly on a 5xx, the body (≤ 512 chars) its cause; no domain code invented |
/submit-tool-result 409 state_run_superseded | RunSupersededError |
DOFrontendExecutor.submitToolResult parses a 200 body with core submitToolResultResponseSchema(), so its continuation is required (a body without it rejects with a ZodError). The DO's submit answers the continuation of its own wake: new_run { runId, startSequence? } when the wake started the run; deferred { blockingRunId } when a registered drive that is executing (its run running, or not yet committed; never one whose run has ended or suspended but has not settled) or a live run of the session carries the answer; consumed when another entry already drained it; else caller_resumes. A failed re-read after the wake is logged and answers caller_resumes, never consumed, except behind an executing drive registered on the DO (its run running or not yet committed; known without the reads): then deferred on that drive's run.
A duplicate submit (already_completed) whose stored answer is still pending answers the same way while the DO is running a wake (CP-198): it waits for that wake's entry outcome under the same bound as the first submit (concurrentEntryTimeoutMs) and answers what it did (new_run, the wake's typed failure, or the bound's 500), never caller_resumes, on which handleChatStream would call resume() into the wake's own drive and get the busy 409. A duplicate with no wake in flight on the DO (none was needed, or an eviction dropped it) answers the executor's continuation at once.
state_not_resumable stays a HelixError: the body carries only the message (a partial batch's message names the awaited call ids first), not the sessionStatus / awaitingToolCallIds of core ResumeRefusedError.
DOFrontendExecutor.resume(sessionId, options?) sends DoResumeOptions (default { mode: 'continue' }: continue, with_message, from_checkpoint); approvals go through /submit-tool-result.
The DO's entry routes answer an entry that did not start with a typed body (core typedErrorResponse: the httpStatusForHelixError status, the message scrubbed), never started (Entry wire answers). When the entry's run already committed (a failure after the run-start commit), the answer is never 503: a retryable error gets its non-retryable row (409 / 500), since re-sending would append the input twice.
Run start on the DO
The DO's own entries commit through DOStateStore.runStartCommitSync, inside the same transactionSync as the DOAB claim predicate, so the claim and the run-start commit are one step (ownerToken: null, ownerless asserted). The DO keeps no JS run lease and never bumps resumeCount. A failed entry is classified by core classifyEntryFailure: lost (another run owns the session, a CAS loss, state_not_resumable), refused (a typed gate refusal), transient (retryable, or unclassified) or fatal (classified non-retryable). A request drive answers it (Entry wire answers) and, before its run-start commit, writes nothing to a session that has a run; a brand-new session that never ran is recorded failed on the session only (no stream, no hook). A wake drive does nothing for lost, re-arms for transient (recording lastFailure on the crash-loop record) and ends the run typed for refused / fatal. A wake that finds a stranded running run recovers it under the same runId (JSAgentExecutor.recover), with no new run-start commit. A run whose K5 commit landed does not block the next entry: the entry's run-start commit finishes it, whether it reads running or suspended_* (R66).
Before that ladder, the wake reconciles a run whose owner is closed (no drive registered, the producer claim held) through core's planClosedOwnerReconciliation → reconcileClosedOwnerRun: a K5-landed run is finished and settled, a run whose suspension committed before its run status gets its suspended_* status and settles, an ended run's open stream is ended, and a run with nothing committed is re-driven (failed only at crash-loop exhaustion, with framework_auto_resume_exhausted). A permanent store error (core isPermanentStoreError) is classified fatal; DOStateStore rethrows one unwrapped, never inside DOStateError. See the Eviction Recovery ladder.
ensureExecutionContinues(options?: { onOutcome?: WakeOutcomeSink }) continues or recovers the DO's run (a wake drive). It resolves once that drive's entry outcome is known; with an execution live it records a pending wake instead and resolves at once. onOutcome receives the outcome once (the submit route answers from it):
import type { WakeOutcome, WakeOutcomeSink } from '@helix-agents/runtime-cloudflare/do';
type WakeOutcome =
| { kind: 'started' }
| { kind: 'skipped'; reason: string }
| { kind: 'failed'; cls: EntryFailureClass; detail: ErrorDetail };
type WakeOutcomeSink = (outcome: WakeOutcome) => void;The synchronous DOStateStore helpers (runStartCommitSync, appendCommitSync, saveStateSync, getCurrentRunSync, getRunSync, loadStateSync, ...) never run migrations, because a migration would open a nested transactionSync. Called before the store is initialised, they throw DOStoreNotInitializedError (not exported; match on error.name). Call an async store method or ensureInitialized() first; DOAB's onStart does.
Companion consult protocol
The DO's persistent companions (spec 2026-10-15; the runtime guide's How a parent learns its companion's outcome).
Routes.
| Route | Who calls it | Answer |
|---|---|---|
POST /companion/child-settled | a companion DO, on its parent DO | body { childSessionId, childAgentType, runId, parentToolCallId, status } (strict; status completed / failed / interrupted / suspended_client_tool): 202 { outcome: 'queued' } (a verified read is owed and the alarm armed), 409 { outcome: 'no_ref' }, 404 { outcome: 'no_session' }, 400 validation_error, 500 when the owed read could not be recorded or the alarm armed. Not in the worker handler's public allowlist. |
GET /subagent/:type/status | a parent DO (the verified read) | adds runId, runStatus, parentToolCallId and committedOutcome: 'terminal' (core RemoteStatusResponse); a committed outcome's output / error / errorDetail come from the committed session, sanitized; 503 when they cannot be read |
POST /subagent/:type/start / /resume | a parent DO | the body takes parentToolCallId (a non-empty string), recorded as the run's RunMetadata.parent |
The child posts the notice at the end of a drive's settle (a request drive or a wake drive); every other settle (a wake's terminal, a reconcile, a stop route, the stop deadline) defers it to its parent-notice alarm. It retries on no answer within 10 s, a transport failure, 5xx, 408, 425 and 429, with a 1 s doubling backoff capped at 5 min and no give-up by time. Any 2xx queued delivers; no_ref, no_session and any other status are given up (logged).
Alarm subscribers. Two subscribers join heartbeat, stop-deadline and execution-watchdog:
parent-notice(the child side): fires at the earliest owed notice's absolutenextAt(__helix_parent_notice_retry:<runId>records) and posts every due one.child-verify(the parent side): fires at the earliestsub_session_refs.verify_owed_atand runs every due verified read. A failed read re-arms with the same backoff (at least 1 s), and 8 consecutive failures ending in a 404 give up. It arms nothing when the DO has nosubAgentNamespace.
The alarm scheduler runs one more pass when a reschedule arrives while a pass is computing.
DO-SQLite migration 17. It adds four nullable columns to sub_session_refs, in the fresh DDL too:
consult_tool_call_id TEXT;child_run_id TEXT;outcome TEXT(JSON{ runId, status, output?, errorDetail? }, Zod-checked; a corrupt value reads as none and is logged);verify_owed_at INTEGER.
DO-SQLite migration 18 (MR B). Three more nullable sub_session_refs columns, in the fresh DDL too: held_prior TEXT (JSON HeldPriorResult: a persistent child's undelivered terminal outcome, held for the tool call that delivers it as priorResult; read with core readStoredHeldPrior, a corrupt value reads as none and is logged), outcome_generation INTEGER (bumped by every reopen: a non-live ref written live) and held_generation INTEGER (its value at the hold). They are behind the core SessionStateStore methods holdPriorResult / releasePriorResult, which every store implements (Postgres V19, D1 V23, Redis, memory).
DO-SQLite migration 20 (RM-19). A nullable state.workspace_ref TEXT column, in the fresh DDL too: SessionState.workspaceRef as JSON (read with core readStoredWorkspaceRef), so a workspace run woken after an eviction reopens its provider's workspace instead of a new one. (Migration 19 is reserved for the remote ref identity, CP-141.)
DO_SCHEMA_VERSION is 18. Since a fresh start is assumed, a ref written before migration 17 has no consult, so no notice or verified read ever records on it.
Entry outcomes and the bounded /start (MR B). Every child entry exchange (/start for a first spawn, a respawn or a continue; /resume for a sendMessage) is bounded (CHILD_START_TIMEOUT_MS / CHILD_RESUME_TIMEOUT_MS, 60 s; DOCompanionDispatchContext.startTimeoutMs / resumeTimeoutMs for tests) and mapped by one table (childEntryOutcome) onto core CompanionEntryOutcome: a 2xx RemoteStartResponse → started: true (recordChildRunStarted); any non-2xx whose envelope carries inputCommitted: true, a transport failure, the bound's abort or a non-conforming 2xx → child_start_unconfirmed, the consult KEPT; 409 ALREADY_RUNNING → child_busy (its resumed_companion_ref_terminated cause → child_terminated; an attach only on a replay of the ref's own consult); any other non-2xx → child_entry_failed + the typed errorDetail; each definite non-start restores the previous consult. The DO's /subagent rejections carry inputCommitted: true exactly when the entry's input committed (wire-answer.ts). An unconfirmed consult (child_run_id NULL, no outcome) is re-probed by the next same-name spawn, getChildStatus or sendMessage (reconcileRunningRef): confirmed by a live run of the consult, abandoned on proof it never started (a committed run of another consult, adopted through abandonUnconfirmedConsult), else left — a failed read, and since CP-161 fix 2 a 404 alone (P1 (a): the child's /start may still be in its gate), makes the spawn answer child_start_unconfirmed again without dispatching. Since fix 3 (rule 4b) the 404 is followed by the parent-ref fence (POST /companion/parent-ref-fence, DO-to-DO only, the generation on the internal header): the child answers exists (a run of the consult committed: kept owed) or, in its own transactionSync over the claim block's record (the two are mutually exclusive: each is one synchronous callback), records the next generation and answers fenced, after which the parent settles the consult failed (abandoned, delivered) and the name is free; the late /start is refused. companion__terminateChild of an unconfirmed consult fences first, too. Every companion /start carries the parent ref's generation on the DO-internal header x-helix-parent-ref-generation (PARENT_REF_GENERATION_HEADER, which the public createRemoteAgentWorkerHandler deletes, as it deletes the respawn header). The child DO refuses a /start below the highest generation it recorded (409 state_run_start_conflict, cause: 'stale_parent_ref_generation', nothing written) and records one only with that start's run-start commit, from the parent its session id derives from (and, once the child session exists, the parent it is stored with), monotonically (CP-161 fix 2, P1 (b)); since fix 3 (C2) the check and the record run inside the claim block's transactionSync, with runStartCommitSync, so a stale start never commits after a newer one. The parent side (CP-161, Greptile P1): the record is DO-wide and never cleared, and the child DO outlives its parent's respawn wipe, which deletes the parent's refs; so the parent's DOStateStore keeps a generation floor in the DO's KV (DOStateStoreOptions.kv, helix:companion:child-ref-generation-floor): wipeSessionSync raises it to max(the wiped refs' generations) + 1 first, in the same transaction, and addSubSessionRefs inserts a new ref at it and reports it ({ inserted: true, generation }, subSessionRefClaimGeneration()). A re-spawned parent's first spawn of a name is therefore never below a generation that child recorded, and a late start of its old incarnation is still refused. CP-161 fix 4: the companion tools prove the parent run (the tool context's runId) still owns the parent session before every child mutation (a superseded run's spawn makes no child request), and a replay of a respawn's reset attaches only to a child whose /status names its consult (else it starts fresh with the respawn header). A continue holds the child's undelivered outcome (holdPriorResult, before beginChildConsult) and delivers it as priorResult; the release is the notifier's held-prior pass at the next iteration (the runtime guide).
DOStateStore consult methods (DO only; they implement the internal ChildConsultStore interface, which is not exported; each is one transactionSync):
getChildConsult(parentSessionId, subSessionId): Promise<ChildConsult | null>;
beginChildConsult(parentSessionId, subSessionId, consult, fence: { parentRunId }): Promise<{ prior: PriorChildConsult }>;
restoreChildConsult(parentSessionId, subSessionId, consult, prior): Promise<'restored' | 'stale'>;
recordChildRunStarted(parentSessionId, subSessionId, consult, runId): Promise<'applied' | 'stale'>;
recordChildOutcome(parentSessionId, subSessionId, consult, outcome): Promise<RecordChildOutcomeResult>;
markChildOutcomeDelivered(parentSessionId, subSessionId, consult, runId): Promise<'marked' | 'stale'>;
abandonUnconfirmedConsult(parentSessionId, subSessionId, consult, word: { consult, outcome } | null): Promise<'abandoned' | 'stale'>; // MR B
setChildVerifyOwed(parentSessionId, subSessionId, at: number | null): Promise<boolean>;
setChildVerifyOwedIf(parentSessionId, subSessionId, expectedAt: number, next: number | null): Promise<boolean>;
listChildVerifyOwed(parentSessionId): Promise<ChildConsult[]>;beginChildConsultsets the consult, clears the run, the outcome and the owed read, and resetscompletion_delivered. It returns the prior values. Itsfenceis required: in the same transaction it first requiresfence.parentRunIdto be the parent session's current live run, else it throwsRunSupersededErrorand writes nothing (CP-161).restoreChildConsultputs all of them back, only while the ref tracks the new consult with no run and no outcome recorded.recordChildRunStartednever overwrites an outcome of that same run.recordChildOutcomeis a compare-and-set on the consult. Aterminatedref is sticky, and it never touchescompletion_delivered.markChildOutcomeDeliveredsets onlycompletion_delivered, for the recorded outcome ofrunId.abandonUnconfirmedConsult(MR B) is the unconfirmed re-probe's CAS: only while the consult is still unconfirmed (no run, no outcome, the ref notterminated), awordof another consult is adopted (its consult, run and outcome on the ref; the ref's status the word's,completed_atnull forinterrupted), marked delivered ONLY if the parent's committed message log already records that run's word (a notice keyed on it, or the held slot's carrying answer — read inside the sametransactionSync; never by text), else undelivered for the notifier;nullwrites the reffailed, delivered (a 404: the child holds no session). Not a reopen: the outcome generation and the held slot are untouched.
They are not on SessionStateStore (holdPriorResult / releasePriorResult are).
Logs. A companion with no subAgentNamespace warns companion.notice.no_namespace once per instance.
Durable Object Types
import type {
DurableObjectNamespace,
DurableObjectId,
DurableObjectStub,
SqlTaggedTemplate,
} from '@helix-agents/runtime-cloudflare/do';Cross-service exposure & transports
DOStubTransport reaches sibling DOs inside the same worker. To let another service consume a DO app as a remote agent — or to consume one from a Cloudflare worker — use the two APIs below.
createRemoteAgentWorkerHandler
Worker-level exposure for a DO producer. Bridges {basePath}/:agentType/* onto the DO's internal /subagent/:agentType/* routes, resolving the instance with namespace.idFromName(sessionId) (the same mechanics as DOStubTransport, lifted into a worker fetch handler).
import { createRemoteAgentWorkerHandler } from '@helix-agents/runtime-cloudflare/do';
interface RemoteAgentWorkerHandlerConfig<TEnv> {
/** DO namespace hosting the agent DOs (same binding the DOs use as `subAgentNamespace`). */
namespace: (env: TEnv) => SubAgentNamespace;
/** agentType allowlist. Default: no allowlist — every agentType is forwarded to the DO, which validates. Must include DESCENDANT types if consumers fetch nested baselines. */
agents?: string[];
/** Route prefix. Default: '/agents'. */
basePath?: string;
/** Gates ALL eight routes. `true` proceeds; `false` → 401 envelope; a `Response` short-circuits. */
auth?: (request: Request, env: TEnv) => boolean | Response | Promise<boolean | Response>;
/** Required to silence the no-auth warning. */
allowUnauthenticated?: boolean;
/** Logger for the no-auth warning. Default: `noopLogger` (the warning is silent without one). */
logger?: Logger;
/** Pure state mapper; its presence makes this a PROJECTED mount. */
projection?: (state: Record<string, unknown>) => Record<string, unknown>;
/** Usage mapper, applied to `/usage` rollups and `end.usage`. */
usageProjection?: (rollup: UsageRollup) => Record<string, unknown>;
/** The largest POST body read, in bytes (default 10 MB). Larger, declared or streamed: a 413 `PAYLOAD_TOO_LARGE` envelope. */
maxRequestBytes?: number;
/** #179: the hop to the DO is a CLIENT span parented on the inbound `traceparent`. Default: the global provider. */
telemetry?: TelemetryOptions;
}
// Returns a worker fetch handler:
const handler: (request: Request, env: Env) => Promise<Response> =
createRemoteAgentWorkerHandler<Env>(config);SubAgentNamespace — the namespace factory's return type — is re-exported from @helix-agents/runtime-cloudflare so you can type your own Env. It is the minimal structural slice of a DurableObjectNamespace the handler needs:
interface SubAgentNamespace {
idFromName(name: string): { toString(): string };
get(id: { toString(): string }): {
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
};
}Routes — eight, under {basePath}/:agentType/:
| Method | Action | sessionId source | Notes |
|---|---|---|---|
| POST | start | JSON body | ALREADY_RUNNING on a live session → consumers attach |
| POST | resume | JSON body | |
| GET | sse | query | exclusive ?fromSequence=; terminal truncated { floor } below the floor; wire v2 frames, always one terminal frame |
| GET | status | query | |
| GET | snapshot | query | protocol-shaped, checkpoint-pinned RemoteSnapshotResponse |
| GET | usage | query | forces includeSubAgents: true → RemoteUsageResponse |
| POST | interrupt | JSON body | |
| POST | abort | JSON body |
These eight actions are the exported RemoteAgentRouteAction union: 'start' | 'resume' | 'sse' | 'status' | 'snapshot' | 'usage' | 'interrupt' | 'abort'.
Failures before the request reaches a DO use the shared RemoteAgentErrorResponse envelope: 400 BAD_REQUEST (missing/invalid sessionId), 401 UNAUTHORIZED, 404 NOT_FOUND (no route, or a non-allowlisted agentType — deliberately not FORBIDDEN, so the mount never confirms which agent types exist), 405 METHOD_NOT_ALLOWED, 502 UPSTREAM_ERROR (DO stub unreachable).
Projection semantics. projection / usageProjection are pure mappers over point-in-time full states. On a projected mount every state_patch SSE frame is suppressed (root, descendants, and CustomStateStreamer frames alike), snapshot.state / end.state pass through projection, /usage + end.usage pass through usageProjection, and all other frames are forwarded byte-faithfully with their original id: sequences (so fromSequence resumption is unaffected).
Auth posture. Unlike AgentServer (fail-closed — its constructor throws), this handler is warn-and-serve: it logs once through the injected logger (default noopLogger, so the warning is silent unless you pass one) and proceeds, because worker handlers commonly sit behind private service bindings. Public mounts MUST set auth. Mount isolation is auth-only: idFromName is mount-agnostic, so a raw and a projected mount reach the same DOs — the fidelity split exists only at this layer.
Full narrative + two-mount worker example: Exposing agents to other services.
serviceBindingTransport
A RemoteAgentTransport over a Cloudflare service binding — zero-hop worker→DO, no public internet. It is a thin wrapper: it returns core's HttpRemoteAgentTransport with its injected fetch pointed at the binding and a synthetic internal base URL (service bindings ignore the authority; only the path reaches the peer worker).
import { serviceBindingTransport } from '@helix-agents/runtime-cloudflare/do';
interface ServiceBindingTransportConfig {
/** The producer worker's service binding (e.g. `env.PRODUCER`). */
binding: ServiceBindingFetcher;
/** Path prefix of the producer's mount. Default: '/agents'. */
basePath?: string;
/**
* agentType path segment. REQUIRED when the producer is a
* createRemoteAgentWorkerHandler mount (routes are `{basePath}/:agentType/...`).
* Omit only when a Node AgentServer sits behind the binding (agentType travels
* in the /start body there).
*/
agentType?: string;
/** Static headers or a per-request async header factory. */
headers?:
| Record<string, string>
| (() => Record<string, string> | Promise<Record<string, string>>);
}
const transport: RemoteAgentTransport = serviceBindingTransport({
binding: env.PRODUCER,
basePath: '/internal/agents',
agentType: 'planner',
});ServiceBindingFetcher (the binding type) is the minimal fetch slice of a Cloudflare service binding, re-exported from @helix-agents/runtime-cloudflare so you can type your own Env:
interface ServiceBindingFetcher {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}agentType is not optional against a worker-handler mount
Omitting it drops the :agentType segment, so getSnapshot() requests {basePath}/snapshot?..., which does not match the handler's ^{basePath}/([^/]+)/(snapshot|usage|…)$ route regex — and 404s. Fetching a grandchild's baseline needs that descendant's own agentType (the DO SQL-validates the path :agentType against the session's stored agent_type), and that type must also be in the producer mount's agents allowlist.
DOStubTransport
Sibling-DO transport (auto-wired when subAgentNamespace is configured). It now implements the full RemoteAgentTransport surface:
getSnapshot(sessionId)/getUsage(sessionId)— routed to the DO's/subagent/:agentType/snapshot|usagebridge handlers.- Zod response validation —
remoteStartResponseSchema(),remoteStatusResponseSchema(),remoteSnapshotResponseSchema(),remoteUsageResponseSchema()(previously raw casts). - Wire v2 reads —
stream()decodes the child's/ssewith coredecodeTransportEvents(chunk,end,fail,truncated { floor }); a body that ends with no terminal frame throws the retryableStreamDropError. - Entries sent once (CP-95) —
/startand/resumeare never re-sent. A thrownfetch, or a 5xx that is not the child's typed retryable answer, rejects with the non-retryableRemoteEntryOutcomeUnknownError(coreremoteEntryProtocolError); see Remote agents. - Typed errors — a non-2xx answer goes through core
remoteProtocolError, the same mappingHttpRemoteAgentTransportuses:ALREADY_RUNNING→RemoteAgentAlreadyRunningError,NOT_FOUND→RemoteAgentNotFoundError,FEATURE_UNAVAILABLE→RemoteAgentFeatureUnavailableError(consumers attach onALREADY_RUNNINGand degrade onFEATURE_UNAVAILABLE); any other envelope with anerrorCode, or any 503, → aHelixErrorwith that code andcause, retryable exactly on a 503; a typed{ error, code }body (agent-server's entry failures) →helixErrorFromPeerBody; anything else → a genericError.
Workflows Runtime API
The Workflows approach uses Cloudflare Workflows for durable, step-level execution with D1 for state.
CloudflareAgentExecutor
Executor that starts and manages Cloudflare Workflows.
import { CloudflareAgentExecutor } from '@helix-agents/runtime-cloudflare';
const executor = new CloudflareAgentExecutor({
workflowBinding, // AgentWorkflowBinding from env
stateStore, // StateStore (D1StateStore)
streamManager, // StreamManager (DurableObjectStreamManager)
agentRegistry, // agent configs by agentType
logger, // Optional
});
// Execute agent
const handle = await executor.execute(MyAgent, {
message: 'Hello',
state: { userId: 'user-123' },
});
// Get existing handle
const handle = await executor.getHandle(MyAgent, 'session-123');execute() on an existing completed, failed, interrupted or clean paused session starts a new turn:
The run-start commit decides. Before creating the instance it writes only a write-once
createSessionfor a new session; the instance's run-start commit picks the single winner, and a loser writes nothing. It passes the workflow body the turn'srunId.One instance per call. Each turn runs in its own write-once instance,
…__turn__<key>, wherekeyis minted by the call (resume / retry:__resume__<key>/__retry__<key>).Refusals. A session with a live run throws
AgentAlreadyRunningErrorwithout writing.Entry calls block until the entry has an outcome.
execute()/resume()/retry()return once the instance's run committed and wrote its entry state, or throw the typed loser error (AgentAlreadyRunningError/RunStartRejectedError). An entry whose instance fails before its run-start commit returns a handle whoseresult()isfailedwith the typederrorDetail(seepreCommitFailurebelow). With no outcome withinentryOutcomeFailSafeMs, the call abandons the entry (abandonRunStart, made up to 3 times with a 25 ms × attempt backoff, so a transient store error still lands the tombstone; a non-retryable error is not retried) and throws core'sRunStartAbandonedError(retryable), orRunStartOutcomeUnknownErrorwhen every abandon attempt failed and the run still cannot be found (read the session before retrying). A failed abandon leaves no tombstone, so the call re-reads the run first (3 attempts): a run it finds is handled as committed below, with no terminate. Otherwise it terminates the instance (the only fence against a late commit) and re-reads the run again: a run that committed before the terminate landed gets one start check at once (3 attempts, no grace) and getsRunStartStalledError:start: 'not_started','unknown'(its start cannot be read) or'started'(the terminate killed a run that had started: no handle, and the run is settled with that message, never left for a reader to record as cancelled from outside). When the terminate landed, that stall is settled assettlesays (below), or the call returns the handle of a run that already ended; when the terminate failed, a run that started gets its handle (it goes on), and otherwise it isownerStopped: falseand nothing is settled. A run that COMMITTED but has not started (norun_startedin its own stream, or no entry state) atentryOutcomeFailSafeMsgets the same bound again until its instance closes or is paused, measured from when the call first saw the commit (a transient owner read or abandon failure does not end it), so a real stall can take up to about twice the bound to surface. If it still has not started it never gets a handle: the instance is terminated, and the call throws core'sRunStartStalledError(transport_error), never retryable (its input is committed, so repeating the call would append it twice). Every outcome's message names the recovery the same way:executor.retry(agent, sessionId)(passoptions.messageif the run committed a step or its entry carried no user message of its own). The exception is an entry into a session PAUSED on stored answers (aresume(), e.g. one that adopted an owed__submit__continuation): when its run was proven not started its stall leaves the session paused (its run-start commit only re-stamped the answers, and the settle completes the suspension or writes nothing), whichretry()refuses, so its error carriesrecovery: 'resume'and namesexecutor.resume(agent, sessionId, { mode: 'continue' }); the next continuation drains the stored answers. Every other stall carriesrecovery: 'retry'. Without a message such a run'sretry()is refusedvalidation_error: astartUnknownrun may have committed a step before it was stopped, and aresume()incontinue/from_checkpointmode with no message commits only heal rows, so its run has no trigger to re-run. When the instance stopped (ownerStopped: true), the executor reconciles the run as a reader reconciles a closed owner's run (Closed owners, below), with the stall as the failure of a run that committed nothing, and the error'ssettleand message say what that did:'failed'(settled with that error's detail),'not_committed'(the write did not land, so a reader such asgetHandle()settles it before theretry()),'not_found'or'superseded'(a newer run had taken the session over). A run with an outcome of its own (it ended, its terminal commit landed — finished now, never overwritten — or its suspension is committed) is no stall: the call returns its handle. When the instance could not be stopped, nothing is settled (read the session first). Aterminate()that throws because the instance had just closed on its own (read back ascomplete,errored,terminatedor gone) counts as stopped; a stall then re-checks the start first, and a run that started gets its handle instead of a stall. A terminate that throws on a live instance, or whose status cannot be read, isownerStopped: false. On a committed run's start grace, the instance is only terminated on a stall the executor actually read: a failed read of the run, its own stream or the session is re-read, and a start that still cannot be read gets one more grace pass (the same bound again). If it is still unreadable then, the call throwsRunStartStalledErrorwithstartUnknown: trueandownerStopped: false: nothing is stopped or settled, the run may still proceed, and the case can take up to about three times the bound. Only the failed-abandon path above terminates before it can read a start, and thenstartUnknown: truemarks a start it could not confirm. An explicitentryStartTimeoutMsreplaces the fail-safe: the call returns the handle when it lapses, without abandoning or stalling (0disables the wait).Closed owners. Every handle's
result(),getHandle(),canResume()andabort()reconcile a run whose instance closed without its terminal commit, through coreplanClosedOwnerReconciliation/reconcileClosedOwnerRun, once the owner is proven closed (a failed probe reconciles nothing), with no lifecycle hook. The run's own stream never decides the outcome: a stream status that cannot be read is planned as absent, and a stream write that fails after the run-level write is logged (corereconcile.stream_unavailable) and repaired by a later reader. A transient store error leaves the reader answering from what is persisted; a non-retryable one is logged at error level and rethrown.- its terminal commit landed → the run is finished with that outcome and its own stream ended or failed;
- its suspension is committed (the session
paused, awaiting client calls or children) → the run readssuspended_*(written when it still readrunning), its ownactivestream is paused, andresult()answers{ status: 'suspended_*', runId, suspended }(toolCallIds,children,stepIdfrom the session); - nothing committed → the session, run and stream end
failed(terminated→framework_cancelled;errored→ the engine's message;complete/ gone →{ message }, except acompleteinstance whose output is its own validated failure keeps thaterrorDetail); - the run already ended but its own stream is still
active/paused→ the stream is ended or failed from the committed outcome.
abort()executes only the first case; it fails any other run, a suspended one included, with its own cancellation (framework_cancelledwith the caller's reason). A store call of that finish that fails rejects the abort with its own error, unless the run reads back as no longer live (the abort resolves). A failed result the instance did not return itself carries the session's persistederrorDetail. See Instances closed outside the product.A handle kept across
deleteSession.deleteSessionremoves the session's runs, so a closed instance with no run of its own is read against the session. With the session present the handle reports the entry'sclosedEntryFailureas before. With the session deleted,result()reports the instance's recorded outcome (acompletedoutput stayscompleted; an aborted run'scancelledoutput stays aframework_cancelledfailure), and an instance that recorded none (errored, terminated, or complete without a result) reports a typed, non-retryablestate_session_not_foundfailure.getHandle()for a deleted session returnsnull.preCommitFailure. A handle fromexecute()/resume()/retry()whose instance closed before its run-start commit carries the entry's typed failure ashandle.preCommitFailure(the detail itsresult()reports): the classified detail the instance returned, or, when the instance closed with no run and no failure output, one derived from the engine status (errored→ the engine's message, no code;terminated→framework_cancelled;complete→ a message naming what was observed, no code; each says the entry committed no run), which the handle'sresult()reports too. This holds only while the session exists: a session deleted during the entry wait (which removes its runs too) gets nopreCommitFailurewhen the instance recorded a non-failure outcome, else the instance's recorded failure (possibly a committed run's, since whether the entry committed can no longer be read) or a typed, non-retryablestate_session_not_found; a failed session read there keeps the wait going (loggedcfw.entry.session_read_failed). The instance's run read and failure write are retried by their steps (about 94 s at most for both: insideentryOutcomeFailSafeMswhen the entry's own steps fail fast; a hang in the entry's own steps can outlast it, and the call then throws the typed abandon / outcome-unknown error); when those retries run out, the entry's own detail is still returned. A session that never had a run is recordedfailed(session only: no stream write, no hook, R43); a session that has a run is never touched.getHandle()returns an active handle on whichever turn, resume or retry instance is running (found from the current run record, whoseownerTokenis the instance id), and a reconnect handle otherwise.Store check. The constructor throws if the store lacks
getCurrentRun,getRun,listRuns,updateRunStatus,commitStateorsetRunOwnerUnknownSince(assertCfwStoreSupportsRuns;AgentStepschecks the same).
terminateRun
executor.terminateRun(sessionId: string): Promise<void>;The admin escape for an operator-paused or otherwise stuck instance. It terminates the owner instance of the session's live run, which makes the run ownerless so the next entry supersedes it, and writes the killed run's run_terminated (reason: 'admin_terminate') to the session's stream (resuming a paused stream first). Once the terminate has landed, that stream write is best-effort: a failed stream read or write is logged (cfw.admin_terminate.stream_end_failed) and the call resolves; the next reader reconciles the run. A no-op when there is no live run with an owner instance, or when the owner already closed. It works on the real Workflows engine and in local dev on Miniflare 5, which implements terminate() (Miniflare 4 did not).
An owner instance that keeps reporting unknown is handled by the next entry: once the run's ownerUnknownSince is older than cfwUnknownTerminateAfterMs (default 10 minutes, a WorkflowOptions field), the entry terminates that instance, writes run_terminated (reason: 'owner_unknown_timeout') and takes the session over.
See Cloudflare Workflows → Multi-Turn Conversations.
CloudflareAgentExecutorConfig
interface CloudflareAgentExecutorConfig {
workflowBinding: WorkflowBinding;
stateStore: SessionStateStore;
streamManager: StreamManager;
usageStore?: UsageStore;
/** Agent configs by agentType (submit-side outputSchema validation, tombstone retention). */
agentRegistry:
| Map<string, AgentConfig>
| Record<string, AgentConfig>
| ((agentType: string) => AgentConfig | undefined);
logger?: Logger;
/**
* How long an entry call waits for its instance's run-start outcome. Default: until the
* outcome, bounded by entryOutcomeFailSafeMs. `0` disables the wait. The wait polls one
* `getRun` read plus one instance `status()` with backoff (25 ms doubling to 250 ms).
*/
entryStartTimeoutMs?: number;
/**
* Fail-safe bound on the entry wait. Default 120 000 ms. No run-start commit within it: the
* entry is abandoned (RunStartAbandonedError, or RunStartOutcomeUnknownError when the abandon
* failed and the run still cannot be found; a failed abandon terminates the instance, since it
* leaves no tombstone). Committed but not started: the same bound again from when the call
* first saw the commit, until the instance closes or is paused; then RunStartStalledError
* (never retryable; recover with executor.retry(agent, sessionId), with options.message if the
* run committed a step or its entry carried no user message of its own). A real stall can take up to
* about twice the bound to surface. On that path a start that cannot be read is never treated
* as a stall: one more grace pass, then RunStartStalledError with startUnknown (ownerStopped
* false, nothing settled). A run found only after a failed abandon's terminate, whose start
* cannot be read, is settled with start 'unknown', and one that had started with start
* 'started' (ownerStopped true: this call killed it, so it gets no handle).
*/
entryOutcomeFailSafeMs?: number;
/**
* How long execute() / resume() / retry() wait for the session's previous instance to
* finish its terminal tail before refusing with AgentAlreadyRunningError.
* Default 30 000 ms. Read-only.
*/
priorInstanceSettleTimeoutMs?: number;
/**
* W1 MR2b: the bound on a `__submit__` continuation's in-body wait for the suspended run's
* instance to close. Default 600 000 ms (10 min). At it the
* predecessor is terminated (its remaining onAgentSuspended work is cut off; up to 3
* attempts) and the suspended run reconciled; an unstoppable predecessor ends the
* continuation without committing, and the next ensure uses the next of its 3
* generations.
*/
continuationPredecessorTimeoutMs?: number;
}The executor declares supportsRequestKey: false (D-2): it does not record request keys yet, so a requestKey passed to execute() / resume() / retry() is ignored (the entry runs keyless), with one request_key.ignored warning per executor instance. The executor declares supportsAbandonOnResume and implements reconcileSession(sessionId) (both read by @helix-agents/ai-sdk's handleChatStream; reconcileSession also by buildSnapshot). See Client-tool submit continuation.
listSubmitContinuations(sessionId, suspendedRunId): Promise<SubmitContinuationGeneration[]> is a diagnostics read: every __submit__ generation (1 to SUBMIT_CONTINUATION_GENERATIONS, 3) of a suspended run, each with its instance id, the run id its run-start commit uses, the engine's view of the instance ({ kind: 'present', status }, { kind: 'absent' } when the binding does not know the id, or { kind: 'unreadable', error } when the read failed) and its own run record (null until it commits its run start). It tells a continuation that was never created from one that was created but has not committed, or committed but is slow. It never creates, adopts or settles anything; a missing session is a typed state_session_not_found.
Step-result cap
Every step.do result is measured against CFW_MAX_STEP_RESULT_BYTES (1 MiB, Cloudflare's "Maximum non-stream step result per step") before the step returns, with stepResultSize(result) ({ bytes, complete, notStorable }, a conservative bound of the engine's V8 structured serialization; bytes is always a finite integer). A tool, sub-agent or companion result over it becomes a typed failed result and the run continues; a model reply or any other step result over it fails the run state_size_exceeded, with no step.do retry. The executor also refuses at construction (typed validation_error) a submitToolResultSchema whose maxResultBytes is over the store's finite limits.maxStateBytes. See Step results and state size.
AgentExecutionHandle
Handle returned by execute() for interacting with running workflows.
// Run ID and stream ID
handle.sessionId;
handle.streamId;
// Stream events
const stream = await handle.stream();
for await (const chunk of stream) {
switch (chunk.type) {
case 'text_delta':
console.log(chunk.delta);
break;
case 'run_interrupted':
console.log(`Interrupted: ${chunk.reason}`);
break;
}
}
// Wait for result
const result = await handle.result();
// Abort execution (HARD stop - cannot resume). Writes the terminal commit
// (failed, framework_cancelled); rejects with a retryable transport_error when
// that write fails (nothing written, call again).
await handle.abort('User cancelled');
// Interrupt execution (SOFT stop - can resume). Resolves once the stop is
// recorded (a paused run is ended at once); a run that already ended resolves
// too. The run's end is observed through result() or the stream.
await handle.interrupt('user_requested');
// Check if resumable
const { canResume, reason } = await handle.canResume();
// Resume execution
const newHandle = await handle.resume();
// Resume with message
const newHandle = await handle.resume({
mode: 'with_message',
message: 'Continue with this context',
});
// Time-travel resume
const newHandle = await handle.resume({
mode: 'from_checkpoint',
checkpointId: 'cpv1-...',
});
// Get current state
const state = await handle.getState();
// Continue conversation
const handle2 = await handle.send('Follow up message');On CF Workflows:
handle.resume(options)runsexecutor.resume(agent, sessionId, options), so a resume after an interrupt reactivates the stream the interrupt ended (CP-67).submitToolResult()for the batch's last answer starts the turn's continuation itself (a__submit__instance) and answerscontinuation: { kind: 'new_run', runId, startSequence? };resume({ mode: 'continue' })adopts that instance. Awith_messageresume on a session owed it waits for it and becomes the next turn;with_message+abandonPendingis one entry. See Client-tool submit continuation.The recorded stop wins (WA MR2 §7):
submitToolResult()for a call of a stopped run answers{ status: 'unknown_tool_call', reason: 'run_stopped' }(nothing stored, no__submit__); a stopped run is never owed a continuation, and a__submit__instance never commits over a run its stop ended (the silent C3 loser);resume({ mode: 'continue' })over a live run with a recorded stop returns the stopped run's handle before any__submit__adoption, andwith_messageover it is a new turn (Ruling 57). So isexecute()over a paused run with a recorded stop (Ruling 57a, CP-244): it waits for a suspended instance still in its tail, settles the run by its stop, then starts the turn. Without a stop it is still refused (AgentAlreadyRunningError).continue/with_messageon acompletedorfailedsession throwAgentNotResumableError(typed, non-retryablestate_not_resumable).result()of a run whose instance closed after its suspension commit answers the suspension ({ status: 'suspended_*', runId, suspended },runIdthe run's own id) instead offailed, andcanResume()is true; a reconnect handle (fromgetHandle()) reconciles only its own run. See Closed owners above.continue/with_messageon anactivesession with no pending calls (its instance is running) throwAgentAlreadyRunningErrorbefore any write. They used to truncate and create a second instance.Concurrent
from_checkpointresumes give at most one rewind and one run: the rewind happens inside the winning instance's run-start commit (truncateTo), so a loser rewinds nothing and writes nothing. The winner'sstream_resync(reason: 'rollback') carries the truncate checkpoint id.The winner of a resume or retry bumps
resumeCountinside its run-start commit.A run superseded by a newer entry stops with
RunSupersededError(thrown as aNonRetryableError), its result isstatus: 'superseded',onAgentSupersededfires, and neitheronAgentCompletenoronAgentFaildoes (one exception: a supersede that lands afteronAgentCompletefired and before the run's terminal commit leavesonAgentCompletethenonAgentSuperseded; the last terminal hook is authoritative).
The Durable Object runtime has runtime-js semantics.
runAgentWorkflow
Core workflow function for Cloudflare Workflows.
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
import { runAgentWorkflow } from '@helix-agents/runtime-cloudflare';
import { createCloudflareStore } from '@helix-agents/store-cloudflare';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
export class AgentWorkflow extends WorkflowEntrypoint<Env, AgentWorkflowInput> {
async run(event: WorkflowEvent<AgentWorkflowInput>, step: WorkflowStep) {
const { stateStore, streamManager } = createCloudflareStore({
db: this.env.AGENT_DB,
streams: this.env.STREAMS,
});
return runAgentWorkflow(event, step, {
stateStore,
streamManager,
llmAdapter: new VercelAIAdapter(),
registry,
workflowBinding: this.env.AGENT_WORKFLOW,
});
}
}AgentSteps
Step implementations for manual workflow control.
import { AgentSteps } from '@helix-agents/runtime-cloudflare';
const steps = new AgentSteps(
registry,
stateStore,
streamManager,
llmAdapter,
logger, // optional
hookManager, // optional
usageStore // optional
);
// Use in workflow (each step input carries the executing run's `runId`)
await step.do('run-start', () => steps.startRunCommit(/* ... */));
await step.do('step-1', () => steps.executeAgentStep({ sessionId, runId }));
await step.do('tool-1', () => steps.executeToolCall({ sessionId, runId, stepId, toolCall }));createWorkflowRunner
Alternative factory for creating workflow runners.
import { createWorkflowRunner } from '@helix-agents/runtime-cloudflare';
const runner = createWorkflowRunner({
stateStore,
streamManager,
llmAdapter,
registry,
workflowBinding,
});
// Use in workflow
return runner.run(event, step);Binding Types
Types for Cloudflare bindings.
import type {
AgentWorkflowBinding,
WorkflowBinding,
WorkflowInstance,
WorkflowStatus,
WorkflowDependencies,
WorkflowOptions,
} from '@helix-agents/runtime-cloudflare';
interface WorkflowInstance<TResult = unknown> {
id: string;
status(): Promise<WorkflowStatus<TResult>>;
abort(): Promise<void>;
/** REQUIRED (breaking): the unknown-owner escape hatch and terminateRun() call it. */
terminate(): Promise<void>;
// sendEvent(...) ...
}
interface WorkflowStatus<TResult = unknown> {
/** `waiting` and `waitingForPause` are live; `unknown` means the status could not be read. */
status:
| 'queued'
| 'running'
| 'complete'
| 'errored'
| 'terminated'
| 'paused'
| 'waiting'
| 'waitingForPause'
| 'unknown';
output?: TResult;
error?: WorkflowError;
}
interface WorkflowOptions {
log?: Logger;
llmStepTimeout?: string;
llmStepRetry?: WorkflowStepRetryConfig;
/**
* How long the body waits in all for a child's completion event (ephemeral sub-agents, a
* blocking companion spawn, `companion__waitForResult` with no caller `timeout`). Default
* `CFW_DEFAULT_SUB_AGENT_EVENT_TIMEOUT_MS` (60 000 ms). The wait parks in `step.waitForEvent`
* slices of at most `CFW_WAIT_SLICE_MS` (30 000 ms).
*/
subAgentEventTimeout?: number;
/**
* How often a running step reads its run's stop record, so a stop aborts the in-flight model
* call, tool or hook (`abortSignal`) instead of waiting for the next step boundary (CP-16).
* Default `CFW_DEFAULT_STOP_POLL_INTERVAL_MS` (500 ms). Each read is one indexed store read
* while the step runs (about 120 for a 60 s model call at the default), and none is made once
* the step ends.
*/
stopPollIntervalMs?: number;
/** How long an owner may report `unknown` before the next entry terminates it. Default 10 min. */
cfwUnknownTerminateAfterMs?: number;
/** Bound on an entry waiting for a suspending owner instance to close. Default 30 s. */
ownerWaitTimeoutMs?: number;
/**
* CP-105 (W1 MR2b): how long a parent polls, in its body, for a fire-and-forget companion
* child's run-start commit (a `sendMessage` resume / continue, and since round 12 of
* FU-WAIT-TIMEOUT-STATUS-TWO-SHAPES a non-blocking `spawnAgent` continue) before it reports
* { delivered: false, reason: 'child_start_unconfirmed' }. Default 60 000 ms. CP-161 fix 4:
* every such instance's reopen is fenced on the parent run (`companionRefExpect.parentRunId`),
* so a slow instance whose run start lands after the parent's run has ended (or was
* superseded) is refused `parent_run_superseded` and never runs: an unconfirmed message is
* then not delivered at all, rather than delivered late to a turn that already moved on.
*/
companionEntryOutcomeTimeoutMs?: number;
}
// Define your environment
interface Env {
AGENT_DB: D1Database;
STREAMS: DurableObjectNamespace;
AGENT_WORKFLOW: AgentWorkflowBinding;
OPENAI_API_KEY: string;
}Instance ids and event types
Cloudflare accepts an instance id, or a waitForEvent / sendEvent event type, only if it is at most 100 characters of [A-Za-z0-9_-] (first character not -). The runtime builds every one with these helpers. An instance id keeps its readable form when it is valid and every part is free of __ (and of a leading / trailing _); an event type keeps it whenever it is valid. Otherwise both use h_<SHA-256 of the parts>_<readable tail>. Both are deterministic and injective: distinct parts never share an id.
import {
buildWorkflowInstanceId,
buildWorkflowEventType,
logicalWorkflowInstanceId,
isValidWorkflowInstanceId,
MAX_WORKFLOW_INSTANCE_ID_LENGTH, // 100
} from '@helix-agents/runtime-cloudflare';
await buildWorkflowInstanceId({ agentType: 'chat', sessionId: 's1' });
// 'agent__chat__s1'
await buildWorkflowInstanceId({ agentType: 'chat', sessionId: 's1', kind: 'turn', key: '2' });
// 'agent__chat__s1__turn__2'
await buildWorkflowInstanceId({ agentType: 'chat', sessionId: 'user.name@x:1' });
// 'h_<32 hex>_agent__chat__user-name-x-1'
await buildWorkflowEventType('sub-agent-complete', childSessionId);interface WorkflowInstanceIdParts {
agentType: string; // registered agent name
sessionId: string; // the session the instance runs
kind?: string; // 'turn' | 'resume' | 'retry' | 'submit' | 'rewind' | 'respawn' | 'continue' | ...
key?: string; // explicit per-instance key (a per-call key, a parent-run suffix, `${step}-${callId}`, ...)
}kind and key must be given together, or the call throws.
Event-wait bounds
import {
CFW_WAIT_SLICE_MS, // 30_000
CFW_DEFAULT_SUB_AGENT_EVENT_TIMEOUT_MS, // 60_000
CFW_WAIT_INSTANT_REJECTIONS_BEFORE_FLOOR, // 5
CFW_WAIT_FLOOR_MS, // 1_000
CFW_WAIT_FLOOR_MAX_MS, // 30_000 (= CFW_WAIT_SLICE_MS)
CFW_DEFAULT_STOP_POLL_INTERVAL_MS, // 500 (WorkflowOptions.stopPollIntervalMs, CP-16)
} from '@helix-agents/runtime-cloudflare';CFW_WAIT_SLICE_MS: the longest singlestep.waitForEventslice the workflow body parks on. Every completion wait (ephemeral sub-agents, a blocking companion spawn,companion__waitForResult) parks in consecutive slices of at most 30 s.CFW_DEFAULT_SUB_AGENT_EVENT_TIMEOUT_MS: the default ofWorkflowOptions.subAgentEventTimeout, the whole bound of such a wait (60 s). Acompanion__waitForResultwith a callertimeoutparks for at mostmin(timeout, subAgentEventTimeout).- A park ends exactly when its step-recorded time reaches its persisted deadline (no slice count), and listens again at once after any early slice end. The spin guard: after
CFW_WAIT_INSTANT_REJECTIONS_BEFORE_FLOORfast rejections (an iteration, consult included, shorter thanCFW_WAIT_FLOOR_MSby step-recorded time; a DROPPED event, one naming no consult or another consult, a stale wake-up or a failed stop read, counts as a rejection; nothing resets the count within a park, a slow end keeps it), each further fast rejection waits a floor before the next listen:CFW_WAIT_FLOOR_MS, doubled after each furtherCFW_WAIT_INSTANT_REJECTIONS_BEFORE_FLOOR, capped atCFW_WAIT_FLOOR_MAX_MS, never past the deadline (a floor that would reach it is skipped once, for a final listen). Every iteration then lasts at least 1 s, except that one final-window listen. After every floor, after every rejected slice (every park, includingcompanion__waitForResult), after every dropped event (every park), and as its last step before a timeout answer, the park reads the child's persisted state, so a child that finished meanwhile is answered with its outcome whether or not its completion event arrives (a longer floor only delays that answer by at most the floor; engine buffering of an event sent during a floor is pinned on Miniflare only, and not relied on). Every park fails closed on the consult (CP-126): it accepts only a completion event naming the consult it waits on, and drops one naming no consult (one warning per park) or another consult.companion__waitForResultwaits on the consult this workflow instance started last for the child (its spawn, continue, resume or CP-82 respawn; the newest wins) and drops any other event (an earlier run's or incarnation's buffered completion, or any event when this instance started none), reading the child's persisted state after each drop. It also answers a terminal ref (before it parks, and at its deadline) only when the child's current run serves that consult; an older consult's terminal ref means "not finished yet", and a timeout then reports the ref's true status. A detached continue (sendMessage, or a non-blockingspawnAgentcontinue) or a resume whose entry definitely never starts (child_busy,child_terminated,child_entry_failed) restores the previous consult, so the child's real outcome answers (a blockingspawnAgentcontinue does not yet: FU-CFW-BLOCKING-SPAWN-CONTINUE-RESTORE). A non-blockingspawnAgentcontinue returns only once its run has committed ({ name, status: 'spawned', delivered: true }), else{ name, status: 'not_spawned', delivered: false, reason, errorDetail? }— coreCompanionSpawnResult, built from the entry poll'sCompanionEntryOutcome(companionSpawnResultFromEntry; asendMessagecontinue throughcompanionDeliveryFromEntry). A continue over acompletedchild whose result the model has not seen HOLDS it (holdPriorResultin the continue dep) and every row its record step writes carries it aspriorResult(the answer, or{ error, priorResult }on a failed row); the record step releases it after itsappendCommitted, and the per-turn notifier's held-prior pass covers a crash on either side. A FIRST spawn (and a respawn) is polled the same way (CP-161): after creating the__spawn__instance the body polls its child's exact run id (${instanceId}:${agentType}-run-start:0, boundcompanionEntryOutcomeTimeoutMs) and answers throughcompanionSpawnResultFromEntry—spawnedonly once that run committed, else a typednot_spawned, blocking or not (a blocking non-start is answered before any park). The companion step runs before the child exists, so its spawn dep only captures the entry's kind (CompanionBlockingDescriptor.spawnEntry): the body applies core's non-start ref write (companionSpawnNonStartRefUpdate:failed, delivered, for a definite first-spawn non-start; nothing for achild_busyor an unconfirmed start; a respawn's exact restore), as the RELEASE of the entry's own claim or reset: a CAS on its generation (releaseSpawnClaim, CP-161 fix 2), reading nothing of the child. A spawn that lost the claim is answeredchild_busyby the companion step itself, and no instance is created. The body records the usage pointer for every started spawn entry, a first spawn, its same-id replay or a respawn (CP-212;AgentSteps.recordCompanionSpawnUsage, one step keyed on the call id; core's helper keeps it to one per child session). Every entry-poll probe reads the parent's own stop record first: a stop during a slow child entry ends the poll at its next probe the way the park's own stop ends (the stop reaches the child; the call writes no ref and no answer row), for the first spawn, the continues and the resume. Once the child started, the blocking park (first spawn, respawn, continue) answers core's typed arm (companionSpawnResultFromTerminal:completedwithoutput,failedwitherrorDetail,interruptedwith its guidance, each with the heldpriorResult), a successful row, as runtime-js and the CF DO do; the park's timeout keeps its documented error row (Timeout waiting for child agent, FU-WAIT-TIMEOUT-STATUS-TWO-SHAPES). - Step cost and limits. A healthy park costs about 3 steps per 30 s slice. A park whose every listen is rejected at once costs, at worst (total /
step.do, from the code as built): 60 s: 92 / 49 (waitForResult, blocking spawn or continuation), 116 / 49 (ephemeral); 10 min: 176 / 91, 221 / 91; 1 h: 576 / 291, 721 / 291. Limits: Miniflare'sDEFAULT_STEP_LIMIT = 1e4(STEP_LIMITto override) counts onlystep.do, per workflow INSTANCE, so a park shares it with every other step of the turn. Wrangler 4.141.0 warns above "the production maximum of 25,000" forworkflows[].limits.steps(wrangler-dist/cli.js:21903-21905,config-schema.json:3913-3916). The production default, and which step kinds count, are undocumented.
Workflow DTOs
Type-safe schemas for workflow inputs/outputs.
Session vs Run Identifiers
sessionId: Primary key for state storage. A session contains all messages, state, and checkpoints.runId: Identifies a specific workflow execution. Multiple runs can occur within one session.
In Cloudflare, each Durable Object or Workflow instance is tied to a sessionId. On Workflows the run id embeds the instance id: ${instanceId}:${agentType}-run-start:0, passed by the executor as AgentWorkflowInput.runId (a sub-agent or direct create() instance derives the same id from event.instanceId). Every step input (ExecuteAgentStepInput, ExecuteToolCallInput, ...) requires the executing run's runId: each write is fenced on it and each chunk stamped with it.
agentWorkflowInputSchema() is a refined schema: an input with companionRef: true (set on the __resume__ instance a parent's companion__sendMessage creates for an interrupted persistent companion, and on the __continue__ instance that continues a completed one; its run-start commit carries StartRun.resumedCompanionRef) must name its parentSessionId, or the parse fails. Renamed from companionResume (W1 MR2b): zod strips the old key, so an instance created before the deploy whose run-start had not run yet writes no ref. The other W1 MR2b fields of agentWorkflowInputFieldsSchema() are set only by the executor on a resume instance:
// W1 MR2b additions to agentWorkflowInputFieldsSchema() (all optional)
awaitPredecessor?: { instanceId: string; timeoutMs: number }; // in-body wait for the suspended instance (__submit__, abandon entry)
resumeTrigger?: { kind: 'owed_continuation' }; // the __submit__ entry: a continuation the store owes, never an explicit resume
abandonPending?: true; // with_message + abandonPending (D11)
inputAfterDrain?: true; // the with_message rows are appended after the drain
companionRef?: boolean; // was companionResume; requires parentSessionIdzod refuses .pick() / .omit() on a refined object schema, so derive a schema from agentWorkflowInputFieldsSchema() (the same fields, no cross-field rule); .extend() and parsing work on either.
import {
// Workflow DTOs
agentWorkflowInputSchema,
agentWorkflowInputFieldsSchema, // the plain fields: use it for .pick() / .omit()
agentWorkflowResultSchema,
type AgentWorkflowInput,
type AgentWorkflowResult,
// Step DTOs
initializeAgentInputSchema,
executeAgentStepInputSchema,
executeToolCallInputSchema,
toolCallResultSchema,
// Sub-session DTOs (v7 — was Sub-Agent in v6)
registerSubSessionsInputSchema,
recordSubSessionResultInputSchema,
markAgentFailedInputSchema,
// Stream DTOs
endAgentStreamInputSchema,
failAgentStreamInputSchema,
} from '@helix-agents/runtime-cloudflare';endAgentStreamInputSchema()
Controls how the agent stream is finalized. Used by the workflow to fire lifecycle hooks and close the stream.
const endAgentStreamInputSchema = lazySchema(() =>
z.object({
sessionId: z.string().min(1),
startedAt: z.number().optional(),
finishWithOutput: z.unknown().optional(),
skipStreamClose: z.boolean().optional(),
})
);| Field | Type | Description |
|---|---|---|
sessionId | string | Session to finalize |
startedAt | number? | Start timestamp for duration calculation |
finishWithOutput | unknown? | When set, promotes the agent state to completed with this output before firing onAgentComplete. Used by the finishWith tool path where state isn't already completed. |
skipStreamClose | boolean? | When true, fires lifecycle hooks without closing the stream. Used for sub-agents that share the parent's stream. |
Workflow Types
import type {
WorkflowStep,
WorkflowEvent,
WorkflowDuration,
WorkflowRetryConfig,
WorkflowStepConfig,
WaitForEventOptions,
WaitForEventResult,
SubAgentCompletionEvent,
} from '@helix-agents/runtime-cloudflare';Shared Utilities
pollUntil
Poll until a condition is met.
import { pollUntil } from '@helix-agents/runtime-cloudflare';
const result = await pollUntil({
check: async () => {
const status = await getStatus();
if (status === 'done') {
return { done: true, value: status };
}
return { done: false };
},
interval: 1000,
timeout: 60000,
});PollOptions
interface PollOptions<T> {
check: () => Promise<PollCheckResult<T>>;
interval?: number;
timeout?: number;
}
type PollCheckResult<T> = { done: true; value: T } | { done: false };mapStatusToRemoteProtocol
Maps a DO session status to the remote-agent protocol's RemoteStatusResponse['status']: active / running → running, completed, interrupted and paused unchanged, anything else (failed, not_found, an unknown value) → failed. Exported from the main entry and /do.
import { mapStatusToRemoteProtocol } from '@helix-agents/runtime-cloudflare/do';
mapStatusToRemoteProtocol('active'); // 'running'Complete Examples
Durable Objects Example (Composition API)
wrangler.toml
name = "my-agent-do"
main = "src/worker.ts"
compatibility_date = "2024-01-01"
# Required for streaming: without it Cloudflare never reports a client
# disconnect, so an abandoned /sse stream stays open until the run ends.
compatibility_flags = ["enable_request_signal"]
[[durable_objects.bindings]]
name = "AGENTS"
class_name = "MyAgentServer"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyAgentServer"]agent-server.ts
import { createAgentServer, AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { createAnthropic } from '@ai-sdk/anthropic';
import { myAgent } from './agent.js';
interface Env {
AGENTS: DurableObjectNamespace;
ANTHROPIC_API_KEY: string;
}
const registry = new AgentRegistry();
registry.register(myAgent);
export const MyAgentServer = createAgentServer<Env>({
llmAdapter: ({ env }) =>
new VercelAIAdapter({
anthropic: createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }),
}),
agents: registry,
// A new message stops the run this DO is executing, then starts.
concurrentEntry: 'interrupt_previous',
hooks: {
afterEntry: async ({ sessionId, runId, drive }) => {
console.log(`Entered ${drive}`, { sessionId, runId });
},
},
});worker.ts
import { MyAgentServer } from './agent-server.js';
import type { Env } from './agent-server.js';
export { MyAgentServer };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/execute' && request.method === 'POST') {
const { message } = await request.json<{ message: string }>();
const sessionId = crypto.randomUUID();
const doId = env.AGENTS.idFromName(`session:${sessionId}`);
const stub = env.AGENTS.get(doId);
const response = await stub.fetch('/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentType: 'researcher',
input: { message },
sessionId,
}),
});
const result = await response.json<{ sessionId: string; streamId: string }>();
return Response.json({
sessionId: result.sessionId,
streamUrl: `/stream/${result.sessionId}`,
websocketUrl: `/ws/${result.sessionId}`,
});
}
if (url.pathname.startsWith('/stream/')) {
const sessionId = url.pathname.split('/').pop()!;
const doId = env.AGENTS.idFromName(`session:${sessionId}`);
// Forward the client's signal so the DO's /sse closes when the client leaves.
return env.AGENTS.get(doId).fetch(new URL('/sse', request.url), { signal: request.signal });
}
if (url.pathname.startsWith('/ws/')) {
const sessionId = url.pathname.split('/').pop()!;
const doId = env.AGENTS.idFromName(`session:${sessionId}`);
return env.AGENTS.get(doId).fetch(request);
}
return new Response('Not found', { status: 404 });
},
};Workflows Example
wrangler.toml
name = "my-agent-worker"
main = "src/worker.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "AGENT_DB"
database_name = "agents-db"
database_id = "xxx"
[[durable_objects.bindings]]
name = "STREAMS"
class_name = "StreamServer"
[[workflows]]
name = "agent-workflow"
binding = "AGENT_WORKFLOW"
class_name = "AgentWorkflow"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["StreamServer"]worker.ts
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from 'cloudflare:workers';
import {
runAgentWorkflow,
AgentRegistry,
CloudflareAgentExecutor,
type AgentWorkflowInput,
} from '@helix-agents/runtime-cloudflare';
import { createCloudflareStore, StreamServer } from '@helix-agents/store-cloudflare';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { MyAgent } from './agent.js';
export { StreamServer };
const registry = new AgentRegistry();
registry.register(MyAgent);
export class AgentWorkflow extends WorkflowEntrypoint<Env, AgentWorkflowInput> {
async run(event: WorkflowEvent<AgentWorkflowInput>, step: WorkflowStep) {
const { stateStore, streamManager } = createCloudflareStore({
db: this.env.AGENT_DB,
streams: this.env.STREAMS,
});
return runAgentWorkflow(event, step, {
stateStore,
streamManager,
llmAdapter: new VercelAIAdapter(),
registry,
workflowBinding: this.env.AGENT_WORKFLOW,
});
}
}
export default {
async fetch(request: Request, env: Env) {
const { stateStore, streamManager } = createCloudflareStore({
db: env.AGENT_DB,
streams: env.STREAMS,
});
const executor = new CloudflareAgentExecutor({
workflowBinding: env.AGENT_WORKFLOW,
stateStore,
streamManager,
});
if (request.method === 'POST') {
const { message } = await request.json();
const handle = await executor.execute(MyAgent, { message });
return Response.json({ sessionId: handle.sessionId });
}
return Response.json({ error: 'Not found' }, { status: 404 });
},
};Interrupt and Resume
Both runtimes support interrupt/resume for pausing and continuing agent execution.
Workflows Interrupt
A stop is recorded on the run (core stopRun), and an open instance is woken with an interrupt event; the workflow reads the run's stop record at every step boundary and ends the run by its kind. It also hears the stop mid-step and while parked:
- Inside a step (an event never reaches a running
step.do), the record is read everystopPollIntervalMs(default 500 ms) while the work runs. A stop aborts the in-flight model call (generateStep'sabortSignal), a tool's or hook'sabortSignal, or a remote sub-agent stream. An aborted model attempt is discarded, never retried (CP-16). - While parked, a wait on a sub-agent, a blocking companion spawn,
companion__waitForResultor a blocking continue races the run's interrupt event, and reads the record at every consult. A stop ends the run and is propagated to the child it waits on (CP-23). A paused run, or one whose instance has closed, is ended by the call itself.abort()also terminates the instance and settles the run before it resolves.
// Interrupt from handle
await handle.interrupt('user_requested');
// Resume later
const { canResume } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
const result = await resumed.result();
}DO Interrupt
Interrupts use HTTP endpoints:
// Interrupt via fetch
await fetch(`/interrupt`, {
method: 'POST',
body: JSON.stringify({ reason: 'user_requested' }),
});
// Resume via fetch: options is DoResumeOptions
await fetch(`/resume`, {
method: 'POST',
body: JSON.stringify({
agentType: 'researcher',
options: { mode: 'with_message', message: 'Continue with the summary' },
}),
});/interrupt and /abort record a durable stop on the session's current run and answer at once: 202 { status: 'stopping', runId, kind } while a drive executes the run, 200 { status: 'interrupted' | 'failed', runId } when the call ended a paused or stranded run itself, 409 state_not_stoppable with no live run (nothing written), 404 state_session_not_found, or a typed 503. Their body ({ reason?, runId? }) is read under 4096 bytes (core MAX_STOP_REQUEST_BYTES): a larger one is the typed 413 validation_payload_too_large. A run that has not ended stopDeadlineMs after the stop is ended by the deadline. A custom endpoint stops the run with EndpointContext.stop. A DO client handle's interrupt() / abort() (DOExecutionHandleImpl: every DOFrontendExecutor handle, the getHandle() reconnect handle included) posts the route and resolves on a 2xx or a 409 state_not_stoppable (the run already ended), and throws the typed error otherwise.
Workflow Execution Semantics
| Action | Behavior |
|---|---|
interrupt() | Soft stop, saves checkpoint, can resume |
resume() | Continue from checkpoint or with new message |
abort() | Hard stop, marks as failed, cannot resume |