Skip to content

Cloudflare Durable Objects Runtime ​

The Durable Objects (DO) runtime executes agents directly inside a Durable Object, bypassing Cloudflare's subrequest limits by streaming directly to WebSocket and SSE connections.

When to Use DO Runtime

Choose the DO runtime when you need heavy streaming (>100 chunks per execution), real-time WebSocket/SSE connections, or simpler single-DO-per-run architecture. For step-level durability with automatic retries, consider the Workflows runtime instead.

Why Durable Objects? ​

Cloudflare Workers have a 1000 subrequest limit per invocation. In the Workflows runtime, each stream chunk write to the streaming Durable Object counts as a subrequest. For streaming-heavy agents, this can quickly exhaust the limit.

The DO runtime solves this by:

  • Hosting agent execution inside the DO - The JSAgentExecutor runs within the Durable Object
  • Streaming directly to connections - WebSocket/SSE writes don't count as subrequests
  • Using local SQLite - State is stored in DO's built-in SQLite, not external D1
  • Only LLM calls consume subrequests - The only external calls are to the LLM API

Key Benefits ​

FeatureDescription
FREE StreamingWebSocket/SSE writes don't count against subrequest limits
Built-in SQLiteNo D1 database needed - state lives in the DO
HibernationCost-efficient sleep/wake with state preservation
Simpler ArchitectureOne DO = one agent run (no coordination needed)
Native WebSocket/SSEReal-time bidirectional communication

Session vs Run Identifiers

  • sessionId: Primary key for state storage. Each Durable Object hosts one session.
  • runId: Identifies a specific execution within a session. Multiple runs can occur within one session (e.g., after interrupts).

Use sessionId for DO naming (e.g., env.AGENTS.idFromName(\session:${sessionId}`)). The runId` is execution metadata for tracing.

Architecture ​

mermaid
graph TB
    subgraph Edge ["Edge Location"]
        subgraph CFWorker ["Cloudflare Worker"]
            W1["Routes requests to DOs<br/>Creates LLM adapters<br/>Manages DO stubs"]
        end

        CFWorker --> AgentDO

        subgraph AgentDO ["AgentServer (Durable Object)"]
            subgraph Executor ["JSAgentExecutor"]
                E1["Agent loop execution<br/>Tool execution<br/>Sub-agent spawning"]
            end

            subgraph Stores [" "]
                direction LR
                StateStore["DOStateStore<br/>(SQLite)"]
                UsageStore["DOUsageStore<br/>(SQLite)"]
                StreamMgr["DOStreamManager<br/>(WebSocket/SSE)"]
            end
        end

        AgentDO --> Clients

        subgraph Clients [" "]
            direction LR
            WS["<b>WebSocket Clients</b><br/>Real-time, survives<br/>hibernation"]
            SSE["<b>SSE Clients</b><br/>One-way, simpler"]
        end
    end

Prerequisites ​

  • Cloudflare account with Workers Paid plan
  • Wrangler CLI: npm install -g wrangler
  • No additional services needed (D1, etc.) - SQLite is built into the DO

Installation ​

bash
npm install @helix-agents/runtime-cloudflare @helix-agents/core

The partyserver dependency is included automatically.

Quick Start (Composition API) ​

The composition API using createAgentServer() is the recommended approach for building AgentServer instances.

1. Configure wrangler.toml ​

toml
name = "my-agent-worker"
main = "src/worker.ts"
compatibility_date = "2024-01-01"
# The incoming request.signal fires when a chat client disconnects, so the
# chat handler stops streaming from the Durable Object (no date implies it).
compatibility_flags = ["nodejs_compat", "enable_request_signal"]

# Durable Object binding with SQLite support
[[durable_objects.bindings]]
name = "AGENTS"
class_name = "MyAgentServer"

# Enable SQLite for the DO class
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyAgentServer"]

Important

Use new_sqlite_classes instead of new_classes to enable SQLite storage in the Durable Object. Without this, the DO won't have access to the this.sql method.

Client disconnects: enable_request_signal is required

List enable_request_signal in compatibility_flags and pass the incoming request (or signal: request.signal) to handleChat / handleChatStream. The flag is required: it has no default enable date, so no compatibility_date implies it, and without it Cloudflare never tells your Worker that a client left. Measured on Cloudflare's edge (2026-10-08, 5 runs each): without the flag nothing fired within 30 s, not request.signal and not the response body's cancel, even on a stream writing every 50 ms. So every reload or closed tab leaves the handler, and the Durable Object's /sse connection, streaming to nobody until the run ends. With the flag, a client that leaves a quiet stream (a long tool call, a slow model) is reported to the Worker within about 0.3 s, and the Durable Object's /sse request learns of it within about 0.1 s of the disconnect (upper bounds, observed from the client). The DO runs in the same Worker, so the one flag covers both. The run itself is never affected.

handleChatStream checks the flag once per isolate on its first request (core requestSignalSupport(), which reads the runtime's Cloudflare.compatibilityFlags) and logs one warning when it is off: through your logger, or console.warn when you pass none. A runtime it cannot read (Node, a runtime without that global) is never warned about.

Local wrangler dev / Miniflare differ from the edge: with the flag they report a disconnect only when the response next writes, so locally a quiet run is noticed at its next chunk. See Client disconnects.

2. Create AgentServer with Composition ​

typescript
// src/agent-server.ts
import { createAgentServer, AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { myAgent } from './agent.js';

interface Env {
  AGENTS: DurableObjectNamespace;
  ANTHROPIC_API_KEY: string;
  OPENAI_API_KEY: string;
}

// Create and populate registry
const registry = new AgentRegistry();
registry.register(myAgent);

// Create AgentServer using composition
export const MyAgentServer = createAgentServer<Env>({
  // Required: Factory to create LLM adapters (called once per drive)
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      anthropic: createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }),
    }),

  // Required: Agent registry or resolver function
  agents: registry,

  // Optional: Required if using createSubAgentTool() — see Sub-Agents section
  // subAgentNamespace: (env) => env.AGENTS,

  // Optional: "last message wins" — a new entry stops the live run first
  concurrentEntry: 'interrupt_previous',

  // Optional: server hooks
  hooks: {
    beforeEntry: async (ctx) => {
      // Admission only (auth, rate limits); return { abort: true, response } to refuse
      console.log(`Entry: ${ctx.drive}`);
    },
    afterEntry: async ({ sessionId, runId, drive }) => {
      console.log(`Run ${runId} owned by a ${drive} drive (session: ${sessionId})`);
    },
    onRunSettled: async ({ runId, status, deliveryId }) => {
      // At least once per (runId, status): deduplicate on deliveryId
      console.log(`Run ${runId} settled: ${status} (${deliveryId})`);
    },
  },
});

@helix-agents/runtime-cloudflare/do is the Durable Object runtime without the Cloudflare Workflows runtime, so the Worker loads less at startup. The main entry, @helix-agents/runtime-cloudflare, exports the same objects and works too. The File Store workspace is at @helix-agents/runtime-cloudflare/filestore, the Sandbox workspace at /sandbox (Startup Cost → Import from /do).

3. Create Worker Entry Point ​

typescript
// src/worker.ts
import { MyAgentServer } from './agent-server.js';
import type { Env } from './agent-server.js';

// Export the AgentServer DO class
export { MyAgentServer };

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Start new agent execution
    if (url.pathname === '/execute' && request.method === 'POST') {
      const { message } = await request.json<{ message: string | UserInputMessage[] }>();

      // Generate unique session ID
      const sessionId = crypto.randomUUID();

      // Get DO stub for this session
      const doId = env.AGENTS.idFromName(`session:${sessionId}`);
      const stub = env.AGENTS.get(doId);

      // Start execution using the new request format
      const response = await stub.fetch('/start', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          agentType: 'researcher', // Agent type from registry
          input: { message },
          sessionId,
        }),
      });

      const result = await response.json<{ sessionId: string; streamId: string; runId: string }>();

      return Response.json({
        sessionId: result.sessionId,
        streamUrl: `/stream/${result.sessionId}`,
        websocketUrl: `/ws/${result.sessionId}`,
      });
    }

    // SSE streaming endpoint
    if (url.pathname.startsWith('/stream/')) {
      const sessionId = url.pathname.split('/').pop()!;
      const doId = env.AGENTS.idFromName(`session:${sessionId}`);
      const stub = env.AGENTS.get(doId);

      // Forward to DO's SSE endpoint
      return stub.fetch(new URL('/sse', request.url), request);
    }

    // WebSocket upgrade
    if (url.pathname.startsWith('/ws/')) {
      const sessionId = url.pathname.split('/').pop()!;
      const doId = env.AGENTS.idFromName(`session:${sessionId}`);
      const stub = env.AGENTS.get(doId);

      // Forward WebSocket upgrade to DO
      return stub.fetch(request);
    }

    // Get status
    if (url.pathname.startsWith('/status/')) {
      const sessionId = url.pathname.split('/').pop()!;
      const doId = env.AGENTS.idFromName(`session:${sessionId}`);
      const stub = env.AGENTS.get(doId);

      return stub.fetch(new URL('/status', request.url));
    }

    return new Response('Not found', { status: 404 });
  },
};

4. Define Your Agent ​

typescript
// src/agent.ts
import { defineAgent, defineTool } from '@helix-agents/core';
import { z } from 'zod';

const searchTool = defineTool({
  name: 'search',
  description: 'Search for information',
  inputSchema: z.object({
    query: z.string(),
  }),
  execute: async ({ query }) => {
    // Your search implementation
    return { results: [`Found: ${query}`] };
  },
});

export const myAgent = defineAgent({
  name: 'researcher',
  description: 'Research assistant',
  systemPrompt: 'You are a helpful research assistant.',
  tools: [searchTool],
  outputSchema: z.object({
    summary: z.string(),
    sources: z.array(z.string()),
  }),
  maxSteps: 10,
});

DurableObjectAgentConfig ​

The createAgentServer<TEnv>(config) factory accepts this configuration (excerpt; the eviction-recovery, workspace and submit options are documented in their own sections):

typescript
interface DurableObjectAgentConfig<TEnv = unknown> {
  /** Factory to create LLM adapters, called once per drive. May be async. Required. */
  llmAdapter: LLMAdapterFactory<TEnv>;

  /** An AgentRegistry instance or a resolver function. May be async. Required. */
  agents: AgentResolverInterface<TEnv> | AgentResolver<TEnv>;

  /** Optional server hooks: admission, run ownership, settle delivery, /start transforms. */
  hooks?: ServerHooks<TEnv>;

  /** Optional custom endpoints, checked BEFORE built-in endpoints. */
  endpoints?: Record<string, EndpointHandler<TEnv>>;

  /**
   * Optional consumer usage store factory. Strict: a throw stops the entry
   * (`framework_usage_store_unavailable`, retryable). Unset: the DO-local DOUsageStore.
   */
  usageStore?: UsageStoreFactory<TEnv>;

  /** What an entry does while another drive executes a run (default 'reject'). */
  concurrentEntry?: 'reject' | 'interrupt_previous';

  /**
   * Bound on a submit's continuation wait, and on the `interrupt_previous` settle wait when the stop it
   * records carries no deadline (default 30 000 ms).
   */
  concurrentEntryTimeoutMs?: number;

  /**
   * The stop deadline, for both stop kinds (default 30 000 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;

  /**
   * CP-217: how a stop this DO owes a child (a companion or a remote 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 24 h after the debt began). Each a positive integer and `maxMs >= baseMs`, else the
   * constructor throws a typed `validation_error`.
   */
  owedStopSchedule?: Partial<OwedStopSchedule>;

  /**
   * The bound on a persistent companion's `/start` (default 60 000 ms): at the bound the spawn answers
   * `not_spawned` / `child_start_unconfirmed`. A positive integer, else the constructor throws a typed
   * `validation_error`.
   */
  companionStartTimeoutMs?: number;

  /**
   * #179: OpenTelemetry (provider, flush, content capture, `helix.step` switch) and the DO's
   * logger (`TelemetryOptions.logger`), resolved ONCE per instance. Default: the global provider
   * and no logging. Replaces `logger`.
   */
  telemetry?: (env: TEnv, ctx: DurableObjectState) => TelemetryOptions;

  /**
   * Optional namespace for routing sub-agent calls to sibling DOs.
   * Required when using createSubAgentTool() or persistentAgents — see Sub-Agents below.
   */
  subAgentNamespace?: (env: TEnv) => SubAgentNamespace;

  // Eviction recovery: autoResumeOnWake, maxWakeResumeAttempts,
  // companionPollFailureWindowMs (see Eviction Recovery).
  // Workspaces: workspaceProviders, workspaceMetrics, workspaceMaxConcurrentOpens.
  // Submit: submitToolResultSchema (the submit size limit; the DO passes it to every executor it
  // builds and its /submit-tool-result pre-check reads the same limits, DI-51), verboseValidationErrors.
}

The drive pipeline ​

Every run a DO starts or recovers goes through one pipeline, runDrive (do/drive/run-drive.ts). A drive is one entry into a run:

DriveStarted by
startPOST /start, POST /subagent/:agentType/start
resumePOST /resume, POST /subagent/:agentType/resume
retryPOST /retry
continuea submit that completed its client-tool batch, an unconsumed submit found on wake, a stranded active session reset on wake
recovera wake that finds the session's run still running (re-entered under its own run id)

start, resume and retry are request drives: a caller waits for the answer. continue and recover are wake drives: nobody waits, so a failure is acted on (see the failure table) instead of answered. Every drive runs the same stages:

  1. Claim and concurrency. A request drive takes the DO's producer claim synchronously (held → the entry is lost); a wake drive waits for it. Whether the registered drive is executing is decided from the run's truth — the stop probe's executingRunOf: its run is the session's current run and running in the store. A drive whose run has ended or suspended but has not settled yet is in its tail: the entry waits for its release (milliseconds), never refuses or stops it. If the drive is executing, concurrentEntry decides (see Concurrent entries). The claim covers the whole drive, beforeEntry included; the execution registration also refuses a registration that has not been released (an invariant; the claim is the gate).
  2. Identity and type gate. The persisted session decides the agent type and the identity. A /resume, a /retry or a /start on an existing session that names another agent type answers 409 state_agent_type_mismatch. A continuing /start whose userId, tags or metadata differ from the session's answers 400 validation_error (the error's field names which); omitted fields keep the session's. Identity is fixed when the session is created. A /resume or /retry with no session answers state_session_not_found.
  3. Materialize the agent: resolve it, rewrite its sub-agent and companion tools, build the adapter and the usage store, and check completion support. Each step fails typed and writes nothing.
  4. Admission (request drives only): hooks.beforeEntry.
  5. Build the executor. One function (buildDriveExecutor) builds every runtime-js executor a DO uses, with the DO's logger, client-tool resolver, workspace providers, claim block and the drive's pre-minted run id.
  6. Call the executor and wait for the entry's outcome. A request drive never answers started before its run-start commit landed.
  7. Detach. hooks.afterEntry fires once the drive owns its run; the run's result() then goes to the settle path.

The design is in the drive pipeline spec (§3); the API changes it made are in the DO drive pipeline upgrade guide.

LLMAdapterFactory ​

Creates the LLM adapter for one drive. It receives one context object and may be async:

typescript
type LLMAdapterFactory<TEnv> = (
  context: DriveFactoryContext<TEnv> & { agentType: string }
) => LLMAdapter | Promise<LLMAdapter>;

interface DriveFactoryContext<TEnv> {
  purpose: 'drive';
  env: TEnv;
  sessionId: string;
  runId: string; // the run id the drive will run under
  drive: 'start' | 'resume' | 'retry' | 'continue' | 'recover';
  identity: AgentIdentity; // the session's persisted identity
}

interface AgentIdentity {
  userId?: string;
  tags?: string[];
  metadata?: Record<string, string>;
  parentSessionId?: string;
  rootSessionId?: string;
}

A throw becomes framework_agent_resolution_failed, keeping the cause's retryability (an unclassified throw is transient: a request answers 503, a wake re-arms).

Example: Per-agent model selection

typescript
llmAdapter: ({ env, agentType }) => {
  // Choose model based on agent type
  if (agentType === 'fast-responder') {
    return new VercelAIAdapter({
      openai: createOpenAI({ apiKey: env.OPENAI_API_KEY }),
      defaultModel: 'gpt-6-luna',
    });
  }
  return new VercelAIAdapter({
    anthropic: createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }),
    defaultModel: 'claude-sonnet-5-5',
  });
};

UsageStoreFactory ​

typescript
type UsageStoreFactory<TEnv> = (
  context: DriveFactoryContext<TEnv> | UsageReadContext<TEnv>
) => UsageStore | Promise<UsageStore>;

/** A call that reads usage and drives nothing. */
interface UsageReadContext<TEnv> {
  purpose: 'usage_read';
  env: TEnv;
  sessionId: string;
  identity: AgentIdentity;
}

A drive calls the factory with its DriveFactoryContext. The protocol usage route (/subagent/:agentType/usage) and the end-of-stream rollup of a run that no drive of this DO instance materialized (for example a run finished on wake) call it with purpose: 'usage_read'. See DOUsageStore for the strict rules.

AgentRegistry with Factories ​

The AgentRegistry supports both static registration and factory functions for dynamic agent creation.

Static Registration ​

For agents that don't need runtime dependencies:

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

const registry = new AgentRegistry();
registry.register(researchAgent);
registry.register(summarizerAgent);

Factory Registration ​

For agents that need environment bindings, per-request configuration, or dependency injection, or that should be built on first use rather than when the Worker loads (see Startup Cost). A factory may be async; the DO awaits it:

typescript
registry.registerFactory<Env>('brainstorm', async (ctx) => {
  return createBrainstormAgent({
    tavilyApiKey: ctx.env.TAVILY_API_KEY,
    database: ctx.env.DATABASE,
    userId: ctx.identity.userId,
  });
});

AgentFactoryContext ​

Resolvers and factories receive one of two contexts, told apart by purpose:

typescript
type AgentFactoryContext<TEnv> = DriveFactoryContext<TEnv> | LookupFactoryContext<TEnv>;

/** A lookup that starts nothing: no run id. */
interface LookupFactoryContext<TEnv> {
  purpose: 'lookup';
  env: TEnv;
  sessionId: string;
  identity: AgentIdentity;
  reason: 'submit_validation' | 'retention' | 'stop_cascade';
}

A resolver sees a run id only when a run is about to be driven under it (purpose: 'drive', see LLMAdapterFactory for DriveFactoryContext). A purpose: 'lookup' call validates a submitted client-tool result against the owning tool's outputSchema (reason: 'submit_validation'), or, when a stop ends a run that has live ephemeral sub-agent children, reads the agent's sub-agent tools to reach each child through its transport (reason: 'stop_cascade'); it never builds an adapter or a usage store. The identity is the session's persisted one on every drive (on a /start of a new session, the body's).

A resolver function has the same shape as a registry's resolve:

typescript
type AgentResolver<TEnv> = (
  agentType: string,
  context: AgentFactoryContext<TEnv>
) => AnyAgentConfig | Promise<AnyAgentConfig>;

interface AgentResolverInterface<TEnv> {
  resolve(
    agentType: string,
    context: AgentFactoryContext<TEnv>
  ): AnyAgentConfig | Promise<AnyAgentConfig>;
  has?(agentType: string): boolean;
}

Using resolve() vs get() ​

typescript
// get() - static registrations only, synchronous
const agent = registry.get('researcher');

// resolve() - factory first, then static; may return a Promise (an async factory)
const agent = await registry.resolve('brainstorm', {
  purpose: 'drive',
  env,
  sessionId: 'session-123',
  runId: 'run-456',
  drive: 'start',
  identity: { userId: 'user-789' },
});

Registry Methods ​

MethodDescription
register(config)Register a static agent configuration
registerFactory(type, factory)Register a factory function (may be async)
replace(config)Overwrite an existing static registration
resolve(type, context)Resolve agent (factory-first, then static)
get(type)Get a static agent only
has(type)Check if agent type is registered
unregister(type)Remove an agent registration
getRegisteredTypes()Get all registered agent type names
clear()Remove all registrations (for testing)

registry.replace(config) ​

Overwrites an existing static agent registration with a new config, returning whether a prior registration existed. Throws if a factory is registered under the same name.

typescript
const registry = new AgentRegistry();
registry.register(myAgent);

// Later: swap the registered reference (e.g., to attach hooks)
const agentWithHooks = { ...myAgent, hooks: makeHooks() };
const hadPrior = registry.replace(agentWithHooks);
// hadPrior === true

When to use: The Cloudflare Workflows runtime resolves agents BY NAME from the registry — workflow inputs only carry the agent name, not the agent reference. Per-call hooks attached to the agent passed into executor.execute(agent, ...) aren't honored unless the registered reference itself carries them. replace() is the canonical way to swap the reference (most commonly in tests that exercise per-test hooks). JS / DBOS runtimes read the agent reference inline at execute() time and don't need this API.

Don't replace factories. If a factory is registered under the same name, replace() throws. Use unregister() followed by registerFactory() to replace a factory.

Unknown agent types: AgentResolutionError ​

An unknown type throws core AgentResolutionError.notFound from both get() and resolve(): code framework_agent_not_found, non-retryable, with agentType and the registered availableTypes (get() lists only the static registrations, and names a factory-registered type as one). A custom resolver should throw the same error for a type it does not know:

typescript
import { AgentResolutionError, isAgentNotFoundError } from '@helix-agents/core';

const agents: AgentResolver<Env> = (agentType, ctx) => {
  if (agentType === 'brainstorm') return createBrainstormAgent(ctx.env);
  throw AgentResolutionError.notFound(agentType, ['brainstorm']);
};

try {
  await registry.resolve('unknown', context);
} catch (error) {
  if (isAgentNotFoundError(error)) {
    console.log(`Unknown type: ${error.agentType}`);
    console.log(`Available: ${error.availableTypes?.join(', ')}`);
  }
}

isAgentNotFoundError and isAgentResolutionError match by name and code, so an error thrown by a second copy of core still matches. On the DO, a not-found type answers 404 framework_agent_not_found (NOT_FOUND on the /subagent routes) and ends a wake drive's run typed. Any other resolver or adapter-factory throw becomes AgentResolutionError.failed (framework_agent_resolution_failed, the original as cause, retryable unless the cause is classified retryable: false).

ExecutionState ​

ExecutionState is a read-only view of what the DO is executing, for custom endpoints:

typescript
interface ExecutionState {
  /** Session ID of the DO's session, or null if none */
  readonly sessionId: string | null;

  /** Run ID of the current execution, or null if none */
  readonly runId: string | null;

  /**
   * Whether a drive's execution is registered: from just before its executor call
   * until the settle path releases it, which happens BEFORE onRunSettled runs.
   */
  readonly isExecuting: boolean;

  /** Current execution status */
  readonly status: 'idle' | 'running' | 'paused' | 'interrupted' | 'completed' | 'failed';
}

To stop a run from a custom endpoint, call EndpointContext.stop(kind, reason) (or use the /interrupt and /abort routes). To replace a live run with a new native entry ("last message wins"), set concurrentEntry: 'interrupt_previous' (a chat send is refused instead; see below).

Concurrent entries ​

concurrentEntry decides what a /start, /resume or /retry does while another drive executes a run on the DO:

  • 'reject' (default) answers 409 { error, code: 'state_already_running' }.
  • 'interrupt_previous' stops the executing run (the DO's one stop, an interrupt with reason superseded_by_new_entry), waits for its drive to settle, then proceeds. The stop runs only after the new entry passed the identity and type gate, materialization and beforeEntry, so a refused or unadmitted entry never interrupts a live run. The wait is bounded by the recorded stop's deadlineAt plus 5 s, since the stop deadline ends the previous run by then; when the stop carries no deadline (it resolved at once, or it failed) the bound is concurrentEntryTimeoutMs (default 30 000 ms). At the bound the entry answers 409 state_already_running, and the old run still stops (the stop is durable). Like every stop that ends a run, it cascades to the old run's live children, background (non-blocking) companions included.

A keyless chat send (createCloudflareChatHandler, DOFrontendExecutor with no request key) is refused with the 409 while a run executes, even under 'interrupt_previous' (CP-13). A keyed chat send (X-Helix-Request-Key, which useHelixChat sends) that repeats a send is answered from its key before this check and attaches to that send's run; a new keyed send follows the policy like a native entry (D-2 MR 1.5): under 'interrupt_previous' it stops the executing run, and its reply shows only its own run. Any /start over a run suspended on its children, a partial step or pending client-tool results is refused instead of superseding it (ruling O-70), naming the run's status. Stop the run first, or send once it has ended. Native /start, /resume and /retry callers keep the policy. Each refused entry logs drive.entry_refused with its reason (executing, reject_concurrent_entry, suspended_run, stop_unsettled).

"Executing" is the run's truth, not the drive's in-memory state: the registered drive is executing while its run is the session's current run and running in the store (the stop probe's executingRunOf; the stop deadline reads the same). A run's terminal status is written before its stream ends, and its drive settles only once handle.result() resolves, so a send that arrives right after a reply's finish finds a drive whose run is over but whose registration is still live. Such a drive — its run ended, or suspended, in the store, or its result() settled — is in its tail: a new entry waits for its release (milliseconds, bounded by concurrentEntryTimeoutMs) under either policy and for a chat send too, then applies the idle rules (O-70 included) and starts. It is never refused 409 state_already_running and never stopped. A tail that does not release within the bound is a bug: the DO logs drive.tail_unsettled (with the session and run ids) and the entry answers the typed, retryable 503 framework_internal_error — never the 409 for a run that is over. Wake drives never interrupt a run. The same bound caps how long a /submit-tool-result waits for the continuation it starts (see the submit route below).

typescript
export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      /* ... */
    }),
  agents: registry,
  // "Last message wins": a new message stops the live run, then starts its own.
  concurrentEntry: 'interrupt_previous',
  concurrentEntryTimeoutMs: 30_000,
});

The stop is the one the /interrupt route makes (see Interrupting Execution). A run held by a tool that ignores its abort signal is ended by the stop deadline, and its stuck drive is detached (its registration released), so the waiting entry can proceed within its bound instead of waiting for the tool.

Server Hooks ​

All hooks are optional. They enable customization without subclassing.

typescript
interface ServerHooks<TEnv = unknown> {
  /** Admission for request drives. `{ abort: true, response }` refuses; nothing is written. */
  beforeEntry?(ctx: BeforeEntryContext<TEnv>): Promise<void | { abort: true; response: Response }>;

  /** Once per drive, after the drive owns its run (request drives and wakes). */
  afterEntry?(ctx: AfterEntryContext<TEnv>): Promise<void>;

  /** At least once per (runId, status) when a run settles. */
  onRunSettled?(ctx: RunSettledContext<TEnv>): Promise<void>;

  /** Transform an incoming native /start body into the standard shape. */
  transformRequest?: (rawBody: unknown, env: TEnv) => Promise<StartBody>;

  /** Transform the native /start `started` answer. */
  transformResponse?: (
    response: StartResponseBody,
    context: { sessionId: string; agentType: string }
  ) => Promise<unknown>;
}

beforeEntry: admission ​

beforeEntry runs for /start, /resume and /retry, and for the /subagent/:agentType/start and /resume routes (origin.route tells them apart). It runs after the busy check, the identity and type gate and materialization, so an entry refused by any of those never reaches it. Use it for admission only (auth, rate limits, quotas): it sees the entry's input and the session's identity, and no mutable state.

typescript
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;
      message?: string | UserInputMessage[]; // the retry's new user message, as parsed
      identity: AgentIdentity;
    };

interface RequestOrigin {
  request: Request; // the original request: headers intact
  route: 'native' | 'subagent';
}

Every request drive exposes all the user input it carries, exactly as parsed: a start its body.input (message, state, history), a resume its options (a with_message message), a retry its message. On a native /start with transformRequest, body is the transformed, validated body; the original raw body is still readable from origin.request (the DO reads the body once, under maxRequestBytes, and rebuilds the request with it).

Wake drives (continue, recover) never run beforeEntry. That includes the continue a /submit-tool-result starts: a submit puts caller-supplied tool results and approval decisions in front of the model, and no admission hook sees them. Submits are outside beforeEntry by design; authorise them in the worker in front of the DO (FU-DO-SUBMIT-ADMISSION-HOOK).

Return { abort: true, response } to refuse: the response is answered verbatim and nothing is written. A throw is HelixError framework_hook_failed, keeping the cause's retryability (an unclassified throw is transient: 503).

typescript
hooks: {
  beforeEntry: async (ctx) => {
    if (ctx.drive === 'start' && !ctx.identity.userId) {
      return {
        abort: true,
        response: Response.json({ error: 'User ID required' }, { status: 401 }),
      };
    }
  },
}

afterEntry ​

afterEntry fires once per drive, after the drive owns its run: after the run-start commit for an entry, or after the in-memory claim for a recover wake. It is fired and not awaited, so a slow hook never delays the run; a throw is logged.

typescript
interface AfterEntryContext<TEnv> {
  env: TEnv;
  sessionId: string;
  runId: string;
  drive: 'start' | 'resume' | 'retry' | 'continue' | 'recover';
  handle: AgentExecutionHandle<unknown>;
}

onRunSettled ​

onRunSettled is delivered whenever a run reaches a status at which nothing executes for it, on every path (request drives, wakes, recovery):

typescript
interface RunSettledContext<TEnv> {
  env: TEnv;
  sessionId: string;
  runId: string;
  drive?: 'start' | 'resume' | 'retry' | 'continue' | 'recover';
  status:
    | 'suspended_client_tool'
    | 'suspended_awaiting_children'
    | 'suspended_step_partial'
    | 'completed'
    | 'failed'
    | 'interrupted'
    | 'superseded';
  result?: AgentResult<unknown>;
  errorDetail?: ErrorDetail;
  deliveryId: string; // `${runId}:${status}`
}

Delivery is at least once per (runId, status). Deduplicate on deliveryId:

  • After the hook returns, the DO writes a durable delivery mark to DO storage. A wake that finds a settled run without its mark (the DO was evicted between the commit and the hook) delivers the hook again.
  • A hook that throws is logged and still marked, so it is never retried forever.
  • With no onRunSettled configured, no mark is written.
  • A run that suspends and is later stopped settles twice: once with its suspended_* status, then with interrupted (or failed after an abort). A stop that lands while the run is still suspending ends it by the stop before its settle, so only the stop's status is delivered, possibly twice (the stop route and the drive's own settle).
  • The execution registration is released before the hook runs, so a slow hook never blocks the next entry (ExecutionState.isExecuting is already false while it runs). The DO holds a keepalive for the duration of the call.

The settle path ​

Every run end goes through one tail (do/drive/settle.ts), whether a request drive, a wake drive or wake recovery reached it:

  1. A failed result whose run is still live (runtime-js's handle safety net resolved it with no K5) is made durable first (failCommittedRun), so the session never reads failed over a running run. If a newer run took over, the run settles superseded. A suspended_* result whose run has a stop record (a stop answered 202 that the run did not reach before its suspension landed) is ended by that stop here (commitStopTerminal) and settles with the stop's status.
  2. The run's own stream is finalized, fenced to the run (end, fail with the run's errorDetail, or paused). A run its own stop ended resets the crash-loop wake record.
  3. The execution registration is released. From here a new entry is not blocked.
  4. A run a stop ended (interrupted, or failed under an abort record) stops its live children with the stop's kind, each through its own route (a persistent companion through its DO's /subagent/:type/interrupt|abort, an ephemeral sub-agent through its tool's transport). Only that run's children are reached (those it started, reopened or re-consulted, and those an earlier run left running). Each persistent companion's stop names the consult its ref tracks (parentToolCallId); each remote child's names its dispatch. Every child's stop is confirmed or owed (CP-217): a child that does not confirm is OWED its stop on its ref (stopOwed, below), and the stop-cascade alarm retries it. A durable mark records the cascade once its reads succeeded (the child refs, the remote children's transports) and every child is confirmed or DURABLY owed (its debt landed on its ref); the cascade is the mark's only writer. A read that failed, or a debt that did not land, leaves it owed: the next wake re-runs it, as it re-runs a cascade an eviction cut short. The alarm owns the debts independently of the mark.

5a. A run that served a parent consult (a persistent companion's run, RunMetadata.parent set) and ended completed, failed or interrupted notifies its parent: the child-settled notice (spec 2026-10-15 §3.5). A drive's settle (a request drive or a wake drive) posts it and awaits the answer, bounded by 10 s (PARENT_NOTICE_TIMEOUT_MS), so a drive's settle can wait up to 10 s on an unreachable parent before step 6. This is after the release (step 3), so no entry waits on it, but onRunSettled and the CP-52 tail are delayed by up to 10 s. Every settle that is not a drive's (a wake's terminal, a reconcile, a stop route, the stop deadline) records the notice owed for the parent-notice alarm instead of posting it (onStart holds blockConcurrencyWhile). A durable mark records it delivered.

  1. onRunSettled is delivered, with its durable mark.
  2. A pending CP-52 continuation is started (fire-and-forget), then the alarm is rescheduled.

The owed-settle set (WA MR2 Rulings 60-62). Steps 4, 5a and 6 do not hold the next entry (R70), so a run's onRunSettled can still be running — or be lost with an evicted instance — after later runs committed. Every run-start commit records the run it replaces as owed (a DO storage key __helix_settle_owed:<runId>) in the SAME transactionSync, AFTER the store's run-start commit and from that run's post-commit row (so a run the commit finished in place is included), when:

  • the run ended (completed, failed, interrupted) and the session held that terminal before the commit (its K5, or the R77 announced commit);
  • it still owes something: an onRunSettled hook is configured and its delivery mark is absent, or it ended by its stop and its stop-cascade mark is absent, or it served a parent consult and its child-settled notice mark is absent (the marks are read with the synchronous KV API).

The entry snapshots the terminal (status, and errorDetail for a failure). Every wake and watchdog pass (skipped while a delivery is in flight in this instance) delivers each owed run from that snapshot: the cascade (a stop-ended run), the child-settled notice (deferred to its own retry record for the parent-notice alarm) and the hook, each behind its mark; then the entry is removed. An owed notice whose retry record could not be written keeps the entry for the next pass. The session's current run is the wake ladder's own redelivery; for a failed run its hook carries the session's committed errorDetail (when the session holds that terminal), like the owed snapshot. A request entry (/start, /resume, /retry, the /subagent routes) whose session's current run ended without its session commit (R77: the run announced failed / interrupted, the K5 never landed) first commits that terminal and settles the run (Ruling 62), so an entry never commits over a run whose outcome the session does not hold. It does so only once the entry is admitted (after the identity + type gate, materializeAgent, beforeEntry and an interrupt_previous stop): an entry refused before that writes nothing (I-D6), and the wake ladder's announced_terminal step commits the run instead. If that commit does not land, the entry commits nothing and answers by the failure table: an unclassified cause is a retryable 503 (transport_error), a permanent store error (a non-retryable HelixError or a corrupt row) its typed 500 / 409. A stranded live run an entry supersedes gets no onRunSettled('superseded') (FU-DO-SUPERSEDED-RUN-NO-SETTLE).

A drive the stop deadline detached settles nothing when its stuck call finally returns: the deadline already ended and settled its run, and only drive.detached_settle is logged.

Request/Response Transformation ​

transformRequest and transformResponse apply to the native /start only (the /subagent/* routes speak the remote protocol). The transformRequest result is validated like a native body. A thrown HelixError keeps its code, a Zod failure answers 400 validation_error, and any other throw is framework_hook_failed (transient, 503, unless its cause is classified non-retryable).

typescript
hooks: {
  transformRequest: async (rawBody, env) => {
    const body = rawBody as { prompt: string; conversationId: string };
    return {
      agentType: 'brainstorm',
      input: { message: body.prompt },
      sessionId: body.conversationId,
    };
  },

  transformResponse: async (response, { sessionId }) => {
    return {
      id: sessionId,
      status: 'processing',
    };
  },
}

Custom Endpoints ​

Add custom HTTP endpoints that are checked before built-in endpoints:

typescript
export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      /* ... */
    }),
  agents: registry,

  endpoints: {
    '/health': async (request, { executionState }) => {
      return Response.json({
        status: 'healthy',
        isExecuting: executionState.isExecuting,
      });
    },

    '/cancel': async (request, { stop }) => {
      const answer = await stop('interrupt', 'cancelled_by_operator');
      // `stopAnswerResponse` (from @helix-agents/core): 202 stopping, 200 resolved, 409 state_not_stoppable.
      const { status, body } = stopAnswerResponse(answer);
      return Response.json(body, { status });
    },

    '/custom-action': async (request, { env, logger }) => {
      if (request.method !== 'POST') {
        return Response.json({ error: 'Method not allowed' }, { status: 405 });
      }
      const body = await request.json();
      logger.info('Custom action', { body });
      return Response.json({ success: true });
    },
  },
});

EndpointHandler ​

typescript
type EndpointHandler<TEnv> = (
  request: Request,
  context: EndpointContext<TEnv>
) => Promise<Response>;

interface EndpointContext<TEnv> {
  env: TEnv;
  executionState: ExecutionState; // read-only
  logger: Logger;
  /** Stop the DO's run: 'interrupt' (soft, resumable) or 'abort' (hard). */
  stop(kind: 'interrupt' | 'abort', reason?: string): Promise<StopAnswer>;
}

stop is the /interrupt / /abort routes' mechanism and answers like them, at once: it resolves core's StopAnswer without waiting for the run to end (see Interrupting Execution):

  • { status: 'stopping', runId, kind }: recorded and signalled; the run is executing and ends at its next boundary, or at the stop deadline;
  • { status: 'interrupted' | 'failed', runId }: nothing executed the run (it was paused or stranded), and this call committed its terminal;
  • { status: 'not_stoppable', currentStatus }: no live run, nothing written (currentStatus: 'none' when the DO has no session).

reason defaults to the kind. It rejects only for a store failure. core stopAnswerResponse(answer) turns the answer into the routes' status and body (202, 200 or 409).

Request Format ​

The DO's native routes take an agentType and look the agent up in the registry or resolver.

/start body (StartBody) ​

Body state and statePatch on the DO's /start and /resume (native and protocol routes) are trusted: the DO sits private behind your app's API, and your own route decides what it forwards. Never expose the DO's protocol routes publicly.

typescript
interface StartBody {
  /** Agent type to execute (looked up in registry/resolver) */
  agentType: string;

  /** Input for the agent (a bare string is read as { message }) */
  input: {
    /** String message or array of UserInputMessage for multi-message input */
    message: string | UserInputMessage[];
    state?: Record<string, unknown>;
    history?: Message[];
  };

  /** Session identifier (required) */
  sessionId: string;

  /** Identity, fixed when the session is created */
  userId?: string;
  tags?: string[];
  metadata?: Record<string, string>;

  /** Set by a parent DO that spawns this DO as a sub-agent */
  parentSessionId?: string;
  rootSessionId?: string;

  /** Start the session as a branch of another session */
  branch?: { fromSessionId: string; checkpointId?: string; messageIndex?: number };
}

/** Individual message in a multi-message input */
interface UserInputMessage {
  role: 'user';
  content: string;
  metadata?: Record<string, unknown>;
  files?: FileInput[];
}

interface FileInput {
  data: string; // Base64 data URI
  mediaType: string; // MIME type
  filename?: string;
}

/resume body (DoResumeOptions) ​

typescript
interface ResumeBody {
  /** Must equal the session's agent type (else 409 state_agent_type_mismatch) */
  agentType: string;
  options: DoResumeOptions;
}

type DoResumeOptions =
  | { mode: 'continue' }
  | { mode: 'with_message'; message: string | UserInputMessage[] }
  | { mode: 'from_checkpoint'; checkpointId: string };

Each variant is strict: an unknown field answers 400 validation_error. Every field the schema accepts is honoured. To retry a failed run use /retry, to branch use a /start with branch, and to start a turn with edited state use a /start with input.state. Approvals go through /submit-tool-result.

/retry body ​

typescript
interface RetryBody {
  /** Must equal the session's agent type */
  agentType: string;
  checkpointId?: string;
  message?: string | UserInputMessage[];
}

AgentServer HTTP Endpoints ​

The AgentServer exposes the following HTTP endpoints:

EndpointMethodDescription
/startPOSTStart new agent execution
/resumePOSTResume paused/interrupted agent
/retryPOSTRetry a failed agent
/interruptPOSTStop the live run, soft (ends interrupted); answers at once
/abortPOSTStop the live run, hard (ends failed); answers at once
/statusGETGet current execution status
/sseGETSSE streaming connection
/historyGETGet historical stream chunks
/usageGETGet usage data (tokens, tool calls)
/messagesGETGet conversation messages (paginated)
/snapshotGETGet UI state snapshot for initialization
/workspaceGETOperator workspace registry snapshot
/chatPOSTv7: Unified entry — fresh / continue / resume / attach
/chat/{id}/streamGETv7: Re-attach to in-flight stream after submit/interrupt
/chat/{id}/submit-tool-resultPOSTv7: Durable submit for client/approval tools

The three /chat/... routes form the v7 stateless HITL chat protocol, wired via the chatHandler config field on createAgentServer (typically handleChatStream from @helix-agents/ai-sdk). See v6 → v7 migration guide for the full protocol. The DO has no chat stop route of its own: stop a chat session with CloudflareChatHandler.stop (see Stopping a chat session from your Worker), which posts the DO's /interrupt or /abort.

Why /submit-tool-result auto-continues the run on DO

A Durable Object has no long-running process between requests — each request wakes the DO, runs, and the DO is free to be evicted afterward. So when a client-tool result is submitted, /submit-tool-result does the durable write and, once the submit completes the batch, runs a continue drive inline to advance the suspended run (the answer waits for that drive's entry, see below). This is the only mechanism that can advance the run on DO; removing it would strand the run (the result would sit undrained until some unrelated request happened to wake the DO). This differs from JS / Temporal, where a follow-up resume() request drives continuation, and matches Cloudflare Workflows, whose submit starts a __submit__ continuation instance itself, and DBOS, which auto-continues via its live DBOS.recv(). See the client-tool continuation model for the full per-runtime breakdown. The follow-up /chat/{id}/stream (or the AI SDK's helixSAW-driven request) simply re-attaches to the already-running stream to read the continuation live — it does not re-drive it, and it does not emit data-stream-resync.

The submit route answers typed and writes nothing on a refusal. Before it writes anything, /submit-tool-result resolves the owning agent for the output-schema check (a purpose: 'lookup' resolver call, reason: 'submit_validation') and materializes the continuation the submit would start (one materialization and one pre-minted run id, reused by that continue drive). A failure of either answers typed with the core status table (an unclassified failure is 503) and the result is not stored. The executor owns the whole submit: a sub-agent DO validates a result for its own call against its own agent, and a root DO forwards a submit another DO's session owns and relays that DO's typed refusal verbatim. Other answers: 200 accepted / already_completed, 400 INVALID_RESULT for a result that fails the tool's outputSchema (its Zod issues only with verboseValidationErrors, as for validation_error's details), 404 unknown_tool_call (with reason: 'run_stopped' when the call's run was stopped, WA MR2 §7.1), 409 state_run_superseded for a late submit to a superseded run. A refusal of the submit itself is typed (DI-51): a result over the limit (whatever its Content-Length) or a body over maxResultBytes × 4 is the 413 validation_payload_too_large ({ error, code, retryable: false, cause: 'tool_result' | 'request_body', limitBytes, sizeBytes }); invalid JSON, JSON nested too deeply or a body that is not a valid submit is a 400 validation_error with a fixed message (Zod details only with verboseValidationErrors); nothing is stored. A submit that completes its batch is answered accepted only once the continuation's entry has started (its run-start commit landed and the submitted results were drained) or another drive owns the run, so a client's read after the answer sees the call consumed and can reattach to the live run (CP-53). That includes a submit landing while the suspending run is still registered (e.g. inside onAgentSuspended): it waits for that run to settle and its pending continuation to enter (CP-52). The whole wait is bounded by concurrentEntryTimeoutMs (default 30 s): past it the submit answers 500 transport_error, non-retryable because the result is stored; its message says the continuation was not confirmed (it may not have started yet, or may already have consumed the result). The continuation is not aborted, and the unconsumed-submit alarm (re-armed every 1 s while a fully submitted batch is unconsumed) continues the run, so a client re-anchors through the snapshot (useHelixChat().retry()) and never re-submits. A forwarded submit answers after the owning DO's continuation entry, and waits at most the bound plus 1 s for that DO; past it, the owning DO's outcome is unknown, so the answer is a retryable 503 transport_error (a re-submit answers already_completed if the result landed). A partial batch answers at once and starts nothing. A duplicate submit (the call's result is already stored or consumed) answers 200 already_completed at once, starts nothing and waits for no continuation; it never replaces the stored result. already_completed means this call's result is recorded, not that the run or its continuation has finished. A continuation entry that fails answers from the wake row of the failure table: lost (another drive owns or continues the run) → 200 accepted; refused / fatal → the run's typed failure (the ErrorDetail the wake ended it with); transient (the run is re-armed and continues) → its non-retryable row, keeping the code, whose message says to re-anchor through the snapshot. Every such answer is on the committed row (core typedErrorResponse(..., { inputCommitted: true })), never 503.

The 200 answer says what the submit caused (W1 MR2b). accepted / already_completed carry a required continuation (core SubmitContinuation), mapped from the wake: new_run { runId, startSequence? } when this submit's wake started the run (startSequence when its record was read back); deferred { blockingRunId } when a drive registered on the DO that is executing (its run running, or not committed yet — never a drive whose run has ended or suspended but has not settled: the same run's-truth rule as the concurrency gate) or a live run of the session carries the answer — running, or suspended_* after it consumed the batch, never an announced-terminal run; consumed when another entry already drained the answer; otherwise 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, which is known without the reads: then it answers deferred on that drive's run. A partial batch answers awaiting_batch. Every gate that decides whether a stored answer is owed its continuation (the submit's wake, the wake request, the CP-52 pending wake, the execution watchdog, wake recovery) uses one rule, isSubmitContinuationOwed: the session paused behind a live current run whose entries are ready at their last submittedAt (core isSubmittedBatchReadyAtLastSubmit), the rule core's router decides new_run with. handleChatStream acts on the kind (an attach for new_run / deferred, a resume() for caller_resumes).

A new message over pending client calls is one entry (W1 MR2b). /resume accepts abandonPending: true on a with_message body; DOFrontendExecutor declares supportsAbandonOnResume, so handleChatStream's path 4 sends ONE entry: the embedded runtime-js resume() answers every unanswered call client_tool_abandoned, commits the results before the message and runs one model call. Any with_message resume on a session PAUSED on client-tool calls alone puts the drained results before the message the same way; a crashed or interrupted session that still holds answered calls, and a suspension that also awaits children, still put the message first (FU-JS-WITH-MESSAGE-AFTER-CRASHED-DRAIN-ORDER, FU-WITH-MESSAGE-MIXED-CHILDREN-ORDER). A retry() of such a resume needs no options.message when the run failed before committing a step (core planRetry rule 4); after committed steps it needs one, like any retry.

Fixed: a submit for one call of a batch no longer continues the run without the others (CP-82)

A submit wakes the run only once EVERY awaited call of the batch has its result (the DO's isSubmitContinuationOwed, over core isSubmittedBatchReadyAtLastSubmit; a deadline that lapsed by the last submit counts as that call's timeout result). Until then the session stays paused, and a /resume on a partial batch answers 409 state_not_resumable. Before this release, the first accepted submit woke the run, the resume's orphan drain failed the unanswered calls as runtime_restarted, and their later submits returned already_completed. See Submit every call of a batch before resuming and CP-82 in the conformance findings.

A client-tool call whose browser left times out at its deadline, with no request (S-A). Every pending client-tool call carries a durable deadlineAt: the tool's clientToolTimeoutMs, else the agent's. With neither set, the call (a client tool or an approval) never expires (FU-CLIENT-TOOL-UNSET-TIMEOUT-NEVER-EXPIRES). The DO's client-tool-deadline alarm subscriber keeps the DO alarm at the earliest deadline of the calls the suspended current run still awaits (unsubmitted, not a pre-submit stub, of that run). At it, with no browser, no submit and no resume, the DO:

  1. re-reads the run as a pre-check (an ended, superseded or stopped run is left alone);
  2. marks every lapsed, unsubmitted call client_tool_timeout (the resolver's expireOverdue, fenced on the suspended run). The write is stop-guarded in the store's own atomic step (saveState { fenceRunId, refuseIfStopped: true }, CP-173). A lapse after a stop is new work, so a stop recorded before the write wins, even one that lands while the expiry waits on the resolver's lock. Nothing is written, the DO settles the recorded stop, the calls stay unpaired for the next turn's heal, and a late submit answers run_stopped;
  3. continues the run exactly as a submit that completed the batch would (isSubmitContinuationOwed, then a continue drive): the continuation run drains the timeouts (tool_end with errorCode: 'client_tool_timeout', one tool result per call) and the model step runs.

A deadline that cannot be expired ends the run typed (CP-174). A failed read or expiry write goes through the drive's failure table and the crash-loop budget it shares with wake recovery (maxWakeResumeAttempts, default 5). It is never retried forever:

  • A permanent failure of the expiry write (a non-retryable store refusal) ends the suspended run failed at once, with the classified errorDetail. A recorded stop still wins: the run then ends by its stop.
  • A row the deadline cannot read is the exception. The terminal write re-reads the same row, so it cannot land either, and the run is never ended. With a corrupt session row, the alarm's own read is fatal. failCommittedRun({ requireSessionCommit: true }) rethrows the permanent load error, its session write answers not_committed, and Ruling 34's gave_up re-arms every 30 s, with the run left paused. A row that classifies transient (the DO's own InvalidCustomStateError, or a run row whose status fails its schema) first spends the budget, then ends at the same gave_up (FU-DO-DEADLINE-CORRUPT-SESSION-ROW-GAVE-UP).
  • A transient or unclassified failure is retried 1 s later, once per attempt. The attempt that spends the budget ends the run failed with framework_auto_resume_exhausted, whose cause is the last failure.
  • A terminal write that does not land either is retried every 30 s, and logged at error once (client_tool.deadline_gave_up).

The failed run's unanswered calls are dropped from the pending map with the failure, so a /retry or a new turn pairs them unpaired_on_continuation. A call the client had already answered stays pending: a /retry drains its answer, and a new turn settles it with that answer in its own run-start commit (RM-81). onRunSettled fires once for the failed run. Before this release, a paused run whose expiry kept failing stayed paused, re-armed every 30 s or every 1 s, and the client was never told.

The deadline and the alarm are both durable, so the deadline fires after an eviction too; a fresh instance woken by a request re-arms a lapsed deadline at once. In a mixed batch, the lapsed calls time out and the run continues once the rest is answered or lapses. The alarm and a submit for the same call are mutually exclusive: both write under the resolver's session lock and set the call's submittedAt only when it is unset, so the first one wins (a submit after the timeout answers already_completed; a timeout after a submit skips the call). The chat handler's deadline sweep and a resume's drain still expire lapsed calls on their own requests, under the same rule; when the chat handler's sweep writes first, the call's recorded code is client_tool_deadline_exceeded, not client_tool_timeout (FU-CLIENT-TOOL-TIMEOUT-TWO-CODES, open; !409 makes that sweep record client_tool_timeout too). Before this fix the DO had no deadline alarm (d7fab7fb2d removed it, relying on those request-time sweeps), so a run whose browser left stayed paused until an unrelated request reached the DO. CF Workflows fires deadlines from a __deadline__ watcher instance, and plain runtime-js from an in-process timer (CP-138); the DO turns runtime-js's timer off, because its alarm survives eviction. See the client-tools guide.

Fixed: dropped stream frames on re-attach and replay (RM-51)

Before @helix-agents/runtime-cloudflare 5.20.0, DOStreamManagerClient.createResumableReader yielded only the first SSE frame of each network read, so re-attaches, page-refresh attaches and finished-run replays could arrive truncated when the DO's writes were coalesced. This is fixed in 5.20.0; upgrade if you see truncated attached or replayed messages. See RM-51 in the conformance findings.

The /workspace route is the wire-level companion to round-5 D6's JSAgentExecutor.getWorkspaceRegistry(sessionId) accessor. See the @helix-agents/agent-server README on GitLab for the response shape and the 404 disambiguation between RUNTIME_NO_WORKSPACE_SUPPORT and WORKSPACES_NOT_FOUND. The DO base class does NOT auto-wire this route today (the DO constructs JSAgentExecutor instances per-request, not statefully); operators wanting introspection from inside a DO subclass should follow the in-process pattern in docs/workspaces/runbook.md.

Starting Execution ​

typescript
// Request with a string message
const response = await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher', // Agent type from registry
    input: { message: 'Hello' }, // Input message (string or UserInputMessage[])
    sessionId: 'session-123', // Session ID for conversation continuity
    userId: 'user-456', // Optional: user context
    tags: ['test'], // Optional: tags
    metadata: { key: 'value' }, // Optional: metadata
  }),
});

// Request with multiple messages (context injection, file attachments)
const response = await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: {
      message: [
        {
          role: 'user',
          content: 'Background: user is on the enterprise plan',
          metadata: { source: 'system' },
        },
        { role: 'user', content: 'What features do I have access to?' },
      ],
    },
    sessionId: 'session-123',
  }),
});

// Response (200), sent once the run-start commit landed
interface StartResponseBody {
  sessionId: string;
  streamId: string;
  runId: string;
  status: 'started' | 'resumed'; // 'resumed' on /resume and /retry
  startSequence?: number; // the stream head at the run-start commit
}

A refused entry answers a typed error instead; see Entry failures.

Resuming Execution ​

typescript
// Continue a paused or interrupted run
await stub.fetch('/resume', {
  method: 'POST',
  body: JSON.stringify({ agentType: 'researcher', options: { mode: 'continue' } }),
});

// Resume with a new user message
await stub.fetch('/resume', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    options: { mode: 'with_message', message: 'Also cover pricing' },
  }),
});

// Rewind to a checkpoint and run from there
await stub.fetch('/resume', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    options: { mode: 'from_checkpoint', checkpointId: 'cp-123' },
  }),
});

/resume answers like /start: started (with status: 'resumed') once its run-start commit landed, or a typed error. A paused session still awaiting client-tool results answers 409 state_not_resumable, whose message names the awaited call ids first; a completed or failed session answers 409 state_not_resumable as well (use /retry or a new /start turn). A from_checkpoint rewind is not blocked by a partial batch: the rewound run re-suspends if a restored call is still unsubmitted.

Interrupting Execution ​

typescript
// Soft stop: the run ends `interrupted` (resumable) at its next boundary
await stub.fetch('/interrupt', {
  method: 'POST',
  body: JSON.stringify({ reason: 'user_requested' }),
});

// Hard stop: the run ends `failed` with `framework_cancelled`
await stub.fetch('/abort', {
  method: 'POST',
  body: JSON.stringify({ reason: 'timeout' }),
});

Every stop on the DO (/interrupt, /abort, /subagent/:type/interrupt|abort, EndpointContext.stop, concurrentEntry: 'interrupt_previous', CloudflareChatHandler.stop, and a host's stop through AfterEntryContext.handle) is one mechanism: core stopRun on the session's current run, or on the run the call names (below). The stop is recorded durably as a run-scoped StopRequest, the run is signalled, and the call answers at once. It never waits for the run to end; the run's end is observed on its stream or /status.

The session's current runWhat the stop doesAnswer
running, a drive executing itrecords the stop, aborts the drive's signal with the StopRequest as the reason, arms the stop-deadline alarm202 { status: 'stopping', runId, kind }
running with nothing executing it, or suspendedrecords the stop and commits its terminal now: K5 interrupted (or failed with framework_cancelled), pendingClientToolCalls cleared, the run ended, the stream ended (run_interrupted) or failed; then the run is settled200 { status: 'interrupted' | 'failed', runId }
no live run (completed, failed, interrupted)nothing is written409 { error, code: 'state_not_stoppable', currentStatus }
no sessionnothing is written404 { error, code: 'state_session_not_found' }
a store failurenothing durable was promisedtyped (core typedErrorResponse), 503 when retryable
  • The body is { reason?: string, runId?: string }. The reason defaults to core's DEFAULT_INTERRUPT_REASON ('User requested interrupt') or DEFAULT_ABORT_REASON ('Aborted by user'), the same default every runtime and agent-server use. Malformed JSON or a body that fails the schema answers 400 validation_error and records nothing.
  • A stop names its run. With runId the stop targets that run, not whatever run is current: a DO client handle (DOExecutionHandleImpl, the DOFrontendExecutor handles) sends the run it was created for, so a handle from an earlier turn never stops a newer run. A reconnect handle (getHandle) over a session that has no run names none: its abort() / interrupt() send nothing and resolve, as a 409 state_not_stoppable would. A named run that is no longer live is answered without any write: 200 with its terminal when it ended interrupted / failed, else 409 state_not_stoppable; such an answer resets no crash-loop record and settles nothing (this call ended nothing). A run-targeted stop cancels no in-flight entry (that entry is not its run). Without runId (a raw HTTP caller, the chat stop) the stop targets the session's current run. The /subagent routes answer the same 2xx bodies and put a refusal in the remote-protocol envelope (errorCode: 'state_not_stoppable' with code ALREADY_COMPLETED for a completed session, else INVALID_REQUEST; 404 NOT_FOUND).
  • The 202 says only what is durable. The run ends at its next boundary: an interrupt keeps every committed step and discards the step in flight; an abort fails the run. Every commit of a stopped run is refused by the store (refuseIfStopped), so a stop recorded before the run's terminal commit wins: an adapter or tool that ignores the signal and returns normally still ends the run by the stop, never completed (CP-59).
  • The record. A stop names its run, so a stale stop never stops a newer run, and nothing clears it. An abort upgrades a recorded interrupt (keeping the earlier deadline); an interrupt never downgrades an abort, and a repeated stop of the same kind keeps the first. A 200 can also report a run that ended interrupted or failed on its own just before the stop.
  • A stopped paused run. Its pending client-tool calls are discarded: a late submit for one gets 404 { status: 'unknown_tool_call', reason: 'run_stopped' } (WA MR2 §7.1), and the next turn's continuation heal pairs it with a not-executed result. A discarded call gets no per-call tool_end; the stream's run_interrupted ends it.
  • A submit over a recorded stop. The submit reads the stop of its call's run before it stores anything (core refuseSubmitIfStopped). A stop recorded but not yet resolved (for example by another process) is resolved by the submit through the DO's own stop path (re-signal a drive that executes the run, else the stop's K5 now; onRunSettled and the cascade as for a stop route), and the answer is 404 { status: 'unknown_tool_call', reason: 'run_stopped' }: nothing is stored and no continuation starts.
  • A submit that loses the race to a stop. A submit that stored its result just before a stop ended the run (the stop's K5 discarded the stored answer before the continuation entered) answers the same 404 { status: 'unknown_tool_call', reason: 'run_stopped' }, never accepted / consumed.
  • with_message over a recorded stop. The recorded stop on the paused run is settled first, before the partial-batch gate (Ruling 57): the run ends interrupted (its onRunSettled delivered, as for a stop route's own terminal) and the entry re-plans once as a NEW turn, with or without abandonPending (a no-op over the now-ended session). The same holds for a /start or /retry that meets a live run with a recorded stop. An ABORT ends the session failed (framework_cancelled), and /resume then answers 409 state_not_resumable (/retry is the path). If the DO is evicted before that settle's delivery mark is written, the next wake redelivers it (the owed-settle set, the settle path). Once the entry committed, a stop is an ordinary stop of its running run (202 stopping).
  • A stopping companion. /status reports stopping: true while the current live run has a recorded stop; a parent's companion__sendMessage then answers { delivered: false, reason: 'child_busy' } without sending.
  • An entry in flight that has not committed its run is cancelled first: it fails framework_cancelled and writes nothing.
  • Children. A run a stop ended stops its live children with the stop's kind (see the settle path): the children that run started, reopened or re-consulted, and those an earlier run left running (a background companion). A child a LATER run started, reopened or re-consulted is never reached, so a delayed or redelivered cascade of an old run never stops the current run's children. The DO's sub_session_refs.parent_run_id (migration 14) records that run: the store stamps the session's current run when a ref is added, when a child is reopened (back to running from a non-live status) and when a child is re-consulted (beginChildConsult; a re-consult belongs to the run that began it, and a restore of the consult puts the prior run back, O9).
  • A parent's stop of its child names the delegation (CP-217). /subagent/:type/interrupt|abort requires exactly one of parentToolCallId and dispatchId (neither or both is a 400 validation_error; the parent sends no run id). A companion's stop names its consult (parentToolCallId, the parent's tool call that began the consult): the child cancels an uncommitted entry of that consult first (the scoped cancelPendingEntry), then resolves its current run and records the stop only when that run serves the consult (RunMetadata.parent.toolCallId; a submit continuation inherits it, and it survives an eviction). A remote / ephemeral child's stop names its dispatch (dispatchId): the child checks it against its stored ChildBinding, then cancels any uncommitted entry and stops its current run (the session is the delegation). A run of another consult, a later one included, a direct entry, or another dispatch's session is never stopped: the answer is 409 state_stop_target_mismatch and nothing is written (all through core resolveDelegationRun). A native route's body may also name a delegation; one without (a client's chat stop, a handle) keeps the run-targeted rules above.
  • A companion terminate (companion__terminateChild) answers { terminated: true } only once the child DO recorded the abort (a stop answer, 409 state_not_stoppable or a typed 404: nothing to stop). Any other answer, a 503 or a timeout, fails the tool call ("Could not terminate child agent ...") and puts the child's ref back as it was, so its real outcome is still delivered.
  • An owed continuation names its run. A submit's continuation (and the CP-52 / unconsumed-submit wake) is owed to the suspended run it was decided for. Its entry continues only that run, still current and suspended; a stop that ended the run while the wake was materializing or waiting for the claim makes the continuation the silent loser (no new run, no continuation heal, no model call).
  • /retry after a stop of a paused run. The stop discarded the run's pending calls, so the retry pairs the kept prefix's trailing unpaired calls with not-executed results before the retry's message (the C5 heal): the model never sees an unpaired call.
  • Eviction. The stop is durable before the answer. A wake that finds a live run with a stop record settles it from the record (settle_stopped) and never re-drives it.
  • Hooks. /interrupt and /abort never fire hooks themselves: the run's own settle delivers onRunSettled.
  • A handle stop. A host's ctx.handle.interrupt() / abort() in afterEntry records the same StopRequest with the DO's stopDeadlineMs and re-arms the alarm, so a run held by a tool that ignores its signal still ends at the deadline.

A blocking companion and the parent's stop ​

A parent stop never writes a companion ref (I-C8): the ref ends only on the child's committed word (its child-settled notice and the parent's verified read, below).

  • The blocking park answers parent_stopped. A blocking companion__spawnAgent parked when its parent run is stopped posts the stop to the child once, naming ITS OWN call's consult, and answers { name, status: 'parent_stopped', kind } (kind is the stop's kind). The parent's step ends by its aborted signal, so the answer is never committed to the transcript, and the ref stays running. Before, the park wrote the ref interrupted or failed from the parent's intention, whatever the child then did.
  • Every parent stop post is classified by type. Confirmed is exactly a stop answer (202 / 200), 409 state_not_stoppable or a typed 404 no session. Anything else is unconfirmed: a 503, the timeout or a transport failure (ChildTransportError), a 2xx that is not a stop answer (RemoteProtocolValidationError), a retryable error, or a non-retryable typed refusal. Never from error text.
  • An unconfirmed child is owed its stop, on its ref (CP-217, one model for companions and remote children): SubSessionRef.stopOwed { kind, at, target, reason, owedByRunId, attempts?, nextAttemptAt? }, written by a compare-and-set on the status and debt the cascade read, so a debt never ends a ref. A refusal is owed like the rest and logged once at error (stop.child_stop_refused). A child that already owes a stop is not posted again by a cascade (the alarm is its single pacer); an abort only upgrades an owed interrupt. A child no route reaches (no subAgentNamespace, no transport) is owed too.
  • The stop-cascade alarm retries. It fires at the earliest due debt on the DO's refs and retries every due one through core retryOwedRemoteStops: a companion through its own stop route, naming the debt's consult; a remote child through its agent's transport, naming its dispatch. Confirmed (a stop answer, state_not_stoppable, state_stop_target_mismatch — the child's run serves another consult — or a typed no session) clears the debt; anything else backs it off by the owedStopSchedule (default 30 s doubling to 1 h).
  • The retry window. Past owedStopSchedule.windowMs (default 24 h, from the debt's at) the debt is abandoned: abandonedAt stamped, one warn stop.remote_stop_owed_abandoned, the record kept, and the ref left running (only the child's own outcome ends a companion's ref; companion__terminateChild ends it only once the child DO confirms the abort, and fails the tool call, leaving the ref as it was, while the child stays unreachable). The first retry of a debt is baseMs after the first stop; the previous companion schedule (1 s doubling to 5 min, 1 h budget) is gone.
  • A re-consult belongs to the run that began it (O9): an earlier run's cascade, redelivered, never stops the consult of a later run.

A remote ephemeral child is owed exactly like a companion; see the remote agents guide.

Stop deadline ​

stopDeadlineMs (default 30 000 ms, both kinds) is recorded on the stop as deadlineAt = requestedAt + stopDeadlineMs, and the DO's stop-deadline alarm fires at that absolute time, so it survives an eviction. A run still live at its deadline (a tool or a provider call that ignores its abort signal) is ended there with the stop's terminal:

  1. the stuck drive is detached: its registration and producer claim are released, so a new entry is accepted at once (drive.stop_deadline_detached is logged at warn);
  2. the stop's terminal is committed (commitStopTerminal), fenced on the run, so an outcome another writer committed first stands;
  3. the run is settled (onRunSettled once per (runId, status), the cascade, the alarm tail).

Every stop the DO records gets the deadline, a stop through AfterEntryContext.handle included (the DO passes its stopDeadlineMs to the runtime-js executor as its stopDeadline option).

A JavaScript promise cannot be killed. The detached call keeps its CPU and memory until it returns or the DO is evicted. Every write it attempts is refused by the store (its run is ended, and stopped), and when it returns its drive settles nothing (drive.detached_settle). A request entry detached this way answers 409 state_commit_stopped: its input committed, then its run was stopped. A deadline whose terminal cannot land for a transient reason retries in 1 s; for a permanent one, a stranded active run ends failed with the classified error (as the wake's rule), and a committed suspension is never overwritten.

Stopping a chat session from your Worker ​

createCloudflareChatHandler (from @helix-agents/ai-sdk/cloudflare) exposes the stop as chat.stop, a thin pass-through to the session's /interrupt or /abort route:

typescript
app.post('/chat/:id/stop', async (c) => {
  // Authorise the caller for this session first: the stop route has no admission hook.
  return chat.stop({ sessionId: c.req.param('id') /* kind: 'interrupt' | 'abort', reason */ });
});
AnswerBodyMeaning
202{ status: 'stopping', runId, kind }Recorded and signalled; the run executes
200{ status: 'interrupted' | 'failed', runId }Recorded and resolved by this call
409{ error, code: 'state_not_stoppable', currentStatus }No live run; nothing written
404{ error, code: 'state_session_not_found' }No such session
400{ error, code: 'validation_error' }Unknown kind, or a sessionId that is empty or cannot travel in a header; the DO was not called
503{ error, code }The DO's typed 503 as it answered, or the handler's own retryable transport_error when the DO could not be reached (nothing durable was promised)

The answer is immediate and never carries the run's end. The run's end is observed on the chat stream (the client's own SSE reader, which ends with data-run-interrupted for an interrupt, or a failed end with framework_cancelled for an abort) or the snapshot. The snapshot's status reads ended for an interrupted run (its vocabulary has no interrupted), so a client discriminates on the stream's data-run-interrupted marker or the DO /status. kind defaults to interrupt.

Getting Status ​

typescript
const response = await stub.fetch('/status');
const status = await response.json<AgentStatusResponse>();

interface AgentStatusResponse {
  sessionId: string | null;
  status: string;
  // v6: 'active' | 'completed' | 'failed' | 'paused' | 'interrupted'
  // v7 adds three suspended_* variants for the stateless suspension model:
  //   'suspended_client_tool'        — pending client-executed tool
  //   'suspended_awaiting_children'  — sub-agent suspension cascade
  //   'suspended_step_partial'       — mixed phase-1/phase-2 batch
  stepCount: number;
  output?: unknown;
  error?: string; // sanitized (core sanitizeErrorMessage), the same text as errorDetail.message
  errorDetail?: ErrorDetail; // message and cause sanitized; code / category kept
  checkpointId?: string;
  isExecuting: boolean; // true if currently running
  awaitingClientTool: boolean; // pending client-executed tool calls
  stopping?: boolean; // WA MR2: the current live run has a recorded stop (it is ending)
}

Getting History ​

typescript
const response = await stub.fetch('/history?fromSequence=0&limit=100');
const history = await response.json<{
  chunks: Array<{ sequence: number; chunk: StreamChunk }>;
  hasMore: boolean;
  latestSequence: number;
}>();

Getting Messages ​

Fetch conversation messages with pagination:

typescript
const response = await stub.fetch('/messages?offset=0&limit=50');
const data = await response.json<{
  messages: UIMessage[];
  total: number;
  offset: number;
  limit: number;
  hasMore: boolean;
}>();

Getting Snapshot ​

Get the session's checkpoint-anchored view (the SnapshotView wire), for example to initialize frontend state on a page refresh:

typescript
const response = await stub.fetch('/snapshot');
if (response.status === 404) {
  // { error: 'Session not found', code: 'state_session_not_found' }
}
if (!response.ok) {
  // { error, code, cause? } with core typedErrorResponse's status: 409 for a non-retryable read
  // (code 'state_history_incomplete' / cause 'corrupt_row' or 'short_read'), 503 for a retryable one,
  // 500 for an unknown error
}
const snapshot = await response.json(); // the SnapshotView wire (serverSnapshotResponseSchema())
// {
//   sessionId, status: 'active' | 'paused' | 'ended' | 'failed', // derived: a live run reads active
//   streamStatus,      // the stream's own: 'active' | 'paused' | 'ended' | 'failed' | 'none' (no stream)
//   head,              // the stream's latest sequence at read time (-1: no stream)
//   streamSequence,    // the cursor: everything committed up to it is in `messages`
//   legacy,            // true for a pre-upgrade session (see below)
//   checkpointId, stepCount,
//   checkpoint: { id, kind, runId, keepCount, stepCount, messageCount, streamSequence } | null,
//   messages,          // raw Message[]: the committed rows [0, checkpoint.messageCount)
//   state,             // custom state at the cursor
//   run: { runId, status, startUIMessageCount, startSequence, turn } | null,
//   streamId, sessionStatus, createdAt, updatedAt, agentType, version,
//   error?, errorDetail?, interruptContext?, streamError?, streamErrorDetail?, streamFinalOutput?,
//   pendingClientToolCalls?, clientToolCallOwnership?, completedClientToolCalls?,
//   rootSessionId?, parentSessionId?,
// }

The handler reads the stream's status and head and the store's view in one synchronous transactionSync (DOStateStore.readSnapshotViewSync: session → current run → latest checkpoint → its message prefix), so no commit can interleave. Nothing uncommitted is merged in: content past the cursor reaches a client through the resumed stream. status follows the current run (a live run reads active / paused even before its stream is claimed). A session whose latest checkpoint predates this release (kind: null) is served in legacy mode (the complete live log, the stream head as cursor) until its first new commit. ?includeReasoning=false strips reasoning.

Frontends do not read this wire directly: createCloudflareChatHandler().getSnapshot() reads it through DOStateStoreClient and returns the UI-shaped FrontendSnapshot (buildSnapshot). Answer a snapshot route with snapshotNotFoundResponse() / snapshotErrorResponse(error) from @helix-agents/ai-sdk/cloudflare.

?messages=none (the head read). The same read and the same wire with messages: []: every other field is identical, and the DO reads no message row. A run row without its startUIMessageCount reads only the rows that field is derived from. The head / status / run readers use it: DOStateStoreClient.loadState / getLatestCheckpoint / getCurrentRun, DOStreamManagerClient.getStreamInfo (which every resumable reader opens with) and the DOFrontendExecutor pre-flights. So a long conversation is read only by the transcript reads (readSnapshotView / getSnapshot, and /messages), never to answer a status, cursor or run question. Because it reads no row, a corrupt or missing transcript row (state_history_incomplete, corrupt_row / short_read) does not fail these readers; the full snapshot still answers it.

Breaking: the /snapshot wire changed

timestamp and the top-level runId / startSequence / startMessageCount / startUIMessageCount / turn are removed (the run's fields are under run). A missing session is a 404 (it was 200 null) and a read failure a typed 500. Producers and consumers of this wire must deploy together. See the upgrade guide.

This is not the protocol snapshot

GET /snapshot on the DO is the DO-native, chat-shaped snapshot used by same-worker frontends: it reads the sessionId from the DO's own execution state or stored session (ignoring any ?sessionId=), answers 404 when the DO holds no session, and bundles messages + root state + stream status for UI hydration.

It is not the protocol snapshot. Cross-service consumers use the bridge route {basePath}/:agentType/snapshot?sessionId= exposed by createRemoteAgentWorkerHandler, which is cold-DO-safe (the sessionId comes from the query), SQL-validates the session and its agentType, returns a protocol-shaped RemoteSnapshotResponse ({ sessionId, streamId, state, streamSequence, oldestRetainedSequence, stepCount, checkpointId? }), is checkpoint-pinned, and answers an unknown session with a 404 RemoteAgentErrorResponse envelope (code: 'NOT_FOUND') rather than null.

See One name, three snapshots.

Streaming ​

WebSocket Streaming ​

WebSocket connections survive DO hibernation and provide real-time bidirectional communication:

typescript
// Client-side
const ws = new WebSocket(`wss://your-worker.workers.dev/ws/${sessionId}?fromSequence=0`);

ws.onopen = () => {
  console.log('Connected');
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);

  if (data.type === 'chunk') {
    handleChunk(data.chunk);
  } else if (data.type === 'end') {
    console.log('Stream ended', data.finalOutput);
    ws.close();
  } else if (data.type === 'fail') {
    console.error('Stream failed', data.error);
    ws.close();
  } else if (data.type === 'pong') {
    // Response to ping
  }
};

ws.onerror = (error) => {
  console.error('WebSocket error', error);
};

// Send ping to keep alive
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'ping' }));
  }
}, 30000);

SSE Streaming ​

Server-Sent Events for one-way streaming (simpler, wider browser support). The DO's /sse speaks stream wire contract v2: named SSE events whose data: carries no type discriminator, so listen per event name. Every connection ends with exactly one terminal frame (end, fail or truncated), then the body closes:

EventDataKind
chunk{ sequence, chunk }, SSE id: = the sequencecontent
end{ status, output?, state?, usage? }terminal
fail{ error, errorDetail?, recoverable }terminal
truncated{ floor, errorDetail } (code TRUNCATED)terminal
rewound{ atStep, atSequence?, minSequence? }marker
caught-up{ head }marker
typescript
// Client-side
const eventSource = new EventSource(
  `https://your-worker.workers.dev/stream/${sessionId}?fromSequence=0`
);

eventSource.addEventListener('chunk', (event) => {
  // data: { sequence: number; chunk: StreamChunk }   (id: <sequence>)
  const { sequence, chunk } = JSON.parse(event.data);
  handleChunk(chunk);
});

eventSource.addEventListener('caught-up', (event) => {
  // data: { head } — the replay is done; live frames follow
});

eventSource.addEventListener('end', (event) => {
  // data: { status, output?, state?, usage? }
  const { status, output } = JSON.parse(event.data);
  console.log('Complete', status, output);
  eventSource.close();
});

eventSource.addEventListener('fail', (event) => {
  // data: { error, errorDetail?, recoverable }
  const { error, errorDetail, recoverable } = JSON.parse(event.data);
  console.error('Failed', errorDetail?.code ?? '', error, recoverable);
  eventSource.close();
});

eventSource.addEventListener('truncated', (event) => {
  // data: { floor, errorDetail } — refetch the snapshot, resume from its streamSequence
  eventSource.close();
});

// SSE heartbeats are sent automatically every 30 seconds

A non-browser client should decode with core createSSEFrameDecoder + decodeStreamFrame and classify the end with classifyStreamTerminal (see Stream wire frames). EventSource reconnects on its own after any close, so it cannot tell a drop from an end: close it on a terminal frame yourself.

The end frame is the unified protocol end frame. SSE end carries { status, output?, state?, usage? } — status is 'completed' | 'paused' | 'interrupted' (the DO writes the persisted end status, so a stopped run's end is interrupted), and state / usage ride along only when the producer's terminal persist committed (persist-before-emit). Only status: 'completed' may be folded as success — a 'paused' / 'interrupted' end must route through /status recovery, never be treated as completed-with-null. The WebSocket end message is unchanged and still carries the core StreamEvent shape ({ type: 'end', finalOutput }), so WS consumers are unaffected.

A body with no terminal frame is a drop. The DO closes a connection without a terminal frame only when it evicts it (a slow consumer, staleness, the connection cap) or crashes. Every reader treats that as a drop: it throws the retryable StreamDropError and re-attaches from its cursor, never ends cleanly.

fromSequence is exclusive everywhere: ?fromSequence=N (and the equivalent Last-Event-ID: N header, which the DO honors on reconnect) replays chunks with sequence > N. Pass a snapshot's streamSequence straight through as fromSequence; never derive one from the -1 sentinel.

caught-up { head }. On an active stream the route registers the connection for live broadcasts, replays the history up to its head, then writes caught-up { head }: the end of the replayed backlog (a reader's catchUpHead). It replaces the v1 : connected comment. On a terminal stream the route replays the history and ends with its terminal frame instead.

Truncation is signaled, not silent. While a cleanupToStep marker is set (a cleanup removed chunks inside the current run) and fromSequence is below the stream's retention floor (the highest sequence that is not replayable), the DO does not quietly replay only the survivors — it writes a single terminal truncated { floor, errorDetail } frame (errorDetail.code: 'TRUNCATED') and closes. The documented consumer behavior is: refetch the snapshot and resume from its checkpoint-pinned streamSequence. resetStream and reactivateStream clear the marker: a reset is a deliberate new run, so a reader from 0 is served the next run. The Node AgentServer and the StreamServer DO answer the same frame.

rewound while attached. cleanupToStep broadcasts rewound { atStep, atSequence, minSequence? } to attached SSE readers on every cleanup (a no-op one included). A reader prunes the buffered chunks the cleanup removed; once its buffer is drained, a reader still behind atSequence on a live stream throws StreamTruncatedError. It replaces the v1 non-terminal truncated { truncatedAtStep } SSE frame. A reader that was away when the cleanup ran (evicted, or its connection dropped) and re-opens past a chunk it removed gets the same frame first, before its replay (CP-200; stream wire), so it is told exactly as an attached reader is.

Reading a DO's stream from a Worker. DOStreamManagerClient (the chat route's stream manager) and the DOFrontendExecutor handle's stream() read /sse through one reader (openDoSse, on core's createSSEFrameDecoder + decodeStreamFrame). Both keep the StreamManager reader contract every other runtime keeps:

What the reader seesDOStreamManagerClient.createResumableReaderhandle stream()
the stream does not exist (getStreamInfo is null)null, unless waitForLive—
the stream had already failednull (the chat route then answers its typed 409 stream_failed), unless waitForLiveone error chunk, then done
a fail frame while readingone error chunk carrying the frame's errorDetail verbatim (not repeated after an in-band one), then donethe same
a truncated framethrows StreamTruncatedErrorthe same
a rewound frame that leaves the read behind the headthrows StreamTruncatedErrorthe same
/sse answers 404nullnull
/sse answers any other non-2xxa typed error (a typed body's code; otherwise transport_error, retryable on a 5xx)the same
the request fails, or a frame is malformeda retryable transport_errorthe same
the body ends with no terminal frame, or a body read failsre-opens /sse from the last stored sequence it read, every chunk once (WE C2a); throws the retryable StreamDropError only when a re-open cannot help (below; a plain end after the reader's own close())the same
an evicted{resumeFrom, reason} frame (WE C2b: a slow or stale client, the connection cap, a server error)re-opens from its own cursor (slow_consumer / stale never count toward the cap); a cursor below resumeFrom throws StreamTruncatedError; when a re-open cannot help, throws StreamEvictedError (transport_stream_evicted, the reason as cause)the same
after a re-open, a chunk of a newer run of the sessionthrows StreamRunChangedError (transport_stream_run_changed, retryable: re-anchor from the snapshot)the same
the replayed backlog passes READER_BACKLOG_MAX_BYTES (32 MiB)throws StreamTruncatedError (re-anchor from the snapshot)—
options.signal aborts (handle only)—the read ends, no error

No path turns a failure or a drop into a clean end, and the handle never throws for a failed run: code written against AgentExecutor's handle reads a CF DO failure exactly as on every other runtime. A re-open cannot help after a second drop with no new chunk since the last re-open, past MAX_STREAM_REOPENS (8) counted re-opens, or when the re-open request fails or is refused (an eviction for a newer run, or a typed one, ends as the rows above say). Retry any StreamDropError (the typed ones included) by re-attaching from your cursor (fromSequence), or re-anchor from the snapshot; never re-send the request that started the run.

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). Any other failure throws a typed DOPeerError (the peer's code, else transport_error). The in-DO DOStreamManager.getStreamInfo returns null for a stream that was never created and for an id that is not the DO's own stream (RM-71).

DOStreamManagerClient.getStreamInfo reports the stream's own status (the /snapshot's streamStatus) and null when the DO has no stream, like every other stream manager. In a new run's start window the run is live while the stream still shows the previous turn's ended; the chat route reads that as the window and waits (RM-73).

?waitForLive=1. DOStreamManagerClient.createResumableReader(streamId, { fromSequence, waitForLive: true }) (what handleChatStream asks for in a run's start window) sends /sse?…&waitForLive=1. Until the stream is live (active when the request arrives, a transition to active since, a chunk past the cursor, or a stream that did not exist now existing — the in-DO DOStreamManager's own rule), the route parks the request instead of replaying the previous turn's history and terminal frame. It writes a : waiting comment first, so the client's reader returns at once with no backlog, then serves the stream as usual once it is live. A parked request counts toward the DO's 100-connection cap and gets the 30 s heartbeat, and is evicted like any connection when its client reads nothing for 60 s (SSEConnectionManager.hold), so a client that never reads cannot keep a slot. An older DO ignores the parameter, so deploy the Worker and the DO together.

Output-gate invariant — do not enable allowUnconfirmed on the SSE write path

The DO chunk-persistence invariant is that each chunk is INSERTed into durable SQLite before it is broadcast to WS/SSE clients (DOStreamManager.write()). That ordering is only observable-to-the-outside-world safe because Cloudflare's output gate holds the response until the storage write settles. A future allowUnconfirmed "optimization" on this path would let a broadcast chunk escape before its write lands, which would silently corrupt fromSequence dedup and replay (a client could see a sequence that the log later does not contain). It must not be enabled on the SSE write path.

Stream Events ​

WebSocket and SSE carry the same chunk payloads, but their terminal frames differ: WebSocket keeps the core StreamEvent shape below, while SSE uses the unified protocol end frame documented in SSE Streaming.

typescript
// WebSocket wire shape (unchanged)
type StreamEvent =
  | { type: 'chunk'; sequence: number; chunk: StreamChunk }
  | { type: 'end'; finalOutput: unknown }
  | { type: 'fail'; error: string };

type StreamChunk =
  | { type: 'text_delta'; delta: string; agentId: string; ... }
  | { type: 'tool_call_start'; toolName: string; ... }
  | { type: 'tool_result'; result: unknown; ... }
  | { type: 'run_completed'; output: unknown; ... }
  | { type: 'run_interrupted'; reason: string; ... }
  // v7 additions:
  | { type: 'tool_approval_request'; toolCallId: string; toolName: string; input: unknown; ... }
  | { type: 'tool_approval_response'; toolCallId: string; decision: 'approve' | 'reject'; ... }
  | { type: 'run_resumed'; previousRunId: string; ... }
  | { type: 'run_suspended'; reason: 'client_tool' | 'awaiting_children' | 'step_partial'; ... }
  // ... and more

LLM Adapter Handling ​

Critical

LLM adapters cannot be serialized over HTTP. They must be created within the worker/DO context.

With the composition API, the llmAdapter factory handles this automatically:

typescript
export const MyAgentServer = createAgentServer<Env>({
  // Called inside the DO, once per drive
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      model: openai('gpt-6-luna', { apiKey: env.OPENAI_API_KEY }),
    }),
  // ...
});

Completion-support refusal ​

/start, /resume and /retry refuse an outputSchema agent whose model has no completion strategy before the DO commits or writes anything, with HTTP 400 { error, code } (framework_completion_strategy_unavailable or framework_completion_schema_unsupported), built by core typedErrorResponse: the message is scrubbed (at most 256 characters, < / > escaped), so the DO and @helix-agents/agent-server answer the same refusal with the same bytes. The check is the last step of materialization and uses the adapter the factory built for this drive, so a capabilities override on that adapter applies. On a wake, a stranded run whose agent is now refused is not re-driven (it could never succeed): it ends failed with the refusal's typed, non-retryable detail. /submit-tool-result (client-tool results and approval answers) materializes the continuation the submit would start, and so applies the same check, before the result is written: a session paused on a client tool whose agent became unfinishable answers the submit with the same typed 400 and keeps the call pending. A root DO relays an owning sub-agent DO's refusal of a forwarded submit unchanged.

Entry failures ​

Every failed drive is classified by core classifyEntryFailure into one of four classes, never by message text:

ClassWhat it meansExamples
lostSomeone else owns the session, or it is legitimately waitingAgentAlreadyRunningError, RunSupersededError, CAS losses, the run-start rejects expected_mismatch_idle / expected_run_status_changed / entry_status_changed / entry_abandoned, state_not_resumable
refusedA typed refusal before anything ranframework_agent_not_found, state_agent_type_mismatch, validation_error, framework_not_supported, a completion-support refusal
transientRetryable, or unclassifieda plain Error from a resolver, framework_usage_store_unavailable, a retryable framework_agent_resolution_failed
fatalClassified retryable: false and neither of the abovethe run-start rejects idempotency_mismatch / resumed_companion_ref_missing, a non-retryable resolver failure

The failure table ​

Drivelostrefusedtransientfatal
start / resume / retry409 state_already_runningthe core status table503; 409 / 500 once the input committedthe core status table
continue / recovernothing (debug log)terminalre-armterminal

A lost entry whose run start was rejected keeps its cause on the wire (409 state_run_start_conflict), and a state_not_resumable refusal keeps its own code; see Wire answers.

A request drive that fails before its run-start commit writes nothing to a session that already has a run: no session, run, stream, hook or status write. The session's stream still ends with the previous turn and /status still shows its output. Only a brand-new session's createSession can precede the failure, and such a never-run session is recorded failed on the session alone (see never-run sessions). An entry that failed after its run-start commit is the run's own failure: the run loop ends it (K5, run status, stream) and the settle path reports it.

Terminal (a wake drive) ends the live run with the classified detail: failCommittedRun for a running or suspended run, or core failSessionWithoutRun when no run is live. The run then settles (onRunSettled with failed). Re-arm records the failure as lastFailure on the wake record (DO storage key __helix_wake_resume_record, read through Zod), undoes this drive's own stranded-session reset, and leaves the run for the next alarm, under the crash-loop budget (see Eviction Recovery).

Never-run sessions ​

A session whose first run never started (its entry failed before the run-start commit, or the DO was evicted between createSession and that commit) is recorded failed on the session only: the session row gets status: 'failed' and the errorDetail, written idle-fenced (it never lands over a run that started meanwhile). No stream is created or failed and no hook fires: streams are written only by their runs. Its /snapshot reads status: 'failed' with run: null, and its next /start turn runs on a clean stream.

Wire answers ​

The DO's native routes (/start, /resume, /retry) answer typed { error, code } bodies, built by core typedErrorResponse (the message scrubbed, at most 256 characters). One mapper (do/drive/wire-answer.ts) picks the body from the route:

AnswerStatusBody
started200{ sessionId, streamId, runId, status, startSequence? }
beforeEntry refusedthe hook'sthe hook's Response, verbatim
a rejected run start409{ error, code: 'state_run_start_conflict', cause }; cause is one of the six RunStartRejectedError causes
state_not_resumable409{ error, code: 'state_not_resumable' }; a partial batch names the awaited call ids first
no session on the DO (/resume, /retry)404{ error, code: 'state_session_not_found' } (not retryable)
any other lost409{ error, code: 'state_already_running' } (the one busy answer)
otherwisecore status table404 framework_agent_not_found / state_checkpoint_not_found (a foreign checkpoint is answered as a missing one); 400 validation_error / completion refusals; 503 retryable (409 / 500 once the input committed); 409 non-retryable state_*; else 500 (e.g. framework_not_supported)

An entry whose input committed never answers 503: repeating the request would append its input twice. Its retryable error gets the non-retryable row (409 for state_*, else 500), and a run read that fails while deciding counts as committed. Use /retry to run the turn again, not a re-send.

The remote-protocol routes (/subagent/:agentType/start|resume) answer the protocol envelope (RemoteAgentErrorResponse), with the typed code in errorCode and a rejected run start's reason in cause:

AnswerStatuscode
started200the native body: RemoteStartResponse { sessionId, streamId, runId } plus status and startSequence?
state_not_resumable409ALREADY_COMPLETED for a completed session, else INVALID_REQUEST
any other lost409ALREADY_RUNNING (no streamId)
state_checkpoint_not_found404INVALID_REQUEST (a bad reference, so DOStubTransport keeps the typed code, never RemoteAgentNotFoundError)
any other status 404404NOT_FOUND (framework_agent_not_found, state_session_not_found)
status 400400INVALID_REQUEST
any other statusthat status (409 / 500 / 503)INTERNAL_ERROR

Eviction between the commit and run_started. If the DO is evicted after the run-start commit and before the run writes run_started, the /start (or /retry) request is cut off: it never answers, or the platform answers it with an error. Wake recovery then continues the committed run under its own run id. The SEND looks failed to the caller, but the run and its input exist. A client that re-reads the session snapshot (/snapshot, the chat client's resume rule) sees the run and attaches to it. A client that re-sends the message appends it a second time.

Typed errors in the DO clients ​

DOFrontendExecutor reads a native answer's body with core peerErrorBodySchema() (never the message text) and throw a typed error:

AnswerClient error
409 state_already_runningAgentAlreadyRunningError
404 framework_agent_not_foundAgentResolutionError for the requested agent type (no availableTypes: the body does not carry them)
409 state_run_start_conflict with a known causeRunStartRejectedError(sessionId, cause)
any other typed body (incl. 409 state_not_resumable)core helixErrorFromPeerBody: a HelixError with the body's code and cause; retryable when the body says so, else exactly on a 503
a body that is not typed (not JSON, no code, empty)a transport_error naming the route and status (/start answered 502 with an untyped body), retryable on a 5xx, the body its cause

So createCloudflareChatHandler / handleChatStream answer a chat SEND or RESUME with the same typed error, before any stream opens. DOPeerError is now only the read error of DOStateStoreClient and of DOStreamManagerClient's getStreamInfo / getChunks* (CP-28).

DOStubTransport (and core HttpRemoteAgentTransport) map the protocol envelope through core remoteProtocolError: ALREADY_RUNNING → RemoteAgentAlreadyRunningError, NOT_FOUND → RemoteAgentNotFoundError, an envelope with errorCode or any 503 → a typed HelixError (retryable exactly on a 503), a typed { error, code } body → the same helixErrorFromPeerBody, anything else → a plain Error. Both send /start and /resume exactly once (CP-95): a thrown request, or a 5xx that is not the host's typed retryable answer, rejects with the non-retryable RemoteEntryOutcomeUnknownError (core remoteEntryProtocolError). See Remote agents: entries are sent once.

Hibernation (v7 Stateless Model) ​

The DO runtime uses a fully stateless suspension model in v7. The DO closes its SSE response and becomes hibernation-eligible at every Human-In-The-Loop (HITL) suspension boundary, instead of holding the request open across HITL waits.

v7 Behavior ​

  • The DO request handler closes its SSE stream when an agent reaches a suspension boundary ('suspended_client_tool', 'suspended_awaiting_children', 'suspended_step_partial').
  • DOs become hibernation-eligible at every suspension boundary — no in-memory promises pin the instance.
  • SQLite state (including suspension_context) persists across hibernation; the DO has zero in-memory state to restore on wake.
  • Idle CPU billing on long-suspended HITL sessions drops sharply (typical reduction of ~80% wall-time billing on multi-minute approvals).
  • Clients reconnect via the new GET /chat/{id}/stream route after submitting a tool result. The previous "hold the SSE open through HITL" pattern is gone.

Resume Protocol ​

typescript
// 1. Submit a client-tool result (durable, idempotent)
await fetch(`/chat/${sessionId}/submit-tool-result`, {
  method: 'POST',
  body: JSON.stringify({ toolCallId, result }),
});

// 2. Re-attach to the (newly-resumed) stream
const eventSource = new EventSource(`/chat/${sessionId}/stream`);

The framework handles the rest: the durable submit lands in pendingClientToolCalls, the DO wakes and observes the change, and the resume drains pending entries before continuing. It drains every pending entry, so a call of the same step that has not been submitted yet is failed as runtime_restarted rather than waited for (CP-82, above). Before @helix-agents/runtime-cloudflare 5.20.0 the re-attach in step 2 could lose frames when the ai-sdk chat handler served it (RM-51, fixed in @helix-agents/runtime-cloudflare 5.20.0).

A continuation that fails is classified like every other wake drive (see the failure table): a lost entry does nothing, a typed refusal or a non-retryable failure ends the suspended run failed with its detail (and settles it), and a transient failure re-arms the alarm with the failure recorded as lastFailure. A submit whose continuation cannot be materialized (an unresolvable agent, a failing factory) is refused typed before the result is written (MA-06, CP-70).

Durable stop (No More INTERRUPT_NOT_LOCAL) ​

The INTERRUPT_NOT_LOCAL 503 error code is removed in v7. A stop is durable: it is recorded on the run before it is answered, any DO instance (or a wake after an eviction) honours it, and there is no observation deadline or 504. See Interrupting Execution for the routes and their answers.

Eviction Recovery (Self-Healing Runs) ​

Execution itself (LLM steps, tool execution, companion status polls) runs as an in-memory background promise inside the DO — and Cloudflare can evict a DO at any time (deploys, memory pressure, storage resets, or plain idle eviction ~10 seconds after the last request handler returns). The runtime layers three mechanisms so an eviction mid-run never permanently kills the run:

  1. Execution keepalive — while a run is actually executing, the DO holds a lightweight setInterval keepalive so routine idle-eviction doesn't chop in-flight LLM steps. The keepalive spans ONLY genuinely-executing periods: it is cleared the moment the run completes, fails, or reaches a suspension boundary, so suspended/idle DOs hibernate exactly as the v7 model intends. A 4-hour hard cap prevents forever-resident DOs (after the cap, the wake-recovery path below resumes the run from its last persisted step).

  2. Wake auto-resume — every wake path (onStart on a fresh instance — PartyServer runs it before any request, connection, or alarm) checks for a stranded run: persisted status active with nothing executing. Stranded runs are re-driven through the same drive pipeline as every route (a recover drive for a still-running run, a continue drive otherwise), with the same executor options and hooks, continuing from the last persisted step. A durable stop record always wins over auto-resume: the wake ends the run by its stop instead (settle_stopped, below).

  3. Alarm-tick recovery — while durable state says a run should be executing, the execution-watchdog alarm subscriber keeps a DO alarm chain scheduled, and every onAlarm() tick drives the recovery check directly. DO alarms survive eviction, so even a fully headless run (no SSE client attached, no incoming requests) is re-woken within ~30 seconds of an eviction and self-heals; a resume attempt that failed transiently on a still-warm instance is retried on the next tick. It also keeps the chain (at the same 30 s interval) while the current run still reads live although its own K5 already ended the session: the run's terminal status write after the K5 was lost (core failCommittedRun logs that at error). The next tick's closed-owner reconciliation writes the missing status, and the chain then quiesces (DI-16).

The recovery ladder. A wake that finds nothing executing walks these rungs in order:

  1. Closed-owner reconciliation. Nothing is registered and no producer holds the claim, so the run's owner is closed. Holding the claim, the wake reads the current run, the session, its latest checkpoint and the run's own stream status. It plans with core planClosedOwnerReconciliation and executes with reconcileClosedOwnerRun: the same planner and executor CF Workflows' readers use.

    • A run whose K5 landed (the session is already completed, failed or interrupted; the run reads running or suspended_*): the run is finished with the committed status, its stream ended or failed, never re-run, and settled (onRunSettled).
    • A live run with a stop record (settle_stopped; for example a stop that answered 202 before an eviction): the stop's terminal is committed (interrupted, or failed with framework_cancelled), the crash-loop record is reset, and the run is settled. It is never re-driven, whether or not its deadline passed.
    • A run whose suspension committed before its run status (the session paused and awaiting calls or children, the run still running): the run gets its suspended_* status and its active stream is paused, and it settles suspended_*.
    • An ended run whose own stream is still active / paused: the stream is ended or failed (no hook).
    • A live run with nothing committed past its entry (fail_uncommitted) is NOT failed here: it goes down the ladder and is re-driven (rung 5). It is failed only at a terminal rung (4, or a permanent read below), with that rung's detail.

    A stream that cannot be read or written never hides the run's outcome (the next wake repairs it). A store read that fails transiently decides nothing this wake. A stranded active run (or a paused session with an unconsumed submit) keeps its watchdog alarm, so the next alarm retries; a paused or terminal session arms no alarm, so the reconciliation runs again only at the next wake (the DO's next onStart, or an alarm another subscriber armed). A permanent one (a corrupt row, core isPermanentStoreError) ends a stranded active run failed typed (its re-drive would read the same row) and writes nothing over a committed outcome. A settled run whose onRunSettled delivery mark is missing gets its hook again.

  2. An active session with no run record (evicted between its createSession and its run-start commit, so its input never committed): it ends failed on the session alone, with no stream write and no hook. Resuming it would run the agent on an empty history.

  3. autoResumeOnWake: false: a stranded active run is demoted to paused and its stream ended; it stays dead until an explicit /resume. A paused session with an unconsumed submit stays paused.

  4. Crash-loop guard exhausted: the run ends failed (below), through the same core executor (its fail_uncommitted, requireSessionCommit). A failed write that did not land leaves the budget exhausted, so the next alarm retries it.

  5. Otherwise: the attempt is recorded and the run is re-driven. A failure of that drive is classified (the failure table): terminal, re-arm or nothing.

Crash-loop guard: wake auto-resume tracks consecutive attempts in DO storage (__helix_wake_resume_record, read through Zod; a corrupt record is logged and read as absent). If maxWakeResumeAttempts (default 5) resumes happen with no progress, the run ends in the TERMINAL status failed with the typed detail { code: 'framework_auto_resume_exhausted', category: 'framework', retryable: true, cause }, written in the run's atomic K5 commit. cause is the last failure a re-arm recorded (WakeRecoveryRecord.lastFailure, a structured ErrorDetail), so the real reason survives instead of a generic message; it is absent when the attempts hung without failing. Terminal is load-bearing: a parent waiting on the run (a blocking spawn, companion__waitForResult) gets its committed failed (through the child's notice, or its next verified read) instead of waiting on a permanently-inert run, and the DO's alarm chain stops so the instance idles out. Recover via /retry (which accepts failed sessions) after fixing the underlying issue. Two progress signals each reset the counter: committed progress (the session's message count advancing — the signal every committed step and every new turn produces) and survival (the previous attempt ran ≥5 minutes before re-stranding). The guard targets TIGHT crash loops — a step that kills the isolate on every resume, cycling at the ~30s alarm cadence; a long-parked healthy run (e.g. a blocking-spawn parent waiting hours on a slow child) is re-stranded by every deploy and is protected by the survival signal.

onStart and onAlarm never throw. Each wake step (onStart, the watchdog subscriber, and each onAlarm step) runs inside a wake boundary that logs a throw at error (wake.boundary) instead of rethrowing it, so a store outage during recovery leaves the durable stranded signal for the next alarm, and a sub-agent tool that fails to rewrite after a deploy (a sub-agent without outputSchema) ends that run typed while /status and /abort keep answering (MA-10). When the alarm reschedule itself throws, the DO sets a 30 s backoff alarm (keeping an earlier one), so the alarm chain is never lost.

Forever-running DOs are bounded by four layers: the execution keepalive hard-caps at 4h (the DO then becomes evictable and recovery restarts from the last persisted step); the crash-loop guard converts repeated zero-progress resumes into terminal failed; runs that DO make progress are bounded by the agent's maxSteps (50 when the agent sets none; only maxSteps: Infinity removes this bound); and the durable interrupt/abort routes work at any time, with the stop deadline ending a run that ignores them. Once a session is terminal, every alarm subscriber computes no deadline and the DO hibernates for good.

Companion children self-heal too. Persistent-companion child DOs get the same three mechanisms. A child records its outcome on its parent through the child-settled notice and the parent's verified read, both durable across an eviction of either DO. In addition, the parent's blocking waits (companion__spawnAgent in blocking mode, companion__waitForResult) are hardened:

  • Each verified read of a child DO (GET /subagent/:type/status) wakes it, so a stranded child is resumed by the very read that observes it.
  • A re-executed spawn after a parent eviction that reuses the call id (same LLM tool-call id — a replay, from a deterministic or replayed model output) whose child outcome the parent already recorded for that call answers from the recorded outcome: a blocking spawn returns the child's recorded result (and marks it delivered), a non-blocking one answers spawned. It sends no /start and never runs the child again (core CompanionToolDispatchDeps.recordedConsultOutcome, ruling C14). The output comes from the parent's stored outcome, never from memory.
  • With a real provider the recovered parent's model mints a new tool-call id (the recovery re-calls the model), so the re-emitted spawn is not a replay. A child still live answers "already running"; a completed child is a continue: one whose outcome is not marked delivered delivers it as priorResult beside the new consult's answer and runs the child again (deliver-then-continue, MR B, below); one whose outcome the notifier already injected is continued without a prior. Either way the child runs the re-emitted instruction a second time (FU-DO-COMPANION-ORPHANED-CONSULT-ADOPTION, MR C: re-point the orphaned consult to the new call instead).
  • A replay whose child has no recorded outcome yet attaches to the already-running child instead of double-starting or erroring; a replay /start answered with the child's 409 ALREADY_RUNNING envelope is also treated as attach. Any other 409 (a typed refusal such as state_run_start_conflict) is not_spawned / child_entry_failed with the refusal's errorDetail, never an attach. A NON-replay spawn (a new tool call with a new instruction) whose /start gets a 409 ALREADY_RUNNING answers { name, status: 'not_spawned', delivered: false, reason: 'child_busy' }, a successful tool result (MR B; it used to be an advisory tool error) — attaching there would silently return the previous consult's output for the new instruction. A replay never sends /start to a child whose session exists: it probes /status first and attaches, and that probe's wake re-drives an evicted child's stranded run (the same run, from its committed history; the instruction is not sent again). A /start (a non-replay spawn, or a replay whose probe found no session) is refused busy only while the child DO is executing a run: if the child's persisted run is live but nothing executes it (its wake recovery is waiting on a re-armed alarm, for example), the /start is accepted, its run-start commit supersedes that run, and the new instruction runs (it used to answer 409 ALREADY_RUNNING).
  • A child the parent cannot read (its session answers 404, its DO answers 5xx or does not answer, a wedged isolate) never hangs the parent and is never recorded failed. Once every verified read of it has failed continuously for companionPollFailureWindowMs (default 30 s), the wait answers the typed, successful tool result unreachable (CP-125, D1): a blocking spawn { name, status: 'unreachable', message }, companion__waitForResult { name, status: 'unreachable', currentStatus, message }. Nothing is written and nothing is stopped: the parent's ref stays running, and the child is left to run. The message (core companionUnreachableMessage) tells the model the outcome is unknown, that the result is delivered when it reaches the parent, and to call companion__waitForResult again or companion__terminateChild to stop it. When the child ends, its notice reaches the parent (it is retried with backoff until the parent answers), the parent's verified read records the outcome, and the completion notifier delivers it on the parent's next turn. A failure of the parent's own store during a wait is not an unreachable child: it is logged (companion.child_read.store_failed) and fails the tool call (on a blocking spawn, core's park catch still compensates the ref failed, best-effort; see FU-SPAWN-CONTINUE-ENTRY-OUTCOME-PARITY). A transport failure never writes a ref (I-C8).
  • A stop of the parent run ends the wait (CP-40, the DO half of CP-23): the wait takes the parent run's signal and stops the child with the parent's kind through its /subagent/:type/interrupt|abort route, naming the consult. A blocking companion__spawnAgent then answers { name, status: 'parent_stopped', kind } and writes no ref; companion__waitForResult returns the status the child is being stopped to (interrupted, resumable, or failed). Neither waits for the child to end, and the parent then ends by its own stop. The child's ref stays running until the child's own word reaches it, and an unconfirmed stop is retried by the stop cascade (above).
  • A child that was interrupted on its own (not by this parent) is observed by the blocking park exactly as companion__waitForResult observes it: after 3 consecutive interrupted observations (one shared debounce, D7) the blocking spawn answers { name, status: 'interrupted', message }, where message is core interruptedCompanionGuidance (the child is resumable: use companion__sendMessage). Before, the park kept waiting until the parent was stopped.

A child DO always closes its own stream. A sub-agent in its own DO, or a remote child started through the remote protocol, has a parentSessionId. The JS run loop leaves such a run's stream alone, since an in-process sub-agent shares its parent's stream. A DO child has a stream of its own, and its parent's attached stream (DOStubTransport, a remote dispatch) waits for that stream's end or fail frame. So after every drive (/start, /resume, /retry and the wake auto-resume) the DO closes the run's own stream:

  • end on completion;
  • fail, with the run's errorDetail, on failure;
  • end { status: 'paused' } on suspension.

Each transition is fenced to the run, so a newer run's stream is never touched, and a top-level run's stream is already closed. Before this, the wake drive closed nothing, and the other drives closed only a completion. So a child that an eviction stranded, and any child run that ended failed, left its parent running until the remote timeout (10 minutes by default) (MA-22, FU-EVICT-02).

Configuration (all optional, on createAgentServer):

typescript
export const MyAgentServer = createAgentServer<Env>({
  // ...
  autoResumeOnWake: true, // default; false demotes a stranded run to paused instead
  maxWakeResumeAttempts: 5, // crash-loop guard budget
  companionPollFailureWindowMs: 30_000, // a child wait answers `unreachable` after this long without one read
  owedStopSchedule: { baseMs: 30_000 }, // default 30 s doubling to 1 h, 24 h window: an unconfirmed child stop's retries
  companionStartTimeoutMs: 60_000, // default: the bound on a companion's /start
});

Structured log events (subset): wake_auto_resume.resuming, wake_auto_resume.exhausted, wake_recovery.never_run_session, wake_recovery.finished_committed_run, drive.wake_rearmed, drive.wake_terminal, drive.wake_lost, wake.boundary, settle.hook_failed, settle.stop_cascade_children_unrecorded, stop.child_stop_refused, stop.child_stop_owed_unrecorded, stop.remote_stop_owed_abandoned, stop.child_consult_missing, companion.spawn.replay_attach, companion.spawn.attach_already_running, companion.park.unreachable, companion.child_read.store_failed, the child-settled notice's events (below); the JS runtime's orphaned-child detection logs companion.wait_for_result.orphaned_child.

Keeping DOs Alive (Optional) ​

If you want the DO to remain warm across active streaming sessions (e.g., for an active multi-turn conversation without HITL), keep a WebSocket connection alive:

typescript
setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000);

For HITL flows this is unnecessary — the DO is designed to hibernate during waits. During active execution the runtime already holds its own keepalive (see Eviction Recovery); a client-side ping is only useful for keeping idle conversation DOs warm between turns.

Multi-Turn Conversations ​

Continue conversations across multiple messages:

typescript
// First message - creates a new session
const sessionId = 'session-123';
await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: { message: 'Hello, my name is Alice' },
    sessionId,
  }),
});

// Wait for completion...

// Continue the conversation - same sessionId (and the same identity: a different
// userId / tags / metadata answers 400 validation_error)
await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: { message: 'What is my name?' },
    sessionId, // Reuse the same session
  }),
});

// The agent remembers: "Your name is Alice"

Multi-Message Input ​

The message field in input accepts either a string or a UserInputMessage[] array. This lets you inject context alongside the user's question or attach files:

typescript
await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: {
      message: [
        {
          role: 'user',
          content: 'System context: user is a premium subscriber',
          metadata: { source: 'system' },
        },
        { role: 'user', content: 'What premium features can I use?' },
      ],
    },
    sessionId,
  }),
});

// Messages can include file attachments
await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: {
      message: [
        {
          role: 'user',
          content: 'Analyze this image',
          files: [
            { data: 'data:image/png;base64,...', mediaType: 'image/png', filename: 'chart.png' },
          ],
        },
      ],
    },
    sessionId,
  }),
});

String and multi-message inputs can be mixed freely across turns in the same session.

Interrupt & Resume ​

Full interrupt/resume support is available:

typescript
// Start execution
await stub.fetch('/start', {
  method: 'POST',
  body: JSON.stringify({
    agentType: 'researcher',
    input: { message: 'Long research task' },
    sessionId,
  }),
});

// Interrupt later
await stub.fetch('/interrupt', {
  method: 'POST',
  body: JSON.stringify({ reason: 'user_pause' }),
});

// Check if resumable
const statusResponse = await stub.fetch('/status');
const { status } = await statusResponse.json();

if (status === 'paused' || status === 'interrupted') {
  // Resume
  await stub.fetch('/resume', {
    method: 'POST',
    body: JSON.stringify({
      agentType: 'researcher',
      options: { mode: 'continue' },
    }),
  });
}

DOStateStore ​

The DOStateStore uses the DO's built-in SQLite for persistence:

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

// Created internally by AgentServer
const stateStore = new DOStateStore({
  sql: this.sql, // PartyServer's sql tagged template
  logger: this.logger,
});

// Implements SessionStateStore interface
await stateStore.saveState(sessionId, state, { fenceRunId: runId }); // no checkpoint
const loaded = await stateStore.loadState(sessionId);
await stateStore.appendMessages(sessionId, messages, { streamSequence, runId }); // a commit

Every commit (appendMessages, truncateMessages, commitState, saveStateAndPromoteStaging / promoteStaging, cloneSession) writes its rows, its checkpoint (with kind, runId, the live COUNT(*) as messageCount and the live stream head as streamSequence) and the latest-checkpoint pointer in one transactionSync, fenced on the commit's run. saveState is not a commit: it writes no checkpoint and never moves the pointer (it used to write one, at first recording messageCount: 0, RM-36 / RM-37). History loaded for the model is count-checked (loadLLMHistory): a row the store skips on read (for example a corrupt assistant message) fails the run with state_history_incomplete instead of silently dropping the message.

The run-start commit runs inside the DO's claim. DurableObjectAgentBase owns every run-start of its own session: its claim check and DOStateStore.runStartCommitSync (idempotency, the CAS on the current run, supersede, the optional truncateTo rewind, the input rows, the run and its input checkpoint), followed by saveStateSync for the entry's session state, run in ONE transactionSync, and the DO's in-memory claim is set in the same tick. The DO mints no owner token and keeps no lease (ownerless: 'asserted'): the synchronous claim proves no other execution owns the session. A losing request writes nothing; DOAB treats RunSupersededError, RunStartConflictError and RunStartRejectedError as a lost claim and never rolls the session back to failed. The DO does not count resumeCount. The sync helpers (runStartCommitSync, saveStateSync, readSnapshotViewSync, …) never migrate: called before the schema is initialised they throw DOStoreNotInitializedError (DOAB's onStart initialises it).

State size limit (maxStateBytes, state_size_exceeded) ​

Cloudflare caps one SQL string, BLOB or row of a SQLite-backed Durable Object at 2 MB (Durable Objects limits, CF_SQL_MAX_VALUE_BYTES). DOStateStore keeps every bind under it with ONE limit, maxStateBytes, default 1.8 MB (DEFAULT_MAX_STATE_BYTES), in UTF-8 bytes of the JSON it binds. It applies to one tool's staged writes, the folded customState of a step or state commit (and the state row's other large JSON columns: output, the pending and completed client-tool maps, the caller's tags / metadata at createSession), and every message row a commit inserts. The store measures before it binds, and over the limit it throws core's StateSizeExceededError (code: 'state_size_exceeded', non-retryable) with nothing written. Miniflare does not enforce the platform's 2 MB cap, so this pre-flight is what refuses an oversized write in local tests as well as in production.

Set the limit once on the DO's config; it is passed to the DO's store and to the submit-limit check:

typescript
import { isStateSizeExceededError } from '@helix-agents/core';

export class MyAgentServer extends DurableObjectAgentBase<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    // A positive integer or Infinity; anything else makes the constructor
    // throw a typed `validation_error`.
    super(ctx, env, { llmAdapter, agents, maxStateBytes: 1_500_000 });
  }
}

// A caller of the store directly:
try {
  await stateStore.stageChanges(sessionId, stepId, changes);
} catch (err) {
  if (isStateSizeExceededError(err)) {
    console.error(
      `${err.operation} of ${err.toolCallId}: ${err.sizeBytes} B over ${err.limitBytes} B; ` +
        `keys: ${err.changedKeys.join(', ')}`
    );
  }
  throw err;
}

What a run on the DO does with a refusal (it runs the runtime-js loop):

  • A tool's write over the limit (staging): the tool's result is a typed failed row (metadata.helixSynthetic.kind: 'state_write_rejected'), its writes are discarded (no state_patch), siblings are unaffected and the run continues. See State size and storage limits for the row.

  • A tool or sub-agent result row over the limit: runtime-js measures the row exactly as this store binds it (core messageRowBytes) before its tool_end and afterTool / afterSubAgent, and replaces it with core's typed failed row ("Tool 'x' ran, but its result was too large to record: the recorded row would be N bytes, over the 1800000-byte message limit. Return large data by reference …"; a plain failed row, helixToolFailed: true). The run continues and the model sees the row on its next step; the tool_end carries the text and no result, so a ~2 MB result never reaches the stream either. CF Workflows writes the same row from the same builder for a result over its 1 MiB step-result cap. This store's own refusal (BoundSizeWitness.assertMessage, the same measure) stays the backstop.

  • A hidden companion completion notice over the limit (MA-37): the notice embeds the child's output escaped, so its row is about twice the output. runtime-js measures it the same way and delivers one hidden notice saying so (Sub-agent 'y' completed, but its result was too large to record: …); the child is marked delivered and the notice is never re-injected. The refusal used to be swallowed and retried on every iteration, the model never told.

  • A step commit over the limit (the folded state, or the model's own reply): the run fails state_size_exceeded and its failure terminal commits the stored state, so the chat ends with a typed, non-retryable failure and /status is terminal. Before this check a model reply over 2 MB failed the step commit's SQLite INSERT, which the store reported as a retryable transport_error (CommitNotAppliedError).

  • A terminal commit keeps a column the row already stores: a value over the limit that equals the stored one is only measured, and the checkpoint copy of a commit's state is never refused. So a session whose stored state already sits over a lowered limit still gets its terminal.

  • A client-tool answer whose stored pending map would exceed the limit is refused at /submit-tool-result with a 413 state_size_exceeded before anything is stored. A submitToolResultSchema whose maxResultBytes is over the configured maxStateBytes makes the constructor throw a typed validation_error.

The driver backstop. With a limit raised to Infinity or above the platform cap, SQLite itself may refuse a bind. The store maps that driver error to the same StateSizeExceededError only when a value it bound in that operation was over CF_SQL_MAX_VALUE_BYTES (by its measured bytes, never by the error's text); any other driver error keeps its own DOStateError / CommitNotAppliedError. DurableObjectAgentBase overrides partyserver's onException to rethrow the driver error it is given, so the stores see the real SQLite error as cause (the default returned undefined, which partyserver's sql then threw).

See docs/guide/state.md § "State Size and Storage Limits" for the architectural pattern (state holds control flow, durable stores hold artifacts).

Stream chunk size limit (maxStreamChunkBytes) ​

The DO's stream stores each chunk as one SQL value, so the same 2 MB cap applies to it (RM-90). DOStreamManager enforces ONE limit, maxStreamChunkBytes (default 1.8 MB, DEFAULT_MAX_STATE_BYTES), in UTF-8 bytes of the JSON it binds for one chunk and for each part of an end's terminal payload (the final output, state and usage):

  • A chunk over it is refused with core's StateSizeExceededError (operation: 'stream_chunk', non-retryable) BEFORE a sequence is allocated: no gap in the stream's sequence, nothing broadcast. The run fails typed state_size_exceeded. It used to die on SQLite's own error after the sequence was taken, failing untyped.
  • An end is never refused. A part of its terminal payload over the limit (the final output, state or usage) is not stored: the stream still ends, its end frame names the part (omitted) and the DO warns core.stream.end_parts_omitted. A reader recovers the part from the run's own record (the output from /status, the state and usage from /snapshot and /usage). Refusing the end would leave the stream active behind a finished run, and every reader would wait forever.
  • A model's oversized delta never reaches it. The attempt writer splits a text_delta, a streaming thinking delta and a tool_arg_stream_delta at the producer into pieces of at most 64 KiB (or half the stream's limit, when that is smaller), measured as stored. Every piece keeps the chunk's run, attempt and step, and readers append deltas, so a model that emits a whole multi-megabyte reply as one delta still streams and completes. A chunk proxied from a remote child is never split: it was split at its own source, and RM-86's dedup keys on its child sequence.
  • Only an unsplittable chunk meets it: a tool result, tool_start arguments, a sub-agent result, a final output, the thinking complete block. Each of these also lands in a message row or the state, which MA-31's maxStateBytes refuses typed first in practice.
typescript
export class MyAgentServer extends DurableObjectAgentBase<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    // A positive integer at most CF_SQL_MAX_VALUE_BYTES (2 MB); anything else
    // makes the constructor throw a typed `validation_error`.
    super(ctx, env, { llmAdapter, agents, maxStreamChunkBytes: 1_000_000 });
  }
}

Miniflare accepts values far larger than production does, so this limit, not SQLite, is what refuses an oversized chunk in local tests as well as in production. The CF Workflows binding-side stream (store-cloudflare's StreamServer) enforces the same default; see docs/reference/store-cloudflare.md.

Schema ​

The DOStateStore creates these tables automatically and migrates lazily on first DO access. The current DO_SCHEMA_VERSION is 24 (V4 added completed_client_tool_calls; V5 added suspension_context for the v7 stateless suspension model; 11 and 12 add the commit model's bookkeeping; 13 adds the nullable runs.completion_reason; 14 the stop_requests table and sub_session_refs.parent_run_id; 15 sub_session_refs.location; 17 the companion consult columns and 18 the held-prior columns of sub_session_refs (DO companion lifecycle MR A / MR B, below); 19 sub_session_refs.remote_json, a remote child's stream identity (A lost remote sub-agent); 20 state.workspace_ref (RM-19); 21 sub_session_refs.stop_owed (CP-152); 22 state.memory_extracted_through (MA-40); 23 state.remote_pending_calls (CP-157); 24 state.child_binding, sub_session_refs.dispatch_id and its unique (parent_session_id, dispatch_id) index (Child identity). The block below shows the core tables.

MigrationAdds (all nullable)
11checkpoints.kind / run_id / keep_count; runs.lease_until / start_digest / owner_token / owner_unknown_since; state.current_run_id / latest_truncate_checkpoint_id (state.checkpoint_source is no longer read)
12runs.entry_step_count / input_row_count / parent_session_id / parent_tool_call_id; the abandoned_run_starts tombstone table. Columns are added only when missing
sql
-- Main state table (single row per DO)
CREATE TABLE state (
  id INTEGER PRIMARY KEY DEFAULT 1,
  session_id TEXT NOT NULL,
  agent_type TEXT NOT NULL,
  stream_id TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  step_count INTEGER DEFAULT 0,
  custom_state TEXT,
  output TEXT,
  error TEXT,
  checkpoint_id TEXT,
  completed_client_tool_calls TEXT,        -- V4
  suspension_context TEXT DEFAULT NULL,    -- V5 (suspendedAwaitingChildren, suspendedStepId,
                                           --     tracingContext, expiresAt)
  ...
);

-- Messages table
CREATE TABLE messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  role TEXT NOT NULL,
  content TEXT,
  tool_calls TEXT,
  ...
);

-- Stream chunks table
CREATE TABLE stream_chunks (
  sequence INTEGER PRIMARY KEY,
  chunk TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

See docs/storage/cloudflare.md for the full migration progression.

DOStreamManager ​

The DOStreamManager writes directly to connected clients:

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

// Created internally by AgentServer
const streamManager = new DOStreamManager({
  sql: this.sql,
  getConnections: () => this.getConnections(),
  broadcast: (msg) => this.broadcast(msg),
  sseConnections: this.sseConnections,
  logger: this.logger,
});

// Writes to SQLite AND broadcasts to all connected clients
const writer = await streamManager.createWriter(streamId, agentId, agentType);
await writer.write(chunk); // FREE - no subrequest!

Sub-Agents ​

Sub-agents work transparently in the DO runtime — use createSubAgentTool() exactly as you would in other runtimes. The only requirement is configuring subAgentNamespace so the DO runtime knows which namespace to use when spawning sibling DOs.

Why Sibling DOs? ​

Each DO instance hosts a single session backed by its own SQLite database. Running a sub-agent inside the same DO as its parent would corrupt the parent's state. Instead, the DO runtime automatically routes createSubAgentTool() calls to sibling DO instances — each sub-agent session gets its own isolated DO with its own SQLite.

This is handled internally by DOStubTransport, an implementation of the RemoteAgentTransport interface that routes calls to sibling DOs within the same namespace. You don't interact with DOStubTransport directly — it is wired up automatically when subAgentNamespace is configured. Agent definitions don't need to change; the tool rewriting happens at execution time.

Configuration ​

Add subAgentNamespace to your createAgentServer config:

typescript
// wrangler.toml
// [[durable_objects.bindings]]
// name = "AGENTS"
// class_name = "MyAgentServer"

interface Env {
  AGENTS: DurableObjectNamespace;
  ANTHROPIC_API_KEY: string;
}

export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      anthropic: createAnthropic({ apiKey: env.ANTHROPIC_API_KEY }),
    }),
  agents: registry,

  // Point to the same DO namespace used for the parent
  subAgentNamespace: (env) => env.AGENTS,
});

The sub-agent DO instances are resolved by session ID using namespace.idFromName(sessionId), so each sub-agent session gets a deterministic, isolated DO.

subAgentNamespace is required for sub-agents

If you use createSubAgentTool() in your agents but do not configure subAgentNamespace, the drive refuses the agent typed before anything is written (framework_not_supported, non-retryable, HTTP 500; on a wake the run ends failed; CP-144). Always set subAgentNamespace when your agents include sub-agent tools.

Defining Sub-Agent Tools ​

Define sub-agents and tools exactly as documented in Sub-Agent Orchestration — no DO-specific changes needed. Two requirements: both parent and sub-agents are registered in the same AgentRegistry, since the sibling DO resolves the sub-agent type from the same registry, and every sub-agent has an outputSchema (a remote sub-agent returns structured output). A sub-agent without one fails materialization with a typed, non-retryable validation_error: a request answers 400 and a wake ends the run failed.

typescript
import { defineAgent, createSubAgentTool } from '@helix-agents/core';
import { AgentRegistry } from '@helix-agents/runtime-cloudflare/do';
import { z } from 'zod';

const AnalyzerAgent = defineAgent({
  name: 'analyzer',
  systemPrompt: 'You analyze text for sentiment and key topics.',
  outputSchema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    topics: z.array(z.string()),
  }),
  maxSteps: 5,
});

const analyzerTool = createSubAgentTool(
  AnalyzerAgent,
  z.object({ text: z.string().describe('Text to analyze') }),
  {
    description: 'Analyze text for sentiment and topics',
    timeoutMs: 60_000, // 60 second timeout (default: 10 minutes)
  }
);

const OrchestratorAgent = defineAgent({
  name: 'orchestrator',
  systemPrompt: 'Coordinate analysis tasks.',
  tools: [analyzerTool],
  outputSchema: z.object({ summary: z.string() }),
});

// Both parent and sub-agents must be in the same registry
const registry = new AgentRegistry();
registry.register(OrchestratorAgent);
registry.register(AnalyzerAgent);

LLM adapter for sub-agents

Sub-agent DOs use the same llmAdapter factory configured in createAgentServer. Any llmConfig specified on the agent definition is passed to the adapter but the adapter itself is always created by the server-level factory. Use the factory context's agentType if you need per-agent model selection.

How It Works ​

When a parent agent calls a sub-agent tool:

  1. Tool rewriting — When the agent is materialized, createSubAgentTool() tools are converted by core toRemoteSubAgentTool() to remote tools backed by DOStubTransport. Every sub-agent option is carried, or refused typed: inheritWorkspace: true is a framework_not_supported (a sibling DO cannot share the parent's workspace)
  2. Session ID — The child's id is minted (child_<uuid>), never derived from the parent; the call's dispatch id keys the /start and its binding is stored on the child (Child identity)
  3. Sibling DO — DOStubTransport resolves a sibling DO via namespace.idFromName(sessionId) and calls its /subagent/:agentType/start endpoint
  4. Isolated execution — The sibling DO runs the sub-agent with its own SQLite state
  5. Streaming — Events stream back through SSE from the sibling DO and are proxied into the parent's stream

The parent stream includes subagent_start/subagent_end events identical to local sub-agents — frontends don't need to distinguish between runtimes.

Behavior change: same-worker sub-agents now get the remote-path consumer behaviors

Because local sub-agent tools are transport-rewritten onto DOStubTransport (step 1 above), a same-worker DO sub-agent app travels the remote consumer path. Two behaviors follow that did not exist before:

  • The child's usage rollup folds into the parent. The child's recursive rollup is harvested (from the end frame's usage, or via getUsage() on recovery) and embedded on the parent's SubAgentUsageEntry.remoteRollup, which DOUsageStore.getRollup({ includeSubAgents: true }) now aggregates. Previously the child's store was unreachable across the DO boundary and sub-agent tokens rolled up as 0.
  • The child's output is re-validated against the sub-agent tool's outputSchema before it is folded into the parent (a schema mismatch now surfaces as a structured tool error instead of passing through unvalidated).

Both are called out in the repo-root behavior-changes.md.

A lost remote sub-agent settles typed (S-C) ​

A recovered or resumed parent reconciles each sub-agent it still awaits by reading the child DO's /subagent/:agentType/status (DOStubTransport.getStatus). A DO with no session answers that route with the typed 404 NOT_FOUND envelope, so the read throws RemoteAgentNotFoundError. The child is settled failed, with a typed tool result (metadata.helixErrorDetail, code framework_subagent_lost, non-retryable), when:

causeWhen
not_foundthe child's DO does not know the session (for example a /start lost with its parent)
refused:<code>the child's DO refused the status read typed and non-retryable
status_unreachablethe status stayed unreadable past the child's own timeoutMs plus a minute's grace

A failure within that bound is transient: it is logged at warn and the next reconcile retries. Before S-C, any failed read left the ref running, and the parent awaited the child forever.

DOStateStore persists the child's stream identity (SubSessionRef.remote, migration 19), so a recovered parent re-attaches to the child's stream from its stored lastSequence instead of re-sending /start and re-reading the stream from 0.

An entry that carries a new message settles the parent's remote children before the message lands:

  • a child that finished or is lost gets its result (or the typed framework_subagent_lost result) committed before the message, so the transcript never holds the message between a call and its result;
  • on a resume / retry with a message, a child that is still running makes the entry refuse busy (state_already_running; nothing is written): send the message again once the child finished;
  • on a new turn (a chat send, /start) over a parent whose run ended, a child that is still running is stopped (best-effort interrupt, its ref set failed), and its call keeps the continuation heal's not-executed (unpaired_on_continuation) result. Finish the child, or resume the parent, first if you need its output.

Upgrade notes: A lost remote sub-agent settles typed.

Persistent Sub-Agents ​

Persistent sub-agents in the DO runtime work the same as ephemeral sub-agents at the infrastructure level -- each child gets its own sibling DO instance. The difference is in lifecycle management.

Auto-injected companion tools (v7): Persistent-sub-agent agents have these companion tools auto-injected at registration time. The previous v7-pre gating is lifted — they are unconditionally available on the DO runtime in v7:

ToolPurpose
companion__spawnAgentSpawn a new persistent child (blocking or non-blocking)
companion__sendMessageSend a follow-up message to an already-spawned child
companion__listChildrenList the parent's persistent children
companion__getChildStatusQuery a child's current status
companion__waitForResultBlock until a child reaches a terminal status
companion__terminateChildForcibly stop a child
  • companion__waitForResult answers from the outcome the parent recorded for the ref's consult; until there is one, it runs a verified read of the child (below) every 5 s, and a recorded outcome (the child's notice landing) wakes it at once. Its timeout (milliseconds, a positive whole number; core waitForResultArgsSchema()) is one deadline, decided after each read; the sleep is clamped to the time left, and a deadline that passes before an interrupted child is confirmed (three reads) still answers at once. The answers are core companionWaitResultSchema(): the timeout is { name, status: 'timeout', currentStatus } with the ref's status; a child paused on client / approval calls answers { name, status: 'suspended', pendingCalls, message } at once (CP-40, above), never a timeout; unreachable is { name, status: 'unreachable', currentStatus, message } (see Eviction Recovery). The deadline also cuts a /status read that hangs: that is the typed timeout, not a read failure. A completed / failed answer marks the ref delivered (markChildOutcomeDelivered), so the notifier does not inject it again. A ref that already reads completed / failed with no recorded outcome for its consult reads the child once: the child's committed word for the consult answers (and is recorded), a live run of the consult keeps the wait going, and anything else answers the ref's own status at once, writing nothing. With no timeout it waits until the child finishes, the window answers unreachable, or the parent run is stopped.
  • The subAgentNamespace configuration is required (same as for ephemeral sub-agents). An agent with persistentAgents on a DO without it fails materialization with framework_not_supported (non-retryable; HTTP 500).
  • Persistent children maintain state across multiple interactions within their DO's SQLite.
  • The DO V4 migration adds mode and name columns to __agents_sub_session_refs for persistent child tracking.

No additional DO configuration is needed beyond what is already documented for ephemeral sub-agents.

Companion delivery contract. A child DO's /start carries the parent session's userId, tags and metadata, so the child's resolver and factories see the same identity. companion__sendMessage to a child that has not completed (a completed child is continued with a new /start turn instead) sends /resume with the message (with_message) and maps the child's answer onto the shared delivery contract:

Child's /resume answerTool result
2xx whose body parses as a RemoteStartResponse{ delivered: true }, then the parent records the started run on its ref (running, the child's run id)
2xx whose body does not parse (e.g. a hook's own 200){ delivered: false, reason: 'child_start_unconfirmed' }; the new consult is kept, unconfirmed (MR B; it was a typed tool error)
any non-2xx whose envelope carries inputCommitted: true{ delivered: false, reason: 'child_start_unconfirmed' }; the new consult is kept: the child sent it after its run-start commit, so a run serving the consult exists (MR B)
409 ALREADY_RUNNING (the child is executing){ delivered: false, reason: 'child_busy' }; the previous consult is restored
409 ALREADY_RUNNING, cause: 'resumed_companion_ref_terminated'{ delivered: false, reason: 'child_terminated' }; the previous consult is restored
no answer, a transport failure, a timeout, or a body that cannot be read{ delivered: false, reason: 'child_start_unconfirmed' }; the new consult is kept, unconfirmed (no child run recorded)
any other refusal{ delivered: false, reason: 'child_entry_failed', errorDetail } (W1 MR2b; it was a typed tool error); the previous consult is restored

The spawn and continuation /start are mapped by the same table (childEntryOutcome, one function for every entry site) onto core CompanionSpawnResult: a 2xx RemoteStartResponse is { name, status: 'spawned', delivered: true } (non-blocking) or the park (blocking); the other rows are { name, status: 'not_spawned', delivered: false, reason, errorDetail? } with the same reasons, a successful tool result. A 409 ALREADY_RUNNING attaches (started: true) only on a replay of the ref's own consult. Every /start is bounded by CHILD_START_TIMEOUT_MS (60 s, internal; DOCompanionDispatchContext.startTimeoutMs for tests), the same bound as /resume: a hung child DO answers child_start_unconfirmed at the bound, the consult kept, and the next same-name spawn re-probes it (below). What the ref does on a non-start is core's entry table (spec §3.7, I-C10): a first spawn's definite non-start (any reason but child_start_unconfirmed) writes the ref it created failed + delivered, with the detail in the answer (the outcome column stays NULL: a non-start has no run); a respawn's restores the ref exactly as it was, except that a held prior delivered by this answer is marked delivered (its own reset moved the ref's generation, so the slot's release would not mark it, and the next pass would present the outcome again; completionDelivered never suppresses a held prior — the held slot is the only delivery authority while set, so a crash before the commit still delivers it once from the slot, pinned by do-companion-notifier.test.ts); a continue's writes nothing, so a failed re-consult never wipes a completed child (FU-CORE-RESPAWN-AFTER-TRANSPORT-FAILURE-WIPES-COMPLETED-CHILD, closed). A dep that throws compensates nothing.

The /resume body carries the call's id as parentToolCallId (the consult), and the parent records that consult on its ref before it sends the request (below). "Restored" puts back every consult column as it was (the previous consult, its child run, its recorded outcome, its owed read and its delivery flag), only while nothing of the new consult landed; a restored consult with no recorded outcome owes a verified read, so an outcome its run reached meanwhile is still recorded. A failing restore is logged (companion.consult.restore_failed) and never hides the entry's own error. Before deciding on a running ref, sendMessage runs a verified read of the child (CP-39: a child whose run already committed the ref's consult is recorded first, so a completed child is continued instead of answered child_exited); it then still reads the child's session-level /status for its pre-send guard (stopping, the C-4 paused child), a second read that MR C folds into the first (FU-DO-SENDMESSAGE-TWO-STATUS-READS).

Every delivered: false result is a successful tool result (core CompanionDeliveryOutcome); a child whose loop ended before it could take the message answers child_exited. The /resume exchange, including the body read, is bounded by an internal 60 s timeout. A child whose concurrentEntryTimeoutMs is 60 s or more under interrupt_previous can therefore report child_start_unconfirmed instead of child_busy. Delivery to a running child (a message queued for its next step) is not offered yet: a running child answers child_busy. Every /status read of a child is bounded by 10 s (CHILD_STATUS_TIMEOUT_MS): every verified read, sendMessage's pre-send read, companion__getChildStatus's live read (a timeout answers the ref's own status) and a replayed spawn's probe (a timeout attaches; only a 404 means no child). The spawn and continuation /start are bounded by CHILD_START_TIMEOUT_MS (above; FU-DO-COMPANION-START-FETCH-UNBOUNDED, closed).

Re-spawning a completed persistent child continues it on its preserved session (memory retained) rather than recreating it — see Re-consulting a persistent companion (the critic loop).

Re-spawning a failed / terminated persistent child starts it on a fresh session under the same id (CP-142). The parent's re-spawn /start carries two DO-internal headers: x-helix-respawn-fresh-session and x-helix-reject-concurrent-entry (do/internal-headers.ts). So does a first spawn (CP-205). A re-spawned companion parent wipes only its own session (the DO's deleteSession refuses, and its wipe cannot reach its companions' DOs), then holds no ref for a name and re-derives the same child id, so without the headers its first spawn would run a new turn on the child its earlier incarnation left; every other store deletes that child with the parent. Core writes the parent's ref before the /start, so a first spawn never finds the current incarnation's child. An eviction replay of the same call (its ref was written, its /start may not have been sent) probes the child first: a run serving this call, a run with no consult or a failed probe attaches; a 404 starts plainly; a run serving ANOTHER consult (the earlier incarnation's) is started with the headers, and a busy answer (that run is still live) is the typed child_busy, never an attach. A replay of the call's own re-spawn (its ref reset landed, CP-161) attaches only to a run serving the call: a run with no consult is started with the headers too, a 404 keeps them, and a busy answer is child_busy. Every companion /start also carries the parent ref's generation (the DO-internal x-helix-parent-ref-generation, CP-161), and the child refuses one older than the generation it recorded (409 state_run_start_conflict, cause stale_parent_ref_generation) before it plans anything below, so a stale start never wipes. The child DO plans the start inside its request drive, under its producer claim, after the concurrency check:

The child DO findsAnswer
no sessiona plain first start
a stored parent other than the body's parentSessionId, or none (a root session)403 UNAUTHORIZED, errorCode: 'state_session_parent_mismatch'; nothing written
a live run (running or suspended)the busy 409 (ALREADY_RUNNING); a live run is never wiped
its current run already serves this start's consult, and the start names no newer parent-ref generation than that consult started at (a duplicate delivery of one start)the busy 409 (ALREADY_RUNNING); never a wipe (a live sender attaches on it)
a live companion of its last run that is not confirmed stoppeda retryable transport_error (503); nothing wiped (companions already stopped stay stopped); the parent answers not_spawned
otherwisewipe, then start

The live companions of the child's last run (and those earlier runs left running) are stopped first (abort, reason parent_respawned). A child that recorded the stop, or had nothing to stop, counts as confirmed. A failed or unanswered stop post does not, so no old companion is left running against a wiped session.

The wipe is ONE transactionSync. It removes the session's rows (state, messages, checkpoints, runs with their request keys, stop requests, staging, its own child refs and consult columns), the abandoned run-start tombstones, the stream's chunks and its run-keyed KV records (the wake record, settle / notice / cascade marks, owed settles, notice retries, owed child reads). It keeps the stream's sequence counter, so a reader resuming from an old cursor never re-reads a sequence, and the DO's internal usage table. Parked readers are woken. The new run writes stream_resync { reason: 'respawn' } (genesis basis) right after its run_started.

The wipe runs before the entry's admission (beforeEntry) and materialization. An entry refused after the wipe leaves the child with an empty session, and the parent's ref goes back to terminal (not_spawned), so the name stays re-spawnable. That matches the other stores, which delete the old session before they start the new one.

The parent check is not authentication. A companion's session id embeds its parent's (${parent}-agent-${name}), so anyone who can reach a DO's /subagent/* routes can name the right parent. The header is therefore honoured only on the sub-agent route, and the public createRemoteAgentWorkerHandler strips it before forwarding. The DO's native /start (the chat host's) ignores it. A worker that forwards client requests straight to a DO's /subagent/* routes must drop every x-helix-* header itself. A child without outputSchema that runs out of maxSteps ends failed, so it is re-spawned fresh (its memory is lost); give such a companion a stopWhen or a larger maxSteps if it must stay continuable.

How a parent learns its companion's outcome ​

A parent DO and its companion live in two Durable Objects with two stores, so the parent cannot read the child's run. It records the child's outcome on its SubSessionRef only from the child's own committed run for the ref's current consult (spec 2026-10-15). A consult is one parent tool call that starts a child run: the first spawn, a same-name spawn that continues a completed child, or a sendMessage that resumes or continues one. Its id is that call's toolCallId. A transport failure, a timeout or a busy answer never writes the ref (I-C8).

Every child entry names its consult.

  • The /subagent/:type/start and /resume bodies carry parentToolCallId (core RemoteStartRequest / RemoteResumeRequest). The child's run-start commit records it as RunMetadata.parent ({ sessionId, toolCallId }).
  • The parent records the consult on its ref BEFORE the request leaves (I-C2): the first spawn through core's ref insert, every other entry through DOStateStore.beginChildConsult. After a 2xx it records the started run (recordChildRunStarted). That write never overwrites an outcome of the same run, since the child's notice can arrive before the start answer (I-C3).
  • A run that continues a LIVE run (a client-tool or approval submit continuation, a resume() that carries no new consult) keeps that run's consult, a retry() keeps the failed run's, and a wake's recovery keeps its own run. A direct entry on the child that is not a consult records none, and its run never notifies.
  • runtime-js records the same RunMetadata.parent for its in-process companions (spawn, continue and sendMessage resume), so the run record means the same thing on every runtime.

The child's /status reports the run, not only the session. GET /subagent/:type/status adds:

  • runId and runStatus: the current run;
  • parentToolCallId: the consult that run serves;
  • committedOutcome: 'terminal': the run ENDED completed, failed or interrupted, and its outcome is committed. That means its own K5 is the latest checkpoint, or the run ended through the DO's R77 announced commit, so the session holds that same failed / interrupted status with no checkpoint. Core readCommittedRunOutcome(store, sessionId, { countAnnounced: true }) decides it.

With a committed outcome, output, error and errorDetail come from that committed session, sanitized. runStatus comes from the same read as committedOutcome. A failed read of these fields answers 503.

The child-settled notice (child → parent). It is settle step 5a (the settle path). A run that served a consult and ended completed, failed or interrupted posts to its parent:

http
POST http://do/companion/child-settled        (on subAgentNamespace.idFromName(parent.sessionId))
x-partykit-room: <parent session id>
{ "childSessionId", "childAgentType", "runId", "parentToolCallId", "status" }

The body is strict and carries no output: the notice is a wake-up, never the truth (I-C7). The parent answers { outcome: 'queued' | 'no_ref' | 'no_session' }:

Parent's answerThe child
any 2xx { outcome: 'queued' } (the route answers 202)delivered: writes the KV mark __helix_parent_notice:<runId>:<status>
409 { outcome: 'no_ref' } (no ref for the child), 404 { outcome: 'no_session' }given up: marked, logged at warn (companion.notice.given_up)
no answer within 10 s (PARENT_NOTICE_TIMEOUT_MS), a transport failure, 5xx, 408, 425 or 429owed: a retry record __helix_parent_notice_retry:<runId> ({ status, attempts, nextAt }), retried with a 1 s doubling backoff capped at 5 min
any other status (a 400, say), or a 2xx whose body is not an answergiven up (givenUp: 'rejected'), logged at error (companion.notice.rejected): retrying a permanent refusal would post every 5 min forever
  • An owed notice has no give-up by time. The parent-notice alarm subscriber fires at its absolute nextAt.
  • A drive's settle posts the notice and awaits the answer (up to 10 s on an unreachable parent). Every settle that is not a drive's records it owed instead (due now, or keeping an existing retry record) and arms the alarm.
  • A child evicted before its mark landed notifies on its next wake (redeliverPendingSettle). A run that a later run replaced before its notice landed is in the owed-settle set, whose next pass defers it.
  • A mark already present clears any leftover retry record, so the alarm never spins on one.
  • The notice never throws. A retry record that cannot be written is logged (companion.notice.retry_unrecorded) and keeps whatever owes the notice for the next pass.

The parent's route. POST /companion/child-settled is a DO-to-DO route, not in the worker handler's public allowlist.

  1. It parses the body: a malformed body is 400 validation_error.
  2. With no session it answers 404 no_session.
  3. It records a verified read owed for the child's ref (verify_owed_at). With no ref for that child it answers 409 no_ref.
  4. It arms the alarm, then answers 202 queued.
  5. It runs the owed reads in the background (ctx.waitUntil). If the instance is evicted first, the armed alarm runs them.

If the record or the alarm fails, the route answers 500, so the child keeps its notice owed and retries. A forged or stale notice costs one bounded read and writes nothing the child's store does not show.

The verified read. It reads GET /subagent/:type/status, bounded by 10 s (CHILD_STATUS_TIMEOUT_MS). One read is in flight per child and consult, and concurrent callers join it. An owed read always reads fresh: it never joins a read that started before it, which may predate the child's commit. The read writes the ref only through recordChildOutcome, a compare-and-set on the ref still tracking that consult and not terminated, and only when:

  • the child's current run serves the ref's consult (parentToolCallId); and
  • its outcome is committed (committedOutcome: 'terminal').

It writes the status (from runStatus) with the outcome { runId, status, output?, errorDetail? }, then wakes every tool parked on that child. Anything else writes nothing:

  • a run of another consult, or a child session with no run yet;
  • a live run (its own notice comes when it settles);
  • a terminated ref (sticky);
  • a failed read.

Owed reads (verify_owed_at, the child-verify alarm subscriber, due at the raw owed time):

  • A failed read of the child re-arms the debt with the notice backoff (at least 1 s), counting consecutive failures per child in KV (__helix_child_verify_attempts:<childSessionId>).
  • After 8 consecutive failures ending in a 404 (the child's session is gone), the read gives up: the debt and its count are cleared, companion.verify_given_up is logged at warn, and nothing is recorded. A run of 503s or transport failures never gives up.
  • A parent-side failure (its own consult store or KV) is logged (companion.verify_parent_failed). It is not counted, and the debt is kept, due again in 1 s.
  • Every debt write is a compare-and-set on the owed time the pass read, so a newer notice's debt is never erased or delayed.
  • One pass runs at a time. A call during a pass joins it, and exactly one more pass runs after it.
  • A re-arm write that fails holds the alarm deadline 1 s out (in memory), so a storage outage never spins the alarm.
  • With no subAgentNamespace no read can run, so no deadline is armed.

The DO's alarm scheduler runs one more pass when a reschedule arrives while a pass is computing, so a deadline written during a compute is never missed.

Where else a verified read runs (before any decision on a running ref):

  • the parent's completion notifier, before each pass reads the refs (CP-204, refreshCompanionOutcomes). A child ends its stream before it rings its parent (settle steps 2 and 5a) and the route above runs its read in the background, so a parent turn that starts right after a companion ended could otherwise find its ref still running and deliver the outcome one turn late. Every pass awaits the owed reads (the single-flight pass above), at most 2 s; the runner goes on in the background past that. A run's FIRST pass also reads every outstanding consult itself (a running, undelivered persistent ref whose consult has no outcome), fresh and in parallel, each waited at most 2 s (COMPANION_REFRESH_WAIT_MS, the read's caller signal; the read itself keeps its 10 s bound). A read the wait gave up on, or one that failed, records nothing and is left to the notice and the owed reads, never counted toward their give-up (companion.refresh_read_skipped). Later passes read no live child: a companion that settles mid-run is delivered at the first pass after its notice;
  • a spawnAgent that names an existing running child, before core's "already running" (this closes CP-39 even when a notice was lost);
  • sendMessage;
  • getChildStatus, which now records as well as reports;
  • the blocking-spawn park and companion__waitForResult.

It never runs for a replay of the ref's own consult (below). A failed read there decides nothing (logged at debug), and the tool decides on the ref as it stands. A failure of the parent's own store is rethrown. listChildren stays a ref read.

The park and the wait. A blocking spawn parks on its child, and companion__waitForResult waits on one.

  • Recorded outcome: a recorded outcome of the consult answers at once.
  • No outcome yet: a verified read runs every 5 s (CHILD_CONSULT_INTERVAL_MS), and a recorded outcome wakes the tool sooner.
  • terminated ref: answers terminated.
  • Unreadable child: a window of failed reads answers unreachable and writes nothing (Eviction Recovery).
  • interrupted child: the blocking park keeps waiting for a resume (MR C answers it typed, D7). The wait answers interrupted after three reads, as before.
  • The parent's stop: it ends both as before. The child is stopped by the parent's kind, and the tool returns the stopped status (MR C makes that stop consult-scoped and leaves the ref live until the child confirms).

There is no wall-clock bound on an untimed park: the child's own step cap bounds a running child.

The notifier. The DO hands runtime-js a companionNotifier (consultOutcomeNotifier, through buildDriveExecutor). Before each model call of the parent's run, it injects every undelivered completed / failed persistent ref with the recorded outcome's output, or its errorDetail.message: Sub-agent '<name>' completed with result: … / … failed: ….

  • An outcome that is not the word of the run serving the ref's consult is skipped, not marked, and delivered once the right one is recorded. So is a missing outcome.
  • The notice never starts a turn on an idle parent. A non-blocking companion's result is delivered on the parent's next turn (or its next step, if the parent is still running).
  • completionDelivered keeps delivery exactly once. A blocking park and a waitForResult that answered the outcome inline mark it (markChildOutcomeDelivered), so the notifier never repeats it.
  • A same-name spawn of a completed child whose outcome was delivered continues it on its preserved session (CP-39).
  • Every notice carries its key, metadata.helixCompanionNotice = { subSessionId, runId } (the consult run whose outcome it is; the DO's notifier names it). A recovery that finds the notice already committed marks the ref delivered without injecting it again (FU-COMPANION-NOTIFIER-MARK-SEPARATE-WRITE, folded); a key without a run never dedupes (the flag alone decides, so a mark crash can double-inject but never loses an outcome).
  • A continue delivers an undelivered outcome, then continues (deliver-then-continue, MR B). A child can finish while the parent's model call is in flight (after that iteration's notifier ran), and CP-39's in-tool reconcile records it then. A continue over that outcome (a same-name companion__spawnAgent on a completed / failed child, a companion__sendMessage to a completed one) would clear it (beginChildConsult) before the model saw it. Instead it HOLDS it first and carries it as priorResult beside its own answer, on every arm: spawn { name, status: 'spawned', delivered: true, priorResult } (non-blocking), the park's { name, status: 'completed', output, priorResult } (blocking), sendMessage { delivered: true, priorResult }, and a not_spawned / delivered: false answer still carries it, with priorResult { status, output?, errorDetail? }. The new consult runs. "Undelivered" means "not marked delivered": a model that read the output through companion__getChildStatus (which does not mark it) gets it once more.
    • The held prior. The hold is DOStateStore.holdPriorResult (on every store, SessionStateStore): a CAS that moves the recorded outcome into the ref's held slot (sub_session_refs.held_prior, DO-SQLite migration 18), keyed on the continuing call's tool call id, at the ref's outcome generation read in the SAME read (outcome_generation; every reopen bumps it; the DO reads the ref, then the consult row). It never marks delivered. The DO's holdPriorResult runs BEFORE beginChildConsult (so the new consult cannot clear what the call delivers) in the shared continue path (startChildContinuationOutcome: the continue dep and the bespoke sendMessage's completed branch); core asks the same hold in its respawn branch (a failed / terminated ref), before deleteSession.
    • The release is the delivered mark, after the commit that carried the answer. On the DO the companion tools run runtime-js's regular tool path (no post-commit hold registry), so the release (releasePriorResult, a CAS on the holder's call id that clears the slot and marks delivered only while the generation is unchanged) is the notifier's HELD-PRIOR PASS at the top of the parent's NEXT iteration, before generateStep and so before the model's next tool calls (do-companion-notifier.test.ts pins that it runs every iteration). The pass asks the committed history, structurally: a tool row for the holder's call that CARRIES the prior, or a notice keyed on the slot's run → release only; anything else (the call never committed, or its row carries no prior — a tool error after the hold) → the held outcome is injected ONCE as a completion message, then released. Never a text match. So the two crash windows are covered: an eviction between the hold and the step commit (the discarded step's answer never landed: the recovered pass delivers the notice), and an eviction or a failed release after the commit (the row is in history: the pass releases without a second presentation). Pinned over a real eviction by companion-settle-notice.integ.test.ts.
    • A respawn asks for the hold too (core's respawn branch, before deleteSession): a failed ref holds its undelivered failure; a terminated ref never holds (the CAS requires completed / failed, and the DO dep answers null otherwise). So two parallel continues of one child in one step cannot both claim the outcome: the CAS has one holder, the other answers child_busy (the DO) with no prior.

A replayed spawn answers from the recorded outcome (ruling C14), for a replay that reuses the call id. When the re-run step re-emits the same tool-call id (a deterministic or replayed model output), the ref's consult is that call and its outcome is recorded, core handleSpawnAgent answers from it (CompanionToolDispatchDeps.recordedConsultOutcome):

  • a blocking spawn returns the recorded result, and the ref is marked delivered;
  • a non-blocking one answers spawned.

No /start is sent and the child never runs the message again. A replay whose child is still running attaches and parks.

A DO recovery re-calls the model, and a real provider (Anthropic, OpenAI) mints a fresh tool_use id, so the re-emitted spawn is not a replay:

  • the outcome was recorded before the recovered model call: the notifier injects it, and the fresh-id spawn then continues the child (a second run of the instruction);
  • the child is still live: the spawn answers the "already running" tool error;
  • the outcome is recorded during the recovered model call: the spawn is a continue over an undelivered outcome, so it delivers it as priorResult (above) AND runs the child again — the verdict is never lost, exactly one delivery of it in history, and the second run's answer rides the same call (pinned by the companion-settle-notice.integ.test.ts fresh-id case, a real eviction).

FU-DO-COMPANION-ORPHANED-CONSULT-ADOPTION (MR C) adopts the orphan: the consult whose tool call is not in the parent's committed history is re-pointed to the new call id, which parks on it, so the child runs once.

An unconfirmed start is re-probed, never respawned (MR B). A /start that ends without a confirmed answer (the bound, a transport failure, a non-conforming 2xx, a refusal sent after the child's run-start commit — inputCommitted) leaves the consult on the ref with no child run (child_run_id NULL) and the ref running. A sendMessage resume of an interrupted child whose /resume ends the same way leaves its consult unconfirmed on a ref still interrupted. The next companion__spawnAgent of that name, and any getChildStatus / listChildren / sendMessage, runs the verified read first on every non-terminal ref, interrupted included (reconcileLiveRef, GL #174; before, only a running ref was read, so an interrupted ref over a child whose resumed run had ended stayed interrupted: sendMessage answered child_exited and a same-name spawn was refused): a committed run of the consult is recorded (the ref takes its word); a live run of the consult CONFIRMS it (the ref gets child_run_id; the spawn then answers "already running"); a committed run of ANOTHER consult proves it never started, so the ref ADOPTS that word (DOStateStore.abandonUnconfirmedConsult, one CAS) — marked delivered only if the parent's committed history records it (the notice keyed on that run, or the held slot's carrying answer), else undelivered so the notifier delivers it once — and the spawn continues or respawns; a 404 (no session) proves it never started, the ref reads failed and the spawn respawns; a live run of another consult, a session with no run yet, or a consult-less committed run leaves it unconfirmed; a failed read leaves it too, and the spawn answers child_start_unconfirmed again without dispatching. Nothing is ever wiped on uncertainty. A respawn whose /start times out can adopt a word that was delivered inline (a waitForResult answer, a released slot): a visible duplicate, never a loss (FU-DO-COMPANION-INLINE-DELIVERY-UNRECORDED, MR C).

One namespace. The child reaches its parent through its own subAgentNamespace (idFromName(parent.sessionId)), so the parent and every companion must live in that one namespace, the documented topology above. A companion DO with no subAgentNamespace cannot notify its parent. It logs companion.notice.no_namespace at warn once per instance, at its onStart (when its session has a parent) and at a settle. Its parent's in-tool verified reads still record the outcome, but a non-blocking result is not delivered until the model touches that child.

Storage. DO-SQLite migration 17 adds four nullable columns to sub_session_refs:

  • consult_tool_call_id: the consult the ref tracks;
  • child_run_id: the last child run seen serving it; NULL with a consult means the start is not confirmed;
  • outcome: JSON { runId, status, output?, errorDetail? }, the committed word last recorded;
  • verify_owed_at: when an owed verified read is due.

These columns are behind DOStateStore's ChildConsultStore methods (getChildConsult, beginChildConsult, restoreChildConsult, recordChildRunStarted, recordChildOutcome, markChildOutcomeDelivered, abandonUnconfirmedConsult, setChildVerifyOwed, setChildVerifyOwedIf, listChildVerifyOwed). Each is one transactionSync. Core SubSessionRef is unchanged by them, since on every other runtime the consult lives on the child's run record in the shared store.

DO-SQLite migration 18 (MR B) adds three more nullable columns to sub_session_refs, behind the core SessionStateStore methods holdPriorResult / releasePriorResult (on every store: Postgres V19, D1 V23, Redis hash fields, memory): held_prior (JSON HeldPriorResult, the held prior above), outcome_generation (bumped by every reopen: a non-live ref written live) and held_generation (its value at the hold; the release marks delivered only while they still match).

A companion paused on client calls (MR C part 2, CP-40). Nothing on the DO can answer a companion's client or approval call, so:

  • The spawn is refused (refuseChild, core companionNotSupportedDetail): a persistent agent whose tree can call a client-executed or approval-gated tool answers { status: 'not_spawned', reason: 'child_not_supported', errorDetail } with the code framework_companion_not_supported, before any ref, claim or /start; the complete offender list is logged once (companion.spawn.child_not_supported).
  • The child refuses it too (materialize step 2c): a companion /start or /resume of such an agent is refused typed, framework_companion_not_supported (HTTP 500, non-retryable), before anything is written (drive.companion_not_supported); a companion re-spawn materializes before its old session is wiped, so a refused re-spawn wipes nothing. The parent answers that entry child_not_supported (core maps the code). "Companion" is the DO-internal x-helix-companion-entry: 1 header, set by the parent's one child-entry exchange on every companion /start and /resume (so none can leave it out) and deleted by the public worker boundary (so no outside caller can set or forge it). A consult alone is not enough: a CP-157 remote sub-agent also starts with a parentToolCallId and may have client or approval tools, which its root answers, so it is never refused; and on the parent, a committed suspension is a word only for a persistent ref (a remote ref reads it live).
  • A committed suspension is reported. A child whose run is suspended_client_tool over a paused session reports committedOutcome: 'suspension' on /subagent/:type/status (its calls ride the CP-157 pendingCalls listing) and rings its parent (suspended_client_tool is a ringing status). The parent records the word paused_awaiting_client with the calls (completed_at stays null), and the blocking park and companion__waitForResult answer { name, status: 'suspended', pendingCalls, message } at once; sendMessage answers child_awaiting_client. A suspension with no listed call is read as live (the calls were answered meanwhile). A run suspended_awaiting_children / suspended_step_partial stays live. The completion notifier never delivers a pause.

Not in this release. The parent's child stops are not consult-scoped yet, and the stop cascade does not read the consult. sendMessage still reads the child's /status twice (FU-DO-SENDMESSAGE-TWO-STATUS-READS).

Log events: companion.notice.no_namespace, .post_failed, .not_queued, .given_up, .rejected, .retry_unrecorded, .rearm_failed, .failed, .retry_corrupt; companion.verify_failed, .verify_given_up, .verify_parent_failed, .verify_rearm_failed, .verify_list_failed, .verify_owed_failed, .verify_reschedule_failed; companion.park.unreachable; companion.reconcile.read_failed (debug); companion.child_read.store_failed; companion.consult.restore_failed; companion.spawn.child_not_supported (warn, with the full offenders); drive.companion_not_supported (warn, the child's backstop).

Exposing agents to other services ​

DOStubTransport only reaches DOs in the same worker. To let a different service consume this DO app as a remote agent producer, mount createRemoteAgentWorkerHandler in your worker's fetch. It bridges worker-level routes onto the DO's internal /subagent/:agentType/* endpoints, resolving the DO instance with namespace.idFromName(sessionId) — the same mechanics DOStubTransport uses, lifted to a worker fetch handler.

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

interface RemoteAgentWorkerHandlerConfig<TEnv> {
  /** DO namespace hosting the agent DOs (the same binding the DOs use as `subAgentNamespace`). */
  namespace: (env: TEnv) => SubAgentNamespace;
  /** agentType allowlist. Default: all registered (no allowlist). */
  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` (i.e. the warning is silent). */
  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>;
}

const handler: (request: Request, env: Env) => Promise<Response> =
  createRemoteAgentWorkerHandler<Env>(config);

Route map ​

Eight routes, all under {basePath}/:agentType/:

MethodRouteNotes
POST{basePath}/:agentType/startsessionId from the JSON body
POST{basePath}/:agentType/resumesessionId from the JSON body
GET{basePath}/:agentType/sse?sessionId= + exclusive ?fromSequence=
GET{basePath}/:agentType/status?sessionId=
GET{basePath}/:agentType/snapshot?sessionId= — protocol-shaped, checkpoint-pinned
GET{basePath}/:agentType/usage?sessionId= — recursive rollup
POST{basePath}/:agentType/interruptsessionId from the JSON body
POST{basePath}/:agentType/abortsessionId from the JSON body

A wrong HTTP method for a matched action is a 405 METHOD_NOT_ALLOWED; a missing sessionId is a 400 BAD_REQUEST; a non-allowlisted agentType is a 404 NOT_FOUND (never FORBIDDEN — the handler does not confirm which agent types exist); an unreachable DO stub is a 502 UPSTREAM_ERROR. All of them use the shared RemoteAgentErrorResponse envelope.

Two mounts: raw + projected ​

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 and end.state pass through projection, and /usage + end.usage pass through usageProjection. Every other frame is forwarded byte-faithfully with its original id: sequence, so fromSequence resumption still works. Live incremental projected state streaming is out of scope — projected mounts serve state via snapshot + end.state.

Mounting the handler twice gives one internal full-fidelity mount and one external lossy mount:

typescript
// producer/src/worker.ts
import { createRemoteAgentWorkerHandler } from '@helix-agents/runtime-cloudflare/do';
import { ProducerAgentServer } from './my-agent-server.js';
import { externalStateProjection, externalUsageProjection } from './projections.js';

export { ProducerAgentServer };

// RAW internal mount — consumed by the consumer worker's serviceBindingTransport.
// `agents` allowlists the planner AND its descendant agentType so consumers can
// fetch nested baselines with getSnapshot(childSessionId).
const internalMount = createRemoteAgentWorkerHandler<Env>({
  namespace: (env) => env.AGENTS,
  agents: ['planner', 'thread-researcher'],
  basePath: '/internal/agents',
  // Service bindings are private by construction; a raw mount that is NOT
  // private MUST set `auth` instead.
  allowUnauthenticated: true,
});

// PROJECTED external mount — lossy state view + billing-unit usage.
const externalMount = createRemoteAgentWorkerHandler<Env>({
  namespace: (env) => env.AGENTS,
  agents: ['planner'],
  basePath: '/agents',
  auth: (request, env) => request.headers.get('Authorization') === `Bearer ${env.PRODUCER_API_KEY}`,
  projection: externalStateProjection,
  usageProjection: externalUsageProjection,
});

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/internal/agents/')) return internalMount(request, env);
    if (url.pathname.startsWith('/agents/')) return externalMount(request, env);
    return Response.json({ error: 'Not found', code: 'NOT_FOUND' }, { status: 404 });
  },
};

Mount isolation is auth-only

idFromName(sessionId) is mount-agnostic, so both mounts reach the same DOs. The fidelity separation exists only at this worker layer — a caller who reaches the raw mount gets raw state regardless of which mount "owns" the session. Isolate by auth (or by not routing the raw mount publicly at all), never by assuming the mounts address different objects.

Auth posture

Unlike AgentServer (which is fail-closed — its constructor throws without authenticate or allowUnauthenticated: true), this worker handler is warn-and-serve: it logs once and proceeds, because worker handlers commonly sit behind private service bindings where a hard throw would be a false alarm. Consequences:

  • The auth hook gates all eight routes, including GET /sse and GET /snapshot (the highest-exfiltration routes) and the spend-inducing /start and /resume.
  • Service bindings are private by construction; public mounts MUST set auth. Set allowUnauthenticated: true to acknowledge and silence the warning.
  • The warning is emitted through the injected logger — which defaults to noopLogger, so pass a real logger if you want to see it.
  • There is no built-in rate limiting and no SSE connection bounding at this layer. Put those upstream (WAF / gateway) for public mounts.

Descendant allowlist

agents is an allowlist of the :agentType path segment. If consumers are expected to fetch nested baselines — getSnapshot(grandchildSessionId) — the descendant agent types must be in agents too, and the request must carry the descendant's own agentType: the DO SQL-validates the path :agentType against the session's stored agent_type and 404s on a mismatch.

Non-goals of this layer (v1): the handler does not validate workspace wiring — a misconfigured workspace surfaces as a runtime WorkspaceFailedError inside the DO, exactly as it does without the handler — and it does not bridge the /workspace introspection route.

See the Cross-Service Remote Agents guide for the consumer side (serviceBindingTransport, snapshot/usage folds, reconstruction), and @helix-agents/runtime-cloudflare for the full API reference.

DOUsageStore ​

The DOUsageStore tracks token usage, tool calls, and sub-agent invocations using the DO's built-in SQLite. It is the store a drive uses when usageStore is not configured.

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

// Created internally by AgentServer
const usageStore = new DOUsageStore({
  sql: this.sql, // PartyServer's sql tagged template
  sessionId: 'session-123',
  logger: this.logger,
});

// Implements UsageStore interface
await usageStore.recordEntry(entry);
const entries = await usageStore.getEntries(sessionId);
const rollup = await usageStore.getRollup(sessionId);

It is bound to its session. A recordEntry for another session throws HelixError validation_error and inserts nothing, and getEntries / getRollup read only rows of the bound session (filtered in SQL; a row whose data is not valid JSON is excluded and reported once per read with a warning).

A configured consumer store is strict. When usageStore is set, a drive calls the factory and never falls back to the internal store: a factory throw is framework_usage_store_unavailable (retryable), so a request answers 503 and a wake re-arms. A consumer usage-database outage stops runs instead of splitting usage across two stores.

getRollup(sessionId, { includeSubAgents: true }) now aggregates sub-agents. It previously skipped them entirely. Because a DO owns exactly one session, a child's entries can never resolve locally — so each sub-agent group resolves via the embedded SubAgentUsageEntry.remoteRollup that the consumer executor recorded on the parent when the child completed (harvested from the child's end.usage, or from getUsage() on recovery). Embedded rollups are already includeSubAgents: true results computed producer-side, so grandchildren arrive pre-folded; exactly one rollup is selected per subSessionId (never summed).

Getting Usage via HTTP ​

typescript
// DO-native /usage. `mode` DEFAULTS TO 'self' — this is the parent agent only.
const response = await stub.fetch('/usage');
const rollup = await response.json();

// mode=rollup is the only mode that sets includeSubAgents: true
const response = await stub.fetch('/usage?mode=rollup');

// Get raw entries
const response = await stub.fetch('/usage?mode=entries');

/usage and /subagent/:agentType/usage are different surfaces

The DO-native /usage above reads the sessionId from the DO's in-memory execution state (it returns null for an unknown or evicted session, and ignores any ?sessionId=), and its mode parameter defaults to self — includeSubAgents is true only when you pass ?mode=rollup.

The cross-service protocol route {basePath}/:agentType/usage?sessionId= (bridged by createRemoteAgentWorkerHandler) is a different handler: it takes the sessionId from the query, SQL-validates the session and its agentType (404 RemoteAgentErrorResponse envelope on a mismatch — never null), forces includeSubAgents: true (the recursive fold is the protocol contract), and returns { sessionId, rollup }.

Reaching for the native /usage when you wanted the recursive fold silently gets you null or a self-only rollup. Cross-service consumers must call the protocol route (or transport.getUsage(sessionId), which does).

Usage Response ​

typescript
interface TokenCounts {
  prompt?: number;
  completion?: number;
  reasoning?: number;
  cached?: number;
  total?: number;
}

interface UsageRollup {
  runId: string;

  // Token usage for this agent only
  tokens: TokenCounts;
  tokensByModel: Record<string, TokenCounts>;

  // Token usage including sub-agents (local, sibling-DO, and remote children)
  tokensIncludingSubAgents: TokenCounts;

  // Tool execution statistics
  toolStats: {
    totalCalls: number;
    successfulCalls: number;
    failedCalls: number;
    totalDurationMs: number;
    byTool: Record<
      string,
      {
        calls: number;
        successfulCalls: number;
        failedCalls: number;
        totalDurationMs: number;
      }
    >;
  };

  // Sub-agent execution statistics
  subAgentStats: {
    totalCalls: number;
    successfulCalls: number;
    failedCalls: number;
    totalDurationMs: number;
    byType: Record<
      string,
      {
        calls: number;
        successfulCalls: number;
        failedCalls: number;
        totalDurationMs: number;
      }
    >;
  };

  // Custom metrics aggregated by type, then by name
  // Example: { 'api_calls': { 'tavily': 5 }, 'bytes': { 'input': 1024 } }
  custom: Record<string, Record<string, number>>;

  // Timing
  startedAt?: number;
  lastUpdatedAt?: number;
  entryCount: number;
}

Sub-agent usage now aggregates across DOs

Sub-agents run in separate Durable Objects, so a child's usage entries live in a different SQLite database than the parent's. tokensIncludingSubAgents used to contain the parent's tokens only. It now includes sub-agents: the child's recursive rollup is embedded on the parent's SubAgentUsageEntry.remoteRollup when the child completes, and getRollup({ includeSubAgents: true }) folds it in. Remote (cross-service) children resolve through the same field.

Migration from Legacy AgentServer API ​

The subclass-based AgentServer API has been removed. Use createAgentServer() instead:

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

const registry = new AgentRegistry();
registry.register(myAgent);

export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      model: openai('gpt-6-luna', { apiKey: env.OPENAI_API_KEY }),
    }),
  agents: registry,
  concurrentEntry: 'interrupt_previous',
});

For the hook, factory and wire changes of the drive pipeline release, see the DO drive pipeline upgrade guide.

Limitations ​

LLM Adapter Serialization ​

LLM adapters contain functions and API clients that can't be serialized. With the composition API, the llmAdapter factory handles this automatically since it runs inside the DO.

Single DO Per Run ​

Each agent run gets its own DO instance. This means:

  • No shared state between runs (use D1 if needed)
  • Each run has its own SQLite database
  • Geographic pinning based on first access

Geographic Pinning ​

DOs are pinned to a region on first access:

typescript
// First access pins to nearest region
const stub = env.AGENTS.get(doId);

// All subsequent access goes to that region
// Consider region affinity for latency-sensitive apps

Memory Limits ​

DOs have memory limits. For very long conversations:

  • Consider truncating message history
  • Use checkpoints to manage state size
  • Monitor memory usage in production

No Step-Level Durability ​

Unlike Workflows, the DO runtime doesn't have automatic step-level retries:

  • If the DO crashes mid-execution, state reflects last committed step
  • Use checkpoints for crash recovery
  • Consider Workflows runtime for critical durability needs

Best Practices ​

1. Keep Connections Alive ​

typescript
// Client-side keepalive
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'ping' }));
  }
}, 30000);

2. Handle Reconnection ​

typescript
// Track last received sequence
let lastSequence = 0;

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.sequence) {
    lastSequence = data.sequence;
  }
};

// Reconnect from last sequence
function reconnect() {
  ws = new WebSocket(`wss://.../ws/${sessionId}?fromSequence=${lastSequence}`);
}

3. Use Proper Error Handling ​

typescript
try {
  const response = await stub.fetch('/start', { ... });
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }
} catch (error) {
  // Handle DO errors (network, timeout, etc.)
  console.error('DO error:', error);
}

4. Monitor Subrequest Usage ​

typescript
// The only subrequests should be LLM API calls
// Monitor via Cloudflare dashboard to ensure streaming is free

Deployment ​

Development ​

bash
wrangler dev

Production ​

bash
wrangler deploy

Secrets ​

bash
wrangler secret put OPENAI_API_KEY

Next Steps ​

Released under the MIT License.