Skip to content

@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 ​

bash
npm install @helix-agents/runtime-cloudflare

For Workflows approach, also install:

bash
npm install @helix-agents/store-cloudflare

Entry points ​

ImportWhat it exports
@helix-agents/runtime-cloudflareBoth runtimes (Durable Objects and Workflows), the script workspace and the shared utilities.
@helix-agents/runtime-cloudflare/doThe 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/filestoreThe File Store workspace (CloudflareFileStoreWorkspaceProvider, …); loads @cloudflare/shell.
@helix-agents/runtime-cloudflare/sandboxThe Sandbox workspace (needs the optional peer @cloudflare/sandbox).
@helix-agents/runtime-cloudflare/testingFakeSandbox, 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.

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.

typescript
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:

typescript
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.

typescript
/** 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 ​

typescript
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 ​

typescript
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.

SpanKindParentAttributes
{METHOD} {route} (e.g. POST /start, GET /subagent/{agentType}/sse)SERVERthe caller's traceparent, else a roothttp.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.startINTERNALthe request or alarm that started the instance
helix.do.initINTERNALhelix.do.start
helix.do.wake.redeliver, helix.do.wake.reconcile, helix.do.wake.recoveryINTERNALhelix.do.start, or the alarmhelix.do.wake.source (on recovery)
helix.do.entryINTERNALa /start, /resume, /retry or /subagent/{agentType}/start|resume requesthelix.do.drive, http.response.status_code; events materialized, before_entry, executor_built, entered
invoke_agent {agent} (runtime-js)INTERNALhelix.do.entry, or the wake step that re-drives the runsee runtime-js
helix.do.stopINTERNALan /interrupt or /abort requesthelix.stop.kind, helix.stop.answer
helix.do.alarmSERVERnone (a root), linked to the session's last runfaas.trigger=timer, faas.coldstart
{METHOD} {route}CLIENTthe hop's parentevery 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_tool span;
  • 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:

EventWhat threwWhat happens instead
core.telemetry.inject_failedthe propagator's inject on an outbound hopthe request goes out with its own headers
core.telemetry.extract_failedthe propagator's extract on a requestthe request is read as carrying no trace
core.telemetry.span_start_failedstarting a span (a processor's onStart)the work runs under a non-recording span
core.telemetry.span_end_failedending a span (a processor's onEnd)nothing: the span is dropped
core.telemetry.span_update_failedan event, attribute or link on an open spannothing: 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 DOStubTransport hops (/start, the /sse stream, status reads, its stops), on the call's execute_tool span (RemoteAgentTransport.withTraceContext, applied through core transportFor);
  • 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.

typescript
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>;
}
  • beforeEntry is 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 /start its body is the transformed, validated start body (after transformRequest); the raw body stays readable at origin.request: the original request rebuilt with the body the DO read once, under maxRequestBytes. Returning { abort: true, response } answers that response verbatim and writes nothing. A throw answers framework_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 through beforeEntry: authorise it in the worker in front of the DO.
  • afterEntry fires once per drive, after its run-start commit (or, for recover, after the in-memory claim of the run). It is not awaited by the request: a throw is logged at error.
  • onRunSettled fires 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, then interrupted). A race (a stop inside the suspension window, or a wake beside an entry) can deliver one (runId, status) twice, possibly with a different errorDetail, and a stop in the suspension window can skip the suspended_* delivery. Deduplicate on deliveryId and keep the first delivery. With no onRunSettled configured, no mark is written.
  • transformRequest: a thrown HelixError keeps its code; a Zod failure (recognised by shape) answers 400 validation_error; any other throw is framework_hook_failed, retryable (503) unless its cause says otherwise.

Hook contexts ​

typescript
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.

typescript
interface AbortResult {
  abort: true;
  response: Response;
}

ConcurrentEntryPolicy ​

typescript
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 409 state_already_running (ALREADY_RUNNING on /subagent).
  • 'interrupt_previous' first runs the new entry's gate, materialization and beforeEntry. Only then does it stop the executing run (the DO's one stop, an interrupt with reason superseded_by_new_entry), wait for that run to settle, check that nothing executes, and proceed. The wait is bounded by the recorded stop's deadlineAt plus 5 s (the stop deadline ends the run by then), else, for a stop with no deadline, by concurrentEntryTimeoutMs (default 30 000 ms). A timeout answers 409 state_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 statusHint
suspended_awaiting_childrenThe run continues on its own when its children deliver; send again after it ends, or stop it
suspended_step_partialThe run continues on its own when its partial step resumes; send again after it ends, or stop it
suspended_client_toolThe 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.

typescript
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.

typescript
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) ​

typescript
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) ​

typescript
// 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 ​

typescript
// body:
{ agentType: string; checkpointId?: string; message?: string | UserInputMessage[] }

StartResponseBody ​

The native /start, /resume and /retry started answer (and transformResponse's input):

typescript
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
started200 StartResponseBody
a keyed duplicate (D-2): the key's run is live, or ended and still current200 StartResponseBody with status: 'attached', runId and startSequence of that run
a keyed request: its run ended and a later run exists409 { error, code: 'state_request_already_completed', runId }
a keyed request: its key used for other input409 { error, code: 'state_request_key_conflict', runId }
a malformed X-Helix-Request-Key400 { error, code: 'validation_error' }, before any read; the key is not echoed
beforeEntry refusedthe hook's Response
busy (another run owns the session)409 { error, code: 'state_already_running' }
a rejected run start409 { error, code: 'state_run_start_conflict', cause }, cause a RunStartRejectedError cause (runStartRejectCauseSchema())
the session's status refuses the resume409 { error, code: 'state_not_resumable' }; a partial client-tool batch's message names the awaited call ids first
unknown agent type404 { error, code: 'framework_agent_not_found' }
anything elsecore 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 core planKeyedEntry before checkConcurrency. A duplicate of a live run therefore attaches and never gets the busy 409, and interrupt_previous never stops the original run for its own duplicate. A duplicate of a finished run that is still the session's current run is answered attached too (the caller replays it). The already_completed and conflict plans 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 inside runStartCommitSync'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 answers attached.
  • New entries record the key. A new plan runs the entry with requestKey on its execute() / resume() / retry(), and the run-start commit records it with the digest (core executeRequestDigest / 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.

typescript
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 ​

typescript
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.

typescript
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):

typescript
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:

typescript
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.

typescript
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-result

A 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:

typescript
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.

typescript
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 ​

typescript
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.

typescript
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 ​

typescript
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.

typescript
class DOStreamWriter implements StreamWriter {
  write(chunk: StreamChunk): Promise<void>;
  close(): Promise<void>;
}

Connection Types ​

typescript
// 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.

typescript
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 (query includeReasoning, default true) returns the session's checkpoint-anchored view, read in one synchronous transactionSync: 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), and streamStatus: the stream's own status ('active' | 'paused' | 'ended' | 'failed' | 'none', 'none' with no stream), read in the same transaction. status is the snapshot's run-derived status (a live run reads active before its stream is claimed), so a stream reader must use streamStatus. messages is the checkpoint's [0, messageCount) prefix; nothing uncommitted is appended. Every error text on it (error, errorDetail, streamError, streamErrorDetail) is sanitized with core sanitizeErrorMessage / 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 core typedErrorResponse's status: 409 for a non-retryable one (e.g. state_history_incomplete with cause: 'corrupt_row'), 503 for a retryable one, 500 for an unknown error. DOStateStoreClient throws it as a DOPeerError that is retryable only for a 5xx or 429 (and for a failed fetch), so a host re-serving it keeps the retryability. Removed: timestamp and the top-level startSequence / startMessageCount / startUIMessageCount / runId / turn.
  • GET /messages (query offset, limit up to 1000, includeThinking) returns { messages, hasMore, total, sessionId }. A read failure is an untyped 500 { error: 'Internal server error' }.
  • POST /submit-tool-result answers every failure typed, and writes nothing on a refusal: a submit for a run another entry superseded is 409 { error, code: 'state_run_superseded' } (error sanitized); a result that fails the owning tool's outputSchema is 400 { error, code: 'INVALID_RESULT', toolName, toolCallId } (a fixed error; the Zod issues and the detailed message only with verboseValidationErrors, the same gate as validation_error's details, both redacted with core sanitizeToolErrorText since 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 (404 framework_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-result does not run beforeEntry: 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 /snapshot request (the DO's read is already consistent, so no client fence is needed). buildSnapshot prefers it.
  • getMessages pages GET /messages (an explicit limit capped at 1000; with no limit it reads every page to the end as one consistent read, core readMessagesToEnd, RM-01).
  • getLatestCheckpoint / getCurrentRun return the real checkpoint and run the DO reports (with the run's real startSequence and turn, and its requestKey / requestDigest when the run's entry was keyed), or null when there is none. handleChatStream reads the current run's key to attach a keyed duplicate send.
  • null means "no such session" (a 404) and nothing else. Any other non-2xx answer throws DOPeerError.
typescript
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 answerClient error
409 state_already_runningAgentAlreadyRunningError
404 framework_agent_not_foundAgentResolutionError 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_supersededRunSupersededError

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):

typescript
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.

RouteWho calls itAnswer
POST /companion/child-settleda companion DO, on its parent DObody { 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/statusa 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 / /resumea parent DOthe 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 absolute nextAt (__helix_parent_notice_retry:<runId> records) and posts every due one.
  • child-verify (the parent side): fires at the earliest sub_session_refs.verify_owed_at and 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 no subAgentNamespace.

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):

typescript
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[]>;
  • beginChildConsult sets the consult, clears the run, the outcome and the owed read, and resets completion_delivered. It returns the prior values. Its fence is required: in the same transaction it first requires fence.parentRunId to be the parent session's current live run, else it throws RunSupersededError and writes nothing (CP-161).
  • restoreChildConsult puts all of them back, only while the ref tracks the new consult with no run and no outcome recorded.
  • recordChildRunStarted never overwrites an outcome of that same run.
  • recordChildOutcome is a compare-and-set on the consult. A terminated ref is sticky, and it never touches completion_delivered.
  • markChildOutcomeDelivered sets only completion_delivered, for the recorded outcome of runId.
  • abandonUnconfirmedConsult (MR B) is the unconfirmed re-probe's CAS: only while the consult is still unconfirmed (no run, no outcome, the ref not terminated), a word of another consult is adopted (its consult, run and outcome on the ref; the ref's status the word's, completed_at null for interrupted), 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 same transactionSync; never by text), else undelivered for the notifier; null writes the ref failed, 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 ​

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

typescript
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:

typescript
interface SubAgentNamespace {
  idFromName(name: string): { toString(): string };
  get(id: { toString(): string }): {
    fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
  };
}

Routes — eight, under {basePath}/:agentType/:

MethodActionsessionId sourceNotes
POSTstartJSON bodyALREADY_RUNNING on a live session → consumers attach
POSTresumeJSON body
GETssequeryexclusive ?fromSequence=; terminal truncated { floor } below the floor; wire v2 frames, always one terminal frame
GETstatusquery
GETsnapshotqueryprotocol-shaped, checkpoint-pinned RemoteSnapshotResponse
GETusagequeryforces includeSubAgents: true → RemoteUsageResponse
POSTinterruptJSON body
POSTabortJSON 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).

typescript
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:

typescript
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|usage bridge handlers.
  • Zod response validation — remoteStartResponseSchema(), remoteStatusResponseSchema(), remoteSnapshotResponseSchema(), remoteUsageResponseSchema() (previously raw casts).
  • Wire v2 reads — stream() decodes the child's /sse with core decodeTransportEvents (chunk, end, fail, truncated { floor }); a body that ends with no terminal frame throws the retryable StreamDropError.
  • Entries sent once (CP-95) — /start and /resume are never re-sent. A thrown fetch, or a 5xx that is not the child's typed retryable answer, rejects with the non-retryable RemoteEntryOutcomeUnknownError (core remoteEntryProtocolError); see Remote agents.
  • Typed errors — a non-2xx answer goes through core remoteProtocolError, the same mapping HttpRemoteAgentTransport uses: ALREADY_RUNNING → RemoteAgentAlreadyRunningError, NOT_FOUND → RemoteAgentNotFoundError, FEATURE_UNAVAILABLE → RemoteAgentFeatureUnavailableError (consumers attach on ALREADY_RUNNING and degrade on FEATURE_UNAVAILABLE); any other envelope with an errorCode, or any 503, → a HelixError with that code and cause, retryable exactly on a 503; a typed { error, code } body (agent-server's entry failures) → helixErrorFromPeerBody; anything else → a generic Error.

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.

typescript
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 createSession for 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's runId.

  • One instance per call. Each turn runs in its own write-once instance, …__turn__<key>, where key is minted by the call (resume / retry: __resume__<key> / __retry__<key>).

  • Refusals. A session with a live run throws AgentAlreadyRunningError without 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 whose result() is failed with the typed errorDetail (see preCommitFailure below). With no outcome within entryOutcomeFailSafeMs, 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's RunStartAbandonedError (retryable), or RunStartOutcomeUnknownError when 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 gets RunStartStalledError: 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 as settle says (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 is ownerStopped: false and nothing is settled. A run that COMMITTED but has not started (no run_started in its own stream, or no entry state) at entryOutcomeFailSafeMs gets 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's RunStartStalledError (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) (pass options.message if 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 (a resume(), 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), which retry() refuses, so its error carries recovery: 'resume' and names executor.resume(agent, sessionId, { mode: 'continue' }); the next continuation drains the stored answers. Every other stall carries recovery: 'retry'. Without a message such a run's retry() is refused validation_error: a startUnknown run may have committed a step before it was stopped, and a resume() in continue / from_checkpoint mode 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's settle and message say what that did: 'failed' (settled with that error's detail), 'not_committed' (the write did not land, so a reader such as getHandle() settles it before the retry()), '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). A terminate() that throws because the instance had just closed on its own (read back as complete, errored, terminated or 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, is ownerStopped: 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 throws RunStartStalledError with startUnknown: true and ownerStopped: 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 then startUnknown: true marks a start it could not confirm. An explicit entryStartTimeoutMs replaces the fail-safe: the call returns the handle when it lapses, without abandoning or stalling (0 disables the wait).

  • Closed owners. Every handle's result(), getHandle(), canResume() and abort() reconcile a run whose instance closed without its terminal commit, through core planClosedOwnerReconciliation / 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 (core reconcile.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 reads suspended_* (written when it still read running), its own active stream is paused, and result() answers { status: 'suspended_*', runId, suspended } (toolCallIds, children, stepId from the session);
    • nothing committed → the session, run and stream end failed (terminated → framework_cancelled; errored → the engine's message; complete / gone → { message }, except a complete instance whose output is its own validated failure keeps that errorDetail);
    • 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_cancelled with 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 persisted errorDetail. See Instances closed outside the product.

  • A handle kept across deleteSession. deleteSession removes 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's closedEntryFailure as before. With the session deleted, result() reports the instance's recorded outcome (a completed output stays completed; an aborted run's cancelled output stays a framework_cancelled failure), and an instance that recorded none (errored, terminated, or complete without a result) reports a typed, non-retryable state_session_not_found failure. getHandle() for a deleted session returns null.

  • preCommitFailure. A handle from execute() / resume() / retry() whose instance closed before its run-start commit carries the entry's typed failure as handle.preCommitFailure (the detail its result() 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's result() reports too. This holds only while the session exists: a session deleted during the entry wait (which removes its runs too) gets no preCommitFailure when 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-retryable state_session_not_found; a failed session read there keeps the wait going (logged cfw.entry.session_read_failed). The instance's run read and failure write are retried by their steps (about 94 s at most for both: inside entryOutcomeFailSafeMs when 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 recorded failed (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, whose ownerToken is the instance id), and a reconnect handle otherwise.

  • Store check. The constructor throws if the store lacks getCurrentRun, getRun, listRuns, updateRunStatus, commitState or setRunOwnerUnknownSince (assertCfwStoreSupportsRuns; AgentSteps checks the same).

terminateRun ​

typescript
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 ​

typescript
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.

typescript
// 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) runs executor.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 answers continuation: { kind: 'new_run', runId, startSequence? }; resume({ mode: 'continue' }) adopts that instance. A with_message resume on a session owed it waits for it and becomes the next turn; with_message + abandonPending is 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, and with_message over it is a new turn (Ruling 57). So is execute() 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_message on a completed or failed session throw AgentNotResumableError (typed, non-retryable state_not_resumable).

  • result() of a run whose instance closed after its suspension commit answers the suspension ({ status: 'suspended_*', runId, suspended }, runId the run's own id) instead of failed, and canResume() is true; a reconnect handle (from getHandle()) reconciles only its own run. See Closed owners above.

  • continue / with_message on an active session with no pending calls (its instance is running) throw AgentAlreadyRunningError before any write. They used to truncate and create a second instance.

  • Concurrent from_checkpoint resumes 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's stream_resync (reason: 'rollback') carries the truncate checkpoint id.

  • The winner of a resume or retry bumps resumeCount inside its run-start commit.

  • A run superseded by a newer entry stops with RunSupersededError (thrown as a NonRetryableError), its result is status: 'superseded', onAgentSuperseded fires, and neither onAgentComplete nor onAgentFail does (one exception: a supersede that lands after onAgentComplete fired and before the run's terminal commit leaves onAgentComplete then onAgentSuperseded; the last terminal hook is authoritative).

The Durable Object runtime has runtime-js semantics.

runAgentWorkflow ​

Core workflow function for Cloudflare Workflows.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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);
typescript
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 ​

typescript
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 single step.waitForEvent slice 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 of WorkflowOptions.subAgentEventTimeout, the whole bound of such a wait (60 s). A companion__waitForResult with a caller timeout parks for at most min(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_FLOOR fast rejections (an iteration, consult included, shorter than CFW_WAIT_FLOOR_MS by 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 further CFW_WAIT_INSTANT_REJECTIONS_BEFORE_FLOOR, capped at CFW_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, including companion__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__waitForResult waits 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-blocking spawnAgent continue) 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 blocking spawnAgent continue does not yet: FU-CFW-BLOCKING-SPAWN-CONTINUE-RESTORE). A non-blocking spawnAgent continue returns only once its run has committed ({ name, status: 'spawned', delivered: true }), else { name, status: 'not_spawned', delivered: false, reason, errorDetail? } — core CompanionSpawnResult, built from the entry poll's CompanionEntryOutcome (companionSpawnResultFromEntry; a sendMessage continue through companionDeliveryFromEntry). A continue over a completed child whose result the model has not seen HOLDS it (holdPriorResult in the continue dep) and every row its record step writes carries it as priorResult (the answer, or { error, priorResult } on a failed row); the record step releases it after its appendCommitted, 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, bound companionEntryOutcomeTimeoutMs) and answers through companionSpawnResultFromEntry — spawned only once that run committed, else a typed not_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 a child_busy or 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 answered child_busy by 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: completed with output, failed with errorDetail, interrupted with its guidance, each with the held priorResult), 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's DEFAULT_STEP_LIMIT = 1e4 (STEP_LIMIT to override) counts only step.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" for workflows[].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:

typescript
// 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 parentSessionId

zod 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.

typescript
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.

typescript
const endAgentStreamInputSchema = lazySchema(() =>
  z.object({
    sessionId: z.string().min(1),
    startedAt: z.number().optional(),
    finishWithOutput: z.unknown().optional(),
    skipStreamClose: z.boolean().optional(),
  })
);
FieldTypeDescription
sessionIdstringSession to finalize
startedAtnumber?Start timestamp for duration calculation
finishWithOutputunknown?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.
skipStreamCloseboolean?When true, fires lifecycle hooks without closing the stream. Used for sub-agents that share the parent's stream.

Workflow Types ​

typescript
import type {
  WorkflowStep,
  WorkflowEvent,
  WorkflowDuration,
  WorkflowRetryConfig,
  WorkflowStepConfig,
  WaitForEventOptions,
  WaitForEventResult,
  SubAgentCompletionEvent,
} from '@helix-agents/runtime-cloudflare';

Shared Utilities ​

pollUntil ​

Poll until a condition is met.

typescript
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 ​

typescript
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.

typescript
import { mapStatusToRemoteProtocol } from '@helix-agents/runtime-cloudflare/do';

mapStatusToRemoteProtocol('active'); // 'running'

Complete Examples ​

Durable Objects Example (Composition API) ​

wrangler.toml ​

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 ​

typescript
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 ​

typescript
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 ​

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 ​

typescript
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 every stopPollIntervalMs (default 500 ms) while the work runs. A stop aborts the in-flight model call (generateStep's abortSignal), a tool's or hook's abortSignal, 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__waitForResult or 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.
typescript
// 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:

typescript
// 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 ​

ActionBehavior
interrupt()Soft stop, saves checkpoint, can resume
resume()Continue from checkpoint or with new message
abort()Hard stop, marks as failed, cannot resume

See Also ​

Released under the MIT License.