Skip to content

Cloudflare Workflows Runtime ​

The Cloudflare Workflows runtime executes agents using Cloudflare Workflows for durable execution with D1 for state storage and separate Durable Objects for stream management.

When to Use Workflows

Choose the Workflows runtime when you need step-level durability, automatic retries, or want to share D1 state with other services. If your agents require heavy streaming (>100 chunks), consider the Durable Objects runtime instead.

Architecture ​

mermaid
graph TB
    subgraph Edge ["Edge Location (Global)"]
        subgraph Worker ["Cloudflare Worker"]
            W1["HTTP endpoints<br/>CloudflareAgentExecutor<br/>Starts Workflows"]
        end

        Worker --> Workflow

        subgraph Workflow ["Cloudflare Workflow"]
            WF1["Agent execution steps<br/>LLM calls<br/>Tool execution"]
        end

        Workflow --> D1
        Workflow --> DO

        D1["<b>D1 Database</b><br/>Agent state<br/>Messages"]
        DO["<b>Durable Object</b><br/>Stream events<br/>Real-time streaming"]
    end

Prerequisites ​

  • Cloudflare account with Workers Paid plan
  • Wrangler CLI: npm install -g wrangler
  • D1 database for state storage
  • Durable Objects for streaming

Installation ​

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

Setup Guide ​

1. Configure wrangler.toml ​

toml
name = "agent-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# Required for client disconnects: request.signal fires when a client leaves, so the
# chat handler closes its stream readers promptly. No compatibility date implies it.
compatibility_flags = ["nodejs_compat", "enable_request_signal"]

# D1 Database for state
[[d1_databases]]
binding = "DB"
database_name = "agent-state"
database_id = "your-database-id"

# Durable Object for streaming
[durable_objects]
bindings = [
  { name = "STREAM_MANAGER", class_name = "StreamManagerDO" }
]

[[migrations]]
tag = "v1"
new_classes = ["StreamManagerDO"]

# Workflow binding
[[workflows]]
name = "AGENT_WORKFLOW"
class_name = "AgentWorkflow"

2. Use Programmatic Migrations ​

The D1StateStore uses programmatic migrations that are automatically applied. Call runMigration() on startup:

typescript
import { runMigration } from '@helix-agents/store-cloudflare';

// Run migrations on startup (safe to call every request - no-op if already migrated)
await runMigration(env.DB);

The framework creates tables automatically with session-centric naming (all prefixed with __agents_). The current schema is V9:

sql
-- Core state table (V1 baseline + V8 + V9)
CREATE TABLE __agents_states (
  session_id TEXT PRIMARY KEY,
  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,
  failure_reason TEXT,                  -- V7 cascade discriminator
  parent_session_id TEXT,
  aborted INTEGER DEFAULT 0,
  abort_reason TEXT,
  user_id TEXT, tags TEXT, metadata TEXT,
  pending_client_tool_calls TEXT,       -- V7 HITL
  completed_client_tool_calls TEXT,     -- V8 idempotency
  suspension_context TEXT,              -- V9 stateless suspension
  expires_at INTEGER,                   -- packed inside suspension_context JSON
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

-- Messages table (separated for O(1) append)
CREATE TABLE __agents_messages (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL,
  sequence INTEGER NOT NULL,
  message TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  UNIQUE(session_id, sequence)
);

-- Sub-session refs (V4 adds mode + name for persistent sub-agents)
CREATE TABLE __agents_sub_session_refs (
  session_id TEXT NOT NULL,
  sub_session_id TEXT NOT NULL,
  agent_type TEXT NOT NULL,
  parent_tool_call_id TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  mode TEXT,                            -- V4: 'persistent' | 'ephemeral'
  name TEXT,                            -- V4: persistent child name
  remote_json TEXT,                     -- V2: remote agent metadata
  started_at INTEGER NOT NULL,
  completed_at INTEGER,
  PRIMARY KEY (session_id, sub_session_id)
);

-- Run tracking (__agents_runs is part of the V1 baseline)
CREATE TABLE __agents_runs (
  run_id TEXT PRIMARY KEY,
  session_id TEXT NOT NULL,
  turn INTEGER NOT NULL,
  status TEXT NOT NULL,
  step_count INTEGER NOT NULL DEFAULT 0,
  started_at INTEGER NOT NULL,
  completed_at INTEGER
);

suspension_context packs suspendedAwaitingChildren, suspendedStepId, tracingContext, and expiresAt. A partial index on json_extract(suspension_context, '$.expiresAt') enables operator cleanup of abandoned sessions. See docs/storage/cloudflare.md for the complete schema and migration progression V1→V9.

3. Create the Durable Object ​

typescript
// src/stream-manager-do.ts
import { DurableObject } from 'cloudflare:workers';

export class StreamManagerDO extends DurableObject {
  private chunks: Map<string, StreamChunk[]> = new Map();

  async write(streamId: string, chunk: StreamChunk): Promise<void> {
    const chunks = this.chunks.get(streamId) ?? [];
    chunks.push(chunk);
    this.chunks.set(streamId, chunks);

    // Notify connected clients via WebSocket
    this.ctx.getWebSockets().forEach((ws) => {
      ws.send(JSON.stringify(chunk));
    });
  }

  async read(streamId: string, fromOffset: number): Promise<StreamChunk[]> {
    const chunks = this.chunks.get(streamId) ?? [];
    return chunks.slice(fromOffset);
  }

  async fetch(request: Request): Promise<Response> {
    // WebSocket upgrade for real-time streaming
    if (request.headers.get('Upgrade') === 'websocket') {
      const [client, server] = Object.values(new WebSocketPair());
      this.ctx.acceptWebSocket(server);
      return new Response(null, { status: 101, webSocket: client });
    }
    return new Response('Expected WebSocket', { status: 400 });
  }
}

4. Create the Workflow ​

The recommended pattern is to use createWorkflowRunner (or call runAgentWorkflow directly), which encapsulates the v7 stateless suspension contract:

typescript
// src/workflows/agent-workflow.ts
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
import {
  AgentRegistry,
  runAgentWorkflow,
  type AgentWorkflowInput,
  type AgentWorkflowResult,
} from '@helix-agents/runtime-cloudflare';
import { D1StateStore, DOStreamManager } from '@helix-agents/store-cloudflare';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { registry } from '../registry.js';

export class AgentWorkflow extends WorkflowEntrypoint<Env, AgentWorkflowInput> {
  async run(
    event: WorkflowEvent<AgentWorkflowInput>,
    step: WorkflowStep
  ): Promise<AgentWorkflowResult> {
    return runAgentWorkflow(event, step, {
      stateStore: new D1StateStore({ database: this.env.DB }),
      streamManager: new DOStreamManager(this.env.STREAM_MANAGER),
      llmAdapter: new VercelAIAdapter({
        /* ... */
      }),
      registry,
      workflowBinding: this.env.AGENT_WORKFLOW,
    });
  }
}

runAgentWorkflow returns early at every HITL boundary with one of three 'suspended_*' statuses (see Stateless Suspension Model below). The executor restarts the workflow with mode: 'resume' when the user submits results or otherwise unblocks the run.

Recording usage (optional) ​

The CFW Workflows runtime records usage only when you pass a usageStore into the workflow dependencies — the workflow body runs in a separate execution context from the executor, so the executor's usageStore powers only the read side (getUsageRollup). To record token, tool, custom (ctx.recordUsage), and sub-agent/companion usage, add a shared/aggregating store (a D1UsageStore over the same D1 binding):

typescript
import { D1UsageStore } from '@helix-agents/store-cloudflare';

return runAgentWorkflow(event, step, {
  stateStore: new D1StateStore({ database: this.env.DB }),
  streamManager: new DOStreamManager(this.env.STREAM_MANAGER),
  llmAdapter: new VercelAIAdapter({
    /* ... */
  }),
  registry,
  workflowBinding: this.env.AGENT_WORKFLOW,
  // Records usage to the SHARED D1 store so `getUsageRollup({ includeSubAgents: true })`
  // can surface sub-agent / persistent-companion cost on the parent.
  usageStore: new D1UsageStore({ database: this.env.DB }),
});

Without usageStore, no usage is recorded (rollups are empty). Configure the same store kind on your CloudflareAgentExecutor for the read side. See Usage Tracking.

5. Create the Worker Entry ​

typescript
// src/index.ts
import { CloudflareAgentExecutor } from '@helix-agents/runtime-cloudflare';
import { D1StateStore, DOStreamManager } from '@helix-agents/store-cloudflare';
import { AgentWorkflow } from './workflows/agent-workflow';
import { StreamManagerDO } from './stream-manager-do';

export { AgentWorkflow, StreamManagerDO };

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

    // Create executor
    const executor = new CloudflareAgentExecutor({
      workflowBinding: env.AGENT_WORKFLOW,
      stateStore: new D1StateStore(env.DB),
      streamManager: new DOStreamManager(env.STREAM_MANAGER),
    });

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

      const handle = await executor.execute(agent, { message }, { sessionId });

      return Response.json({
        sessionId: handle.sessionId,
        streamUrl: `/agent/stream/${handle.sessionId}`,
      });
    }

    // GET /agent/stream/:sessionId - SSE stream
    if (url.pathname.startsWith('/agent/stream/')) {
      const sessionId = url.pathname.split('/').pop();
      const handle = await executor.getHandle(registry.get('default'), sessionId);

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

      const stream = await handle.stream();
      return new Response(
        new ReadableStream({
          async start(controller) {
            for await (const chunk of stream) {
              controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`);
            }
            controller.close();
          },
        }),
        {
          headers: {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
          },
        }
      );
    }

    // GET /agent/result/:sessionId - Get result
    if (url.pathname.startsWith('/agent/result/')) {
      const sessionId = url.pathname.split('/').pop();
      const handle = await executor.getHandle(registry.get('default'), sessionId);

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

      const result = await handle.result();
      return Response.json(result);
    }

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

Agent Registry ​

Register agents so the workflow can instantiate them:

typescript
// src/registry.ts
import { AgentRegistry } from '@helix-agents/runtime-cloudflare';
import { ResearchAgent, AnalyzerAgent } from './agents';

export const registry = new AgentRegistry();
registry.register(ResearchAgent);
registry.register(AnalyzerAgent);

Executor API ​

Creating the Executor ​

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

const executor = new CloudflareAgentExecutor({
  workflowBinding: env.AGENT_WORKFLOW, // From wrangler.toml
  stateStore: d1StateStore,
  streamManager: doStreamManager,
});

Executing Agents ​

typescript
// With a string message
const handle = await executor.execute(
  MyAgent,
  { message: 'Research quantum computing' },
  {
    sessionId: 'custom-session-id', // Optional
  }
);

// Or with multiple messages (for context injection, file attachments, etc.)
const handle = await executor.execute(
  MyAgent,
  {
    message: [
      { role: 'user', content: 'Focus on recent breakthroughs', metadata: { source: 'system' } },
      { role: 'user', content: 'Research quantum computing' },
    ],
  },
  { sessionId: 'custom-session-id' }
);

Getting Handles ​

typescript
const handle = await executor.getHandle(MyAgent, sessionId);

if (handle) {
  const result = await handle.result();
  console.log(result);
}

result() polls the instance until it can no longer run. It keeps waiting while the instance is queued, running, waiting (hibernating on a sleep or event), waitingForPause or operator-paused, with no overall timeout, so a run waiting on a client tool or a sub-agent is never reported failed. A status read that throws rejects that call only; the next result() polls again. A failed run reports the workflow's own error and errorDetail. Race result() with your own timer if you need a deadline. A handle kept across deleteSession (a child session removed by its parent's cascade included) reports the instance's recorded outcome (an aborted run stays a framework_cancelled failure), or a typed state_session_not_found failure if the instance recorded none; getHandle() for a deleted session returns null.

Errors and retries ​

  • LLM step errors. A retryable LLM error (classified retryable: true), thrown by the adapter or returned as an error step, is rethrown so step.do retries the LLM step under llmStepRetry (3 retries by default). This is the one rule every runtime uses (core isRetryableLLMStepError). An unclassified error (no code) is not retried, because it may be deterministic (DI-35). The retried attempt streams under a new attempt id, and the superseded attempt's partial output is dropped by the snapshot and by a useHelixChat client that re-anchors on data-attempt-superseded. When the retries run out, the run fails with the provider's own message and errorDetail. A non-retryable or unclassified error (for example provider_auth_error) fails the run at once.
  • The step's own work before the model call. The rule covers the whole LLM step: an error there classified retryable: false (a persistent history mismatch, state_history_incomplete) fails the run at once with its detail, never repeated by step.do. The boundary is the model call itself (set immediately before the adapter is called), not a position in the code. Before it, an UNCLASSIFIED error from the step's I/O (its state and history reads, its step_start write, a beforeLLMCall hook's state save) is an unknown, possibly transient, fault and keeps step.do's retry. An unclassified throw from the step's deterministic work (building its tool set and its prompt, the agent's systemPrompt function included) or from a user hook fails the run at once: a retry would repeat it (DI-35). From the model call on, an unclassified error is not retried. (runtime-js does not retry a failed history read at all; the CF DO re-arms its wake on a transient entry failure under its crash-loop budget.)
  • Error chunks. Only a terminal error is written to the stream as an error chunk; a client stops reading at one, so a per-attempt chunk would hide the retry. One LLM failure is one chunk: the run's terminal seam writes it (with the classified errorDetail), never the LLM step too. A child run does not own its parent's stream, so its model error reaches the parent as the child's outcome, not as an error chunk in the parent's stream.
  • Terminal detail. The run's errorDetail comes from core resolveErrorDetail(): a classification anywhere on the error's cause chain is kept, and an unrecognised error is recorded as { message } with no code.
  • Aborts. handle.abort() terminates the owner instance and records failed with framework_cancelled; result() reports that detail (see Abort Handling).
  • Instances closed outside the product. A run whose instance was terminated from the dashboard or wrangler, errored, or disappeared before its terminal commit is reconciled by the next executor reader: finished with its committed outcome when its terminal commit landed, reported suspended_* when its suspension was committed, else settled failed (see Instances closed outside the product).
  • Entry fail-safe. execute() / resume() / retry() wait for their entry's outcome for at most entryOutcomeFailSafeMs (120 000 ms). An entry with no outcome by then is abandoned (RunStartAbandonedError, retryable). A run that committed but has not started (no run_started in its own stream, or no entry state) gets the same bound again until its instance closes or is paused, measured from when the call first saw the commit (a transient owner read or abandon failure does not end it), so a real stall can take up to about twice the bound. If it still has not started, it never gets a handle: the instance is terminated and, once stopped, the run is reconciled as any reader reconciles a run whose instance closed (see Instances closed outside the product), with the stall as the failure of a run that committed nothing: it is recorded failed and the call throws core RunStartStalledError (transport_error, settle: 'failed'). A terminal commit the instance landed before it stopped is never overwritten: that run is finished with its committed outcome and the call returns its handle. It is never retryable: the call's input is already committed, so repeating the call would append it twice. Every outcome's message names the recovery the same way: executor.retry(agent, sessionId) (pass options.message if the run committed a step or its entry carried no user message of its own). The exception is an entry into a session PAUSED on stored answers (a resume(), e.g. one that adopted an owed __submit__ continuation): when its run was proven not started its stall leaves the session paused (its run-start commit only re-stamped the answers, and the settle completes the suspension or writes nothing), which retry() refuses, so its error carries recovery: 'resume' and names executor.resume(agent, sessionId, { mode: 'continue' }); the next continuation drains the stored answers. Every other stall carries recovery: 'retry'. Without a message such a run's retry() is refused validation_error: a startUnknown run may have committed a step before it was stopped, and a resume() in continue / from_checkpoint mode with no message commits only heal rows, so its run has no trigger to re-run. Its settle and message follow what happened: 'not_committed' when the failed write did not land (a reader such as getHandle() settles the run, then retry()), 'superseded' when a newer run had already taken the session over. A run with an outcome of its own (it ended, its terminal commit landed, or its suspension is committed) is no stall: the call returns its handle, whose result() reports that outcome. If the instance could not be stopped, nothing is recorded: the run may still go on, so read the session (or getHandle()) first. On a committed run's start grace, the instance is only terminated on a stall the executor actually read: a failed read of the run, its own stream or the session is re-read, and a start that still cannot be read gets one more grace pass. If it is still unreadable then, the call throws RunStartStalledError with startUnknown: true and ownerStopped: false (nothing stopped or recorded; up to about three times the bound). The abandon (abandonRunStart) is made up to 3 times (25 ms × attempt backoff), so a transient store error still lands the tombstone; a non-retryable error is not retried (logged at error level). When every attempt fails, it leaves no tombstone: the call re-reads the run (3 attempts) and, if it finds none, terminates the instance (the only fence against a late commit), then re-reads the run again. Still none: RunStartOutcomeUnknownError (non-retryable; read the session first). A run that committed before the terminate landed gets one start check (3 attempts, no grace): not started → RunStartStalledError; start unreadable → the same with start: 'unknown' (startUnknown: true), whose message says its start could not be confirmed; started → the same with start: 'started' when the terminate landed (this call killed a running run: no handle, and the message says the fail-safe terminated it), the handle when the terminate failed (the run goes on). Either stall is recorded as its settle says when the terminate landed (failed, or not_committed, not_found or superseded as above; a run that already ended returns its handle), since a stopped instance can never finish the run; when the terminate failed, it is ownerStopped: false and nothing is recorded. A terminate() that throws because the instance had just closed on its own (complete, errored, terminated or gone, read back from the engine) counts as stopped. The run may have started or even finished in that window, so a stall first re-checks its start: a run that started returns its handle and is never recorded as a stall. A terminate that throws while the instance is still live, or whose status cannot be read, is ownerStopped: false.
  • Entry failures before the run-start commit. An execute() / resume() / retry() whose instance fails before its run-start commit (for example a history read that comes up short) returns a handle whose result() is failed with the typed errorDetail (also exposed as handle.preCommitFailure), and never touches a session that already has a run: its status, errorDetail, stream and hooks are unchanged.
    • A session that never had a run is recorded failed, and that is the only write: core failSessionWithoutRun saves the session under the idle fence, with no stream created, read, failed or written and no hook (user ruling R43). Streams are written only by their runs, so a late failure write can never fail the next turn's live stream or leave an error chunk after its cursor. A run that commits meanwhile owns the session, and the write is dropped.
    • The failure write is retried. A failed read or save rejects inside its step.do (error-run-exists: did the run start; then error-pre-commit: the write), each of which retries it 3 times (1 s exponential backoff, 10 s per attempt: about 47 s at most per step, about 94 s for both). When the entry's own steps fail fast, the pre-commit path ends inside the 120 s entryOutcomeFailSafeMs; the entry's own steps are not bounded that way (check-existing keeps the engine's default retries, and run-start has no per-attempt timeout and may wait up to 30 s for an owner, over up to 3 attempts), so an entry that hangs there can outlast the fail-safe, and the caller then gets the typed RunStartAbandonedError / RunStartOutcomeUnknownError. When either step's retries run out, the workflow still returns the entry's own classified failure, so preCommitFailure is never replaced by a store error: an exhausted failure write is logged at error level; an exhausted run check (no run committed) is logged at warn and the failure write is skipped. A never-run session is then left active with no run.
    • An instance that closed with no run and no failure output (killed or errored before it could record anything) still gives the call a preCommitFailure, derived from the engine status: errored → the engine's message, no code; terminated → framework_cancelled; complete → a message naming what was observed, no code. Each message says the entry committed no run, and the handle's result() reports the same detail. The session is left as the entry left it (a brand-new one active with no run). This holds only while the session exists: a session deleted during the wait (which removes its runs too) gets no preCommitFailure when the instance recorded a non-failure outcome, else a typed state_session_not_found. Whether the entry committed can no longer be read then, so a deleted session's preCommitFailure carries the instance's recorded failure (possibly a committed run's) or that typed state_session_not_found.
  • Refusals. execute(), resume() and retry() reject a workspace-declaring agent with a typed framework_not_supported error before anything is written. A workflow started directly for an unknown agent type ends failed with the error recorded on the session.
  • Completion-support refusals. An outputSchema agent whose model has no completion strategy (an unrecognised Claude model id, or a completion schema native structured output cannot carry) is refused in the workflow body before its run-start commit: execute() / resume() / retry() reject with the typed, non-retryable framework_completion_strategy_unavailable / framework_completion_schema_unsupported. An existing session is left untouched; a brand-new session's row (created by that execute() before the instance starts) records failed with the typed detail, so its snapshot and getHandle() report the failure. The decision is recorded in a step.do (<agentType>-completion-support), so a replay reuses it: a deploy that changes how the model resolves (a capability-table change, a removed capabilities override) never fails an instance that was already admitted. Cloudflare Workflows does not run llmConfigOverride, so the check uses the base llmConfig.
  • Forced-completion provider errors. A retryable error on a forced call is rethrown for the step.do retry without consuming a forced attempt; a non-retryable or unclassified one fails the run with the provider's detail. A run whose retries run out fails with its forced phase closed failed in the same terminal commit. A provider code that is not a Helix ErrorCode is dropped from the run's errorDetail when the retries run out, as on runtime-js and Temporal (a work call's error keeps it).
  • Skills. A skills provider that throws fails the LLM step with the retryable framework_skills_unavailable, so step.do retries it. An invalid skills configuration (for example duplicate in-code skill names) throws an unclassified error, which fails the run at once (it is not retried, DI-35).

Instance ids and session-id characters ​

Cloudflare accepts a Workflows instance id only if it is at most 100 characters of [A-Za-z0-9_-] (first character not -), and applies the same rule to waitForEvent / sendEvent event types. Session ids and agent names are not restricted on any runtime, so the runtime builds every instance id and event type with buildWorkflowInstanceId / buildWorkflowEventType: the readable id (agent__<name>__<sessionId>[__<kind>__<key>]) when it is valid and no part contains __ (or starts / ends with _), otherwise h_<SHA-256 of the parts>_<readable tail>. The two forms never collide, so a crafted session id cannot take another session's instance id. Session ids containing ., :, @ or spaces, long ids, and deeply nested sub-agent or companion ids all work. A child notifies its parent at the parent's actual instance id.

Every child instance id is keyed on its parent run (C7): a sub-agent spawn is …__spawn__<parent-run suffix>, a companion spawn …__spawn__<parent-run suffix>-<callId>, a companion continuation / resume …__continue__<parent-run suffix>-<step>-<callId> / …__resume__<parent-run suffix>-<step>-<callId>, and a respawn …__respawn__<parent-run suffix>. The ids stay deterministic within one parent run, so a replay rebuilds the same id, and a re-executed child-create step whose create() already landed adopts its own instance (already_exists) instead of failing. A reused parent session id (execute → deleteSession → execute) whose model repeats a tool-call id therefore starts a fresh child instance. The child session id is not keyed on the run, but deleteSession deletes the parent's child sessions too (RM-64), so that child, and a companion spawned again under the same name, start on fresh sessions: no turn of the deleted conversation reaches their model. A child behind a remote agent tool (location: 'remote') is never deleted. The real-engine proof is the e2e delete-session-cascade-cfw-engine.wf.test.ts.

Multi-Turn Conversations ​

A second executor.execute(agent, msg, { sessionId }) on an existing session starts a new turn on the preserved history, as on every other runtime:

  • Which sessions continue: a completed, failed or interrupted session, or a paused one with no pending client-tool calls (the same set as runtime-js and the CF DO). A session whose run is live (active), or one paused on pending client-tool calls, is refused with AgentAlreadyRunningError before anything is written.
  • What a new turn does: its run-start commit appends the new user message and starts a new run record; only then (the winner only) it resets the step count (maxSteps is a per-turn budget) and the previous turn's output, error and errorDetail, reactivates the session's ended stream (a stop recorded for an earlier run never reaches it). So an outputSchema turn 2 cut off by maxSteps or stopWhen enters forced completion exactly like turn 1 (earlier releases kept turn 1's output: the maxSteps cut-off failed framework_max_steps_exhausted, and a stopWhen stop completed turn 2 with turn 1's output).
  • One instance per call: Workflows instance ids are write-once, so every turn runs in its own instance (agent__<name>__<sessionId>__turn__<key>), as resume and retry runs do (__resume__<key>, __retry__<key>). The key is minted by the call itself, so a call that loses writes nothing (no counter). The run id embeds the instance id, so getHandle() finds the live instance from the session's current run record; until the new instance's run-start commit lands there is no live run yet, and getHandle() returns a reconnect handle.
  • The previous turn finishes first: a turn marks the session terminal just before its instance ends the stream, fires onAgentComplete and finalizes its run. A new execute(), a resume() or a retry() arriving in that window waits for the previous instance to finish (up to priorInstanceSettleTimeoutMs, default 30 s, then AgentAlreadyRunningError), instead of racing it.
  • A cancelled start is recoverable: if a Worker is cancelled after execute() created a new session but before the instance existed, the session is left with no run, and the next execute() simply runs it (its run-start commit finds no live run).
  • Caller state: execute(agent, { message, state }, { sessionId }) on a continuation replaces the session state (parsed through the state schema), as on runtime-js.
  • Suspended parents: a paused session suspended on a sub-agent's pending client tool is refused too; resolve or interrupt it first.
  • One winner: the only write execute() makes before creating the instance is a write-once createSession for a new session. The instance's run-start commit decides the single winner: a concurrent execute() of the same session loses with AgentAlreadyRunningError (its instance's entry writes nothing), and the stream is made live only by the winner, after its commit.
  • The handle's stream is live when the call returns: execute(), resume() and retry() return once the instance's entry has started: its run-start commit landed, the run wrote its run_started into its own stream (or already left running), and its entry state was written. run_started is the proof a reader can attach: a claim does not create a Durable Object stream (its /info reads a never-created one as active), only the entry's writer does, just before run_started. A child writing into its parent's stream never claims it, so its call returns on the run record. Before T1-B a call on a new or active session returned right after the commit, and on a loaded host handleChatStream could find no stream yet and answer the SEND with data-resume-rejected / stream_unavailable while the run went on unread.

Using sessionId ​

typescript
// First message - creates a new session
const handle1 = await executor.execute(agent, 'Hello, my name is Alice', {
  sessionId: 'session-123',
});
await handle1.result();

// Continue the conversation - same sessionId
const handle2 = await executor.execute(agent, 'What is my name?', {
  sessionId: 'session-123',
});

Using handle.send() ​

typescript
const handle1 = await executor.execute(agent, 'Hello', {
  sessionId: 'session-123',
});
await handle1.result();

const handle2 = await handle1.send('Tell me more');

Using Direct Messages ​

typescript
const handle = await executor.execute(agent, {
  message: 'Continue from here',
  messages: myExternalMessageHistory,
});

Multi-Message Input ​

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

typescript
const handle = await executor.execute(
  agent,
  {
    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' }
);

Multi-message input also works with handle.send():

typescript
const handle2 = await handle1.send([
  { role: 'user', content: 'Additional context' },
  { role: 'user', content: 'Follow-up question' },
]);

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

Behavior Table ​

InputMessages SourceState Source
message only (new session)Empty (fresh)Empty (fresh)
message + sessionId (existing)From sessionFrom session
message + messagesFrom messagesEmpty (fresh)
message + stateEmpty (fresh)From state
message + sessionId + messagesFrom messages (override)From session
message + sessionId + stateFrom sessionFrom state (override)
All fourFrom messages (override)From state (override)

Note: In the behavior table, message can be either a string or UserInputMessage[]. Both forms work identically with all combinations.

See JS Runtime - Multi-Turn Conversations for detailed documentation.

Streaming ​

Server-Sent Events ​

typescript
// Worker endpoint
if (url.pathname.startsWith('/stream/')) {
  const stream = await handle.stream();

  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const chunk of stream) {
          controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
        }
        controller.close();
      },
    }),
    {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    }
  );
}

WebSocket (via Durable Objects) ​

typescript
// Client connects to DO WebSocket
const ws = new WebSocket(`wss://your-worker.workers.dev/ws/${streamId}`);

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

Sub-Agent Handling ​

Sub-agents execute as nested workflow calls:

typescript
// In workflow step
const subAgentResult = await step.do('sub-agent-call', async () => {
  // Start child workflow
  const instance = await this.env.AGENT_WORKFLOW.create({
    id: `agent__${subAgentType}__${subSessionId}`,
    params: {
      agentType: subAgentType,
      sessionId: subSessionId,
      streamId: parentStreamId, // Same stream
      message: inputMessage,
      parentSessionId: parentSessionId,
    },
  });

  // Wait for completion
  return pollUntilComplete(instance);
});

A child whose instance closed without notifying ​

A child normally ends by sending its parent a completion event. When the parent's wait for that event ends with none (the child's instance was killed, errored or disappeared before it notified), the parent consults the child's run. It acts only when the child's current run serves this consult and its owner instance is provably closed, and then reconciles the child exactly as an executor reader would (see Instances closed outside the product), fenced on the child's run. The parent then takes the path of a delivered event:

Child runWhat the parent records
suspension committeda suspended child: the parent cascades its suspension (suspended_awaiting_children), never a failure
its own terminal commit landedthe committed outcome: complete with the output, errored with the error, terminated for an interrupt
ended, its own terminal commit is the latest checkpointthe same committed outcome (an earlier consult's failed stream end is repaired first)
live with nothing committedthe run is settled failed with how its instance closed, and the parent records that failure (errored, AgentError)
failed with no terminal commit of its own (its failure written without a checkpoint)the same recorded failure (errored, AgentError)
owner live or unreadable, another consult's run, superseded, or completed / interrupted without its own terminal commit as the latest checkpoint (its notification may still be in flight)nothing yet: the parent waits again
live with nothing committed, and its failure write did not landnothing yet: the parent waits again, and its next consult retries the write

A failed child's outcome, whether its completion event or a consult, carries its committed errorDetail with its code, category and retryable (DI-75). The parent's copy goes through core's client preset (sanitizeErrorDetail): the free text is redacted, the code kept. A blocking companion's failed answer carries it, as on runtime-js and the CF Durable Object. An ephemeral sub-agent's failure row (the first wave, the wait bound and a respawn) stamps it in its metadata, so a reload's tool_end carries the code; only CF Workflows does that so far (FU-EPHEMERAL-ROW-TYPED-DETAIL-PARITY), and the row's text stays raw (FU-RAW-ERROR-TEXT-MODEL-SINK). The child's own session keeps the raw detail. A detail the event schema refuses is dropped, never the whole event.

A child with nothing committed gets one answer whoever settled it: this consult, a later consult, or an executor reader (getHandle(), result()) that settled it first. The child's stream never decides the answer: a stream status that cannot be read is planned as absent, and a stream write that fails is logged (reconcile.stream_unavailable) and repaired by a later reader, so a child whose outcome is durable is answered while its stream Durable Object is down. Once the owner is proven closed the consult reads the child's run again and plans on that read, so a child an executor reader settled meanwhile is answered at once. A store read that fails is logged (cfw.consult.closed_child_failed) and the parent waits again. A permanent error (a non-retryable one, such as no workflow binding to probe the child's instance, or a corrupt stored row: InvalidStoredStateError, CorruptMessageRowError, UnknownCommitKindError, also inside a D1 D1StateError) is logged at error level and recorded as the child's typed errored result (its error.name the error's class), never waited out to a timeout. The two companion waits (a blocking companion__spawnAgent and companion__waitForResult) take the consulted event the same way. A companion paused on client / approval calls (a suspended event whose suspension.childStatus is suspended_client_tool, delivered or consulted) is answered { name, status: 'suspended', pendingCalls, message } (the awaited calls in the child's call order, read inside the record step) at the blocking spawn, the blocking continue and companion__waitForResult, and its ref records paused_awaiting_client after the answer's commit, at the site's generation, never delivered (CP-40 (C2); it was recorded failed). A later-turn companion__waitForResult with no consult of its own reads that suspension from the store (readCommittedChildSuspension: the child's run suspended_client_tool over a paused session). A suspension whose calls were all answered meanwhile is answered spawned (the wait: its typed timeout, running) with nothing written. A suspended_awaiting_children / suspended_step_partial event is still recorded as a failed one (FU-CFW-COMPANION-WAIT-SUSPENDED-CHILD-AS-FAILED).

Remote Sub-Agent Handling ​

Remote sub-agents are executed via a dedicated executeRemoteSubAgentCall step, separate from both regular tool calls and local sub-agent workflow spawning.

The step provides:

  • Deterministic session IDs — {parentSessionId}-remote-{toolCallId} for idempotent restarts
  • Crash recovery — On step retry, checks transport.getStatus() to avoid re-executing completed agents
  • Stream proxying — Remote agent chunks flow through the parent's stream
  • SubSessionRef tracking — Registers with remote: { streamId, lastSequence } metadata
  • Interrupt propagation — Abort-check interval propagates interrupts to the remote call
  • Stopping a child no dispatch runs (CP-152) — When a stop ends a parent run that nothing executes (the executor's stop or abort() of a run whose instance closed, a reader settling a closed owner's run), and in the body's own stop for every live remote child none of its dispatches runs, each such child is stopped through its transport by the stop's kind, bounded at 10 s. Since RM-69 no committed call waits on such a child (its step was lost with its instance), so no entry records its outcome: the parent's ref stays running, and ref.stopOwed is stamped { kind, at } when the child did not answer, and cleared when it does. The next entry's lost-step sweep (D3) re-stops the child and settles its ref; a stop still owed on the settled ref is retried at later entries and stops, with backoff, and abandoned after 24 h (CP-203). See Interrupt Propagation.
  • Stopping a local child no wait runs (CP-159) — The same stop cascade (stopChildrenOfRun) reaches the run's live LOCAL children too: a parent parked on a child suspended on a client tool or on its own children (suspended_awaiting_children), and, in a sub-agent wave, a sibling that suspended while the body still waited on another, and a child the body still waits on (the body records the stop on that child's run, but a child that suspended and closed its instance with its event lost has no body left to read it). The child's current run is stopped by core stopRun: an instance that still executes it is signalled and cascades from its own body; a run nothing executes gets the stop's terminal at once, and the cascade then reaches that child's own children, all of a level's subtrees in parallel (a cascade depth levels deep takes at most depth × 10 s). A child already stopped answers not_stoppable (a second stop writes no second terminal). A child is stopped only at the ref generation the cascade read: its run is read first, then the parent's ref again, and a ref a newer consult reopened (or that settled) meanwhile is left to that consult. Each level is pinned to the run whose children it stops (the stopped run; at a child's level, the run the cascade ended): once a newer run is its session's current run (a resume that registered new children, or adopted the stopped run's suspended ones), that run owns them and the cascade stops none of them (stop.cascade_superseded); the newer run's own stop reaches them. The parent's ref records the child's terminal as runtime-js's companion dispatcher does when it sees a child end (CP-68): interrupted for an interrupt (not terminal: no completedAt, the companion stays resumable and the next companion__sendMessage resumes it), failed for an abort. A child the cascade ENDS is recorded once its own children were visited; a child it only SIGNALS (its instance executes it) is waited for within the same 10 s bound and recorded from the terminal the cascade observes (a child that completed before it handled the stop reads completed, its output still delivered to the parent's next turn); past the bound its ref keeps running with stopOwed, as a remote child's unconfirmed stop. Every one of these ref writes is scoped to the ref generation the cascade checked before it stopped the child (CP-161, core writeChildOutcome): a ref a newer consult reset while the cascade waited (a new generation whose child run has not started yet) is left to that consult, never overwritten or stamped stopOwed, and a ref the parent terminated at that generation stays terminated (both logged, nothing written). A cascade that dies between the child's terminal and that write leaves the ref running over an ended run; a cascade that runs again (the body's stop-children step re-run) treats such a child as ended: it reaches its children, then writes the ref. With no second cascade the ref stays running (FU-CFW-STOP-CASCADE-CHILD-REF-CRASH-WINDOW).
  • Timeout enforcement — Configurable per-tool timeout

See Remote Agents Guide for setup and configuration.

Trace propagation. An entry's telemetryParent (EntryTraceOptions on execute, resume and retry, and a submit's 2nd argument, which handleChatStream passes) is recorded in the instance's params as traceParent (serialized TraceIds). Every remote hop of the run is pinned to it through core transportFor:

  • the dispatch's start, stream, status and resume;
  • its stops;
  • the entry's settle of a lost step's remote children;
  • the run's stop cascade (stopChildrenOfRun): its live remote children, the remote children of a suspended local child it ends, and the stops they still owe.

So a remote child's run joins the caller's trace. A CFW-local child instance inherits traceParent, so its remote calls are in the same trace. A stop of a run its instance no longer executes (a suspended run, stopped from the executor) is not an entry and carries no parent, so that cascade's remote stops are unpinned. This needs no tracer provider: the W3C traceparent carries the caller's span directly. CF Workflows emits no spans of its own yet (FU-OTEL-CFW).

Persistent Sub-Agent Handling ​

Persistent sub-agents in the Cloudflare Workflows runtime are managed through companion tools and execute as nested workflow instances:

  • Blocking spawn: Creates a child workflow instance and waits for completion within a workflow step.
  • Non-blocking spawn: Creates a child workflow instance and continues without waiting.
  • State tracking: The D1StateStore V4 migration adds mode and name columns to __agents_sub_session_refs for persistent child tracking.

Companion tool results flow through the workflow step context. Persistent children use the same SubSessionRef tracking as ephemeral sub-agents but with mode: 'persistent'.

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

An interrupted persistent child stays resumable: its ref is recorded 'interrupted' (it used to become failed, or stay a stale running), and companion__sendMessage resumes it in a fresh instance agent__<type>__<childSession>__resume__<parent-run suffix>-<stepCount>-<toolCallId> that streams to the child's own stream. Before, the resume tried to recreate the child's base instance id, which Cloudflare's write-once ids reject. The resume instance carries companionRef: true, so its run-start commit turns the parent's ref running (StartRun.resumedCompanionRef; a refused or lost start writes no ref), and a resume whose child agent the adapter cannot finish is refused in the parent's companion step as a tool error, before any instance is created (CP-103). See Interrupted companions are resumable.

Delivery is reported from the child's run-start commit (CP-105, W1 MR2b). A companion__sendMessage that resumes an interrupted child, or continues a completed one as a detached __continue__ instance, records its tool result only after the parent has polled, in its own body, for the child's exact run id (<child instance id>:<child agent type>-run-start:0): one step.do read per poll (…-companion-entry-poll-<callId>-<n>), then step.sleep, backing off 0.1, 0.2, 0.4, 0.8, 1.6, 3.2 s, then 5 s. The run exists → { delivered: true }. The child's instance closed with no run → { delivered: false, reason: 'child_busy' } when its entry lost the run-start commit (state_run_start_conflict), else { delivered: false, reason: 'child_entry_failed', errorDetail }. No outcome within companionEntryOutcomeTimeoutMs (a WorkflowOptions field, default 60 000 ms; about 17 polls, about 34 engine steps per send at worst, and 2–6 steps for a normal delivery) → { delivered: false, reason: 'child_start_unconfirmed' }. A delivered: false outcome is a successful tool result (core CompanionDeliveryOutcome); the model reads the reason and may send again. The continue path no longer resets the parent's ref before the child exists: both the resume and the continue instance set companionRef: true, so the ref turns running and undelivered inside the child's run-start commit, and a lost continuation writes nothing (it used to leave the ref running with the child still completed). A detached (non-blocking) spawnAgent continue is polled the same way (FU-WAIT-TIMEOUT-STATUS-TWO-SHAPES round 12): it answers { name, status: 'spawned', delivered: true } once its run has committed, else { name, status: 'not_spawned', delivered: false, reason, errorDetail? } (a successful tool result). A detached continue or a resume whose entry definitely never starts (child_busy, child_terminated, child_entry_failed) leaves a later waitForResult on the child answering its real outcome (the previous consult is restored); a blocking spawnAgent continue does not yet (FU-CFW-BLOCKING-SPAWN-CONTINUE-RESTORE). A child instance whose create is replayed adopts its own instance by get().

A first spawn's or respawn's child is created only by a run that owns its session (CP-161 fixes 6 to 9). The companion step claims the name (or resets it); the child's instance is created in a later step.do of the workflow body. That step first proves the parent run is still the session's current live run (assertRunOwnsSession). On a supersede in between (reachable: an entry that finds the instance's status unreadable for cfwUnknownTerminateAfterMs supersedes the run while the instance may still execute), it creates no instance, releases the claim its companion step landed (the ref failed, delivered; a respawn's exact restore) unless a child run already serves the call, and the run ends superseded. The created child's run-start commit is also fenced on the parent run (StartRun.parentRunFence): a child whose creating run is no longer the parent's current LIVE run (superseded, or its turn ended, stopped or failed before the child's commit: a slow child whose spawn was answered child_start_unconfirmed and whose parent turn then ended) is refused parent_run_superseded and never runs, and that child releases the parent's claim or reset itself (parentSpawnClaim, core releaseSpawnClaimForRefusedChild; a CAS on the entry's generation), because that parent run may never do it. A first spawn's claim is released failed, delivered, and a respawn's is restored exactly: a refused child did no work, so no report OF THE REFUSED CHILD reaches the model (orchestrator ruling D1; the drop is logged at info, companion.spawn.refused_child_report_dropped, naming the call); a respawn's exact restore keeps whatever the old generation still owed (an undelivered old outcome is still reported). A child that actually ran keeps its report. A same-call replay re-opens the ref's generation before it creates its own child (CP-161 fix 8), so an earlier child's late release conflicts instead of freeing the new child. Since RM-69 a lost step leaves no trailing tool_use for a successor to re-run (the step's call is in no committed row; a claim it landed is the stranded-claim reclaim's), so no known path re-runs a committed call in another execution: the re-open is kept as a defensive guard for same-call replays. A stranded claim is reclaimed (CP-161 fix 9). When the parent instance is killed between its companion step and the create step (no child exists to release the claim), or the refused child's own release is lost (one step.do; a store outage past its retries), the next spawnAgent of that name reclaims it: the companion step reads, after its run's run-start commit, the claiming call's tool rows (none, or only a synthetic not-executed one), checks that the call is not one of the run's own, and that no child run serves it (none, or a terminal run of another call), then releases the claim through the CAS at its generation fenced on the current run, and the spawn proceeds as a respawn. Remote refs are never reclaimed. Limits: only a spawnAgent that meets the name reclaims it (waitForResult, sendMessage and listChildren still read a stranded ref as running, and an auto-named strand is never met again: FU-CFW-STRANDED-CLAIM-OTHER-READERS); a stranded respawn reset is reclaimed failed, delivered (its exact restore is gone; a held prior is still delivered by the next held-prior pass). A spawn child's run-start also refuses, typed (state_agent_type_mismatch), a session of another agent type, which a stale child could recreate after the successor's respawn delete. A direct execute() / resume() of a companion child's session id from user code is outside the companion model: its run records no parent and carries no parent-run fence, so a reclaim never fires over it and nothing fences it.

A companion's ref follows its answer's commit (RM-69 x CP-161, D2). Since a step commits once, after its last result, every ref write a child's outcome drives — a blocking spawn's, waitForResult's or a blocking continue's delivered mark, and the interrupted status of a child that stopped resumable — is a post-commit effect of the step (r.afterAnswer), applied after the step's one commit through core writeChildOutcome at the generation its entry or read produced (a stale write, or one over the parent's terminated at that generation, writes nothing, logged). A step that never commits therefore leaves the ref as it was: after a lost step (or an instance gone between the commit and its post-commit step) the ref stays running over a child that ended interrupted, as a self-interrupted child already leaves it; companion__sendMessage decides from the child's session and resumes it, and the reader fold of CP-184 is what settles the ref. A same-step sibling call sees the ref only after the step's commit (a sibling waitForResult on the same name parks until its first consult, as it already does for the post-commit delivered marks). The claim, reset and release CASes are not outcome writes: they precede the work they guard.

Stateless Suspension Model (v7) ​

The Workflows runtime in v7 implements a fully stateless suspension model: the workflow body returns early at every Human-In-The-Loop (HITL) boundary instead of polling or holding the workflow open. This eliminates v6's billable wall-time during HITL waits (typical reduction of ~80% on multi-minute approvals or client-tool calls).

Suspension Boundaries ​

runAgentWorkflow returns one of three 'suspended_*' statuses on AgentWorkflowResult when execution hits a HITL boundary:

StatusMeaning
'suspended_client_tool'One or more client-executed tools are pending; awaiting submitToolResult
'suspended_awaiting_children'Sub-agents have suspended; parent waits for child resume cascades
'suspended_step_partial'Mixed server+client tool batch; phase-1 settled, phase-2 (finishWith) blocked

A step that co-emits a client-executed (or approval-gated) call and a sub-agent call (local or remote) runs the sub-agent first, as runtime-js and the CF Durable Object do (CP-145): the child runs, and the run then suspends 'suspended_step_partial' on the pending client call, the suspension committing the step with the child's result (RM-69). A child that itself suspends cascades as usual, with the client call in the same suspension. A cascade suspension waits for the step's remote sub-agent calls first, so their results are in the suspension commit (RM-92). The step's companion and finishWith calls run after the resume, as for any client-tool suspension.

These are exported as SUSPENDED_WORKFLOW_RESULT_STATUSES (a tuple) and SuspendedWorkflowResultStatus (the union type) from @helix-agents/runtime-cloudflare for use in route handlers and discriminator logic.

Workflow Input mode ​

The AgentWorkflowInput carries a mode: 'fresh' | 'resume' discriminator:

  • 'fresh' (default): brand-new workflow instance; skip the resume drain, proceed to init / execute.
  • 'resume': drain submitted/expired pending client-tool entries via the applyResultsAndReload activity BEFORE iterating, then continue.

Each resume runs in its own instance named agent__<name>__<sessionId>__resume__<key>, where <key> is minted by the resume() call (a from_checkpoint rewind is a resume mode, in the same kind of instance). A retry uses __retry__<key>. All of these go through buildWorkflowInstanceId.

The executor also passes runId: the id the instance's run-start commit creates, ${instanceId}:${agentType}-run-start:0 (CP-88). Instances created without it (sub-agents, companions, direct create() callers) derive the same id from their instance id.

Resume Activities ​

Two activities (executed via step.do) underpin the protocol:

  • applyResultsAndReload — At the top of a 'resume' workflow, drains any pending client-tool submissions or expired-pending sweeps from durable state, applies them to the staged step, and re-loads SessionState. Returns the next stepId and the updated state.
  • commitSuspendedStep — Persists the suspension boundary atomically: writes suspendedStepId, suspendedAwaitingChildren, pendingClientToolCalls, and the new status into __agents_states.suspension_context in a single db.batch([...]) (D1) or transaction. Workflow then returns the 'suspended_*' result and exits.

Approval Gates and Client-Executed Tools ​

Agents declaring requireApproval: true on tools, or using defineClientExecutedTool, suspend the workflow on first encounter. The submit that lands the batch's last answer in durable state starts a fresh mode: 'resume' workflow instance (__submit__, below) which drains the results and continues.

Client-tool submit continuation ​

submitToolResult answers with the continuation it caused (core SubmitContinuation; see the continuation model). On CF Workflows a submit self-wakes (CP-104, W1 MR2b):

  • A call of a stopped run (WA MR2 §7.1, "the recorded stop wins") answers 404 { status: 'unknown_tool_call', reason: 'run_stopped' }: the submit reads the stop of the call's run (readStop) before it stores anything (core refuseSubmitIfStopped). A recorded stop is settled through the stop path (core stopRun with the record's own kind and reason: a run nothing executes ends interrupted, or failed / framework_cancelled, now; an instance still finishing its suspension tail is only signalled and ends the run at its post-suspend stop read). Nothing is stored and no __submit__ instance is created. A stop that already ended the run answers the same while the call is still unanswered in the log; any other unknown call answers the bare unknown_tool_call.
  • A partial batch answers awaiting_batch; nothing starts. Readiness is decided at the batch's last submittedAt (a sibling whose deadline lapsed by then counts as answered).
  • The batch's last answer (and every re-send of an answer that is stored but not yet drained) creates or adopts ONE deterministic instance and answers new_run { runId, startSequence? }. Its id is buildWorkflowInstanceId({ agentType, sessionId: <root>, kind: 'submit', key }), readable form agent__<type>__<root>__submit__<key>, where key is the first 12 hex characters of SHA-256 of <suspended run id>:<generation>, generation 1..3. A sub-agent-owned call continues the ROOT (its resume respawns the child). When the root's current run is already running (the continuation committed), the answer is new_run on that run and nothing is created.
  • The predecessor wait. The continuation waits in its workflow body, before its completion-support check and its run-start commit, until the suspended run's instance has closed: a step.do probe, then step.sleep, backing off 200 ms → 5 s, bounded by the executor option continuationPredecessorTimeoutMs (default 600_000, 10 min). So the run-start's in-step 30 s owner wait is never reached, and a turn never fails because the suspended instance was still finishing its onAgentSuspended hook.
  • At the bound the predecessor is terminated (its remaining onAgentSuspended work is cut off) and the suspended run is reconciled (a committed suspension stays suspended, so the answer stays stored); cfw.continuation.predecessor_terminated is logged. The terminate is tried at most 3 times (a 5 s sleep and one re-probe after each failure). If it cannot be stopped, the continuation ends without committing (cfw.continuation.predecessor_unstoppable, error level): the stored answer stays owed, and the next submit, resume or reader gets the next generation. Past 3 generations the submit answers a typed, non-retryable transport_error (cfw.submit.continuation_generations_exhausted), and the session is no longer owed: the next entry (a with_message, an abandon-and-send, a continue) drains the stored answers itself.
  • Errors. A create that fails is wrapped in a typed, non-retryable transport_error (the answer is stored; re-read the snapshot). An agent type the executor's agentRegistry does not resolve is a non-retryable framework_agent_not_found (logged at error, cfw.submit.continuation_agent_unknown). A workspace agent is refused framework_not_supported.
  • A stop on the suspended run wins over the owed continuation (WA MR2 §7.1 H1, §7.2). A stop is a run-scoped record; there is no interrupt flag (Stop as one signal).
    • The ensure (decideSubmitContinuation) reads the stop of the root's suspended run: with one recorded, it settles the run through the stop path and creates no instance; the submit answers accepted with continuation consumed (an answer stored before the stop; the stop's terminal discards it). A session whose run has a stop is never owed a continuation (owesSubmitContinuation).
    • A __submit__ instance (resumeTrigger: owed_continuation) commits only while the session is paused and the suspended run it continues (seenRunId) is still current and suspended_*. A stop whose terminal landed while it waited for its predecessor (interrupted, or failed with framework_cancelled) makes it the silent C3 loser: it writes nothing, notifies nobody and closes with { status: 'failed', errorDetail: { code: 'state_run_start_conflict' } }.
    • A stop recorded but not yet resolved when it commits refuses its run-start commit (expected_run_stopped): the stopped run is settled by its stop and the continuation does not proceed (the same lost claim, cause: 'expected_run_stopped'). Either way the model is never called.

resume() on a session owed a continuation:

  • A recorded stop decides first (WA MR2, Rulings 41 / 57, §7.1 H1). A continue or with_message resume over a live run that has a stop record is decided by the stop BEFORE the owed-continuation adoption and before the partial-batch refusal (R44). The run is settled through the stop path (core stopRun with the record's own kind and reason): an instance still executing it is only signalled and ends the run at its own stop read; a terminal is never committed under a live owner. A continue then returns a handle on the stopped run whose result() is its outcome (interrupted, or failed / framework_cancelled; following the signalled instance when it was still live): no __submit__ is created or adopted and the model is not called (Ruling 22). A with_message goes on as a new turn (its unanswered calls are paired not-executed before the message); after an abort the session is failed and the resume is refused state_not_resumable (retry() is the path). with_message + abandonPending is not decided in the request (it never waits there): its __resume__ waits for the suspended instance in its body, and its run start settles the stop and re-plans as a new turn; after an abort the re-plan finds the session failed and the caller gets the same AgentNotResumableError (state_not_resumable) as on runtime-js and the CF DO (a genuine CP-96 race still surfaces as RunStartRejectedError('entry_status_changed')). A stop recorded after this check is met by the continuation's own run start, and the continue still answers the stopped run's handle.

  • continue adopts the __submit__ instance (user ruling Q2): the submit and the explicit resume reach ONE instance, with no loser — before its run-start commit, and once its run is running behind a live or paused instance (the resume then returns a handle on that run at once, with no settle wait and no busy answer; a running continuation whose instance already closed without its K5 is superseded by the resume's own run-start commit instead). Its entry wait is never abandoned or terminated: past continuationPredecessorTimeoutMs + entryOutcomeFailSafeMs it throws RunStartOutcomeUnknownError with reason: 'continuation_awaiting_predecessor'.

  • with_message waits, then is the next turn (user ruling (b)). On CF Workflows, submitting the batch's last answer STARTS the continuation (the model runs on the answers). A later resume({ mode: 'with_message' }) waits for that continuation to settle and then becomes the NEXT turn: two model calls, two entries. To get ONE entry and ONE model call with the message, send with_message + abandonPending instead of submitting first (unanswered calls are answered client_tool_abandoned), or do not submit the answers first. A wait past continuationPredecessorTimeoutMs + entryOutcomeFailSafeMs throws RunStartOutcomeUnknownError (reason: 'continuation_awaiting_predecessor', or the retryable 'continuation_running' when the continuation's run is still running; the message was not written); nothing is abandoned or terminated, so read the session. A continuation whose instance closed without its terminal commit is settled as a reader; when 3 settles all fail, the wait ends early with the same retryable 'continuation_running' and ownerStopped: true. A continuation that suspends again leaves the message to the normal rules (a partial new batch refuses it, R44). A continuation that an abort ended (before or during the wait) is not "finished": the message is refused state_not_resumable (AgentNotResumableError, nothing written; retry() is the path, Ruling 63). A continuation that failed on its own still makes the message the next turn.

  • with_message + abandonPending (D11) answers every unanswered call client_tool_abandoned in its own drain, then commits the message: ONE entry and one model call, no __submit__. It skips the CP-88 settle and the batch gate; its entry waits for the suspended instance in its body. On a session already owed a continuation (every call answered) there is nothing to abandon: the call is the busy loser (AgentAlreadyRunningError, nothing written, no wait) after ensuring the owed continuation exists. A suspension that also awaits children is the busy refusal too (AgentAlreadyRunningError, state_already_running, nothing written; core planWithMessageEntry, S-B): its run is live and continues on its own, so the caller holds the message and sends it once the run ends.

  • A with_message resume on a session PAUSED on client-tool calls alone (core planWithMessageEntry) drains the results first, then records a deferred companion or finishWith call not executed (-record-deferred-calls), then commits the message as its own append commit (inputAfterDrain, -append-entry-input, idempotent on the message's entry-input marker). An 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() after abandon-and-send, or after such a resume, works the same as any retry: when the run failed before committing a step, no options.message is needed (core planRetry rule 4 re-runs from the entry's own input row); after committed steps it needs options.message.

  • A completed or failed session refuses continue / with_message with a typed, non-retryable AgentNotResumableError (state_not_resumable; use execute() or retry()).

Chat readers reconcile (reconcileSession). CloudflareAgentExecutor.reconcileSession(sessionId) (the optional MinimalExecutor.reconcileSession) settles the session's current run when its instance closed without its terminal commit (the same closed-owner reconciliation as result()), and when that found a committed suspension whose batch is fully submitted, it ensures the owed __submit__ continuation. handleChatStream and buildSnapshot (with BuildSnapshotDeps.executor) call it, so a chat-only host never strands a stored answer. A continuation that cannot be started never rejects the read (logged cfw.chat.continuation_failed); the settle's permanent errors and a corrupt row are rethrown. It answers changed: true only when its settle wrote something or it CREATED the continuation (logged cfw.chat.continuation_started); a suspension it found already complete, a continuation it adopted (another reader's or the submit's) and one already running are not changes.

A suspension does not pause the stream (RM-56)

The workflow never calls pauseStream(), so a suspension writes no run_paused and the stream stays active at the tool's tool_start, although the session reads paused. No internal suspension_marker is written either. A stream reader therefore stays open (observed: a reader drained for 10 s did not complete) as if the run were still working, where on JS and the Durable Object runtime it receives run_paused and closes. Detect the suspension from the workflow result's suspended_* status or the session's pendingClientToolCalls. Observed live in the Workflows test pool. See RM-56 in the conformance findings.

Persistent sub-agents are also fully supported on the Workflows path in v7 (parity with the DO runtime). Companion tools (spawnAgent, sendMessage, listChildren, getChildStatus, waitForResult, terminateChild) are auto-injected; child workflows are spawned as nested workflow instances and their 'suspended_awaiting_children' cascade propagates to the parent.

companion__waitForResult and its bound (O1). The workflow parks on the child's completion event for at most min(timeout, subAgentEventTimeout): the caller's timeout (milliseconds, a positive whole number) is one deadline, set by the companion step and carried to the park (CompanionBlockingDescriptor.waitDeadlineAt). When it passes, the record step reads the child's ref and answers the typed { name, status: 'timeout', currentStatus } as a successful tool result (core waitForResultTimeout). On CF Workflows an untimed wait answers { status: 'timeout', currentStatus } after subAgentEventTimeout, and the model should call waitForResult again. This bound is deliberate: an unbounded re-park would spend a Workflows step every 30 s or less against the instance's step limit, so a long-running child would exhaust that limit and fail the parent; a bounded answer the model can repeat is the safer failure. The pre-park interrupted check (three polls, about 2 s) also ends at the deadline. Every other outcome is the same typed companionWaitResultSchema() answer, also a successful tool result: { name, status: 'interrupted', message } (from the pre-park check or a child interrupted while the wait is parked), { name, status: 'completed', result }, and { name, status: 'failed' | 'terminated' } (no error text, as core and the DO answer it). Every park treats a step.waitForEvent rejection as "no event this slice" and waits again (the engine's WorkflowTimeoutError is never read as the child's outcome; a non-timeout rejection that repeats is logged once at warn). A blocking companion spawn or continuation that reaches its bound answers its timeout without marking the still-running child failed or delivered, so if the child completes later, the per-turn completion notifier delivers its outcome to the parent in a later turn. An ephemeral sub-agent park that reaches its bound stops the child's run (an abort, run-scoped) and records its true outcome: its own result if it finished first, failed with framework_subagent_wait_bound if the stop ended it, or, when the stop is not confirmed within about 8 s (5 reads 2 s apart, each settling a closed instance by its stop record), the parent's failure with the child's ref untouched. A child with NO run at the bound has its workflow instance terminated, then its run is read again: still none → failed with framework_subagent_wait_bound; a run-start that committed in the gap is settled through the closed-owner path and its true outcome recorded. A ref left running by an unconfirmed stop is reconciled later, once the child's run (or, with no run, its session) is completed / failed, by the CFW readers that read refs: a handle's state load (getState, getHandle, result()) and every workflow step's state load (core reconcileEndedEphemeralSubSessionRefs; those reads now write, one run read per running ephemeral ref). The snapshot read does not reconcile refs. Every park takes its deadline once, in a step, so a replay of the body never restarts its bound, and ends exactly when step-recorded time reaches it: an early slice end never costs the rest of the bound, and the park listens again at once (never deaf). Only after CFW_WAIT_INSTANT_REJECTIONS_BEFORE_FLOOR (5) fast rejections (an iteration, its consult included, shorter than 1 s by step-recorded time; a dropped event, a stale wake-up or a failed stop read counts as a rejection; nothing resets the count within a park) does it wait a floor before each further fast listen: 1 s (CFW_WAIT_FLOOR_MS), doubling after every 5 more, up to 30 s (CFW_WAIT_FLOOR_MAX_MS), never past the deadline (a floor that would reach it is skipped once, for a final listen). After every floor, after every slice that ends without an answer (companion__waitForResult included), after every dropped event (every park), and before it answers a timeout, the park reads the child's persisted state: a child whose run ended with its own terminal commit is answered with that outcome, whether or not its completion event arrives (Miniflare buffers an event sent during the floor; production's buffering is unconfirmed and not relied on), so a longer floor only delays the answer. Every park accepts only a completion event naming the consult it waits on (one naming none is dropped, with one warning). companion__waitForResult waits on the consult this workflow instance started last for the child (spawn, continue, resume or respawn; the newest wins), drops any other event (then reads the store), and answers a terminal ref only when the child's current run serves that consult, so an earlier run's outcome, buffered event or terminal ref, is never answered as a later run's (CP-126). A park whose every listen is rejected at once costs at most 721 steps (291 step.do) per hour of bound; Miniflare's step limit (1e4, step.do only) and the production maximum wrangler states (25,000) are per instance, shared with the whole turn (see the reference's "Event-wait bounds"). A child whose terminate threw and whose instance then closed with its session still active and no run keeps its ref running (nothing marks it ended). Two known limits: the deadline is set inside the companion step, so a retry of that step (a thrown store read) re-derives it from the retry's clock and the caller's bound grows by the failed attempts (review M-1, accepted); and a companion that SUSPENDS (a client tool or its own children) sends a suspended completion event, which the record step maps to failed (FU-CFW-COMPANION-WAIT-SUSPENDED-CHILD-AS-FAILED, open), and a child whose instance closed without notifying AND without committing its outcome answers timeout on every repeat wait (FU-CFW-WAITFORRESULT-CLOSED-CHILD-TIMEOUT, narrowed: one that committed its outcome is now answered at the deadline).

sendMessage to a running child (CP-41)

Since WA MR2 the delivery is not a stop: core appends the message to the child's live run and records nothing else, and the child's workflow loads the history at its next step. The CP-41 record stays open until its cells are re-run on the Workflows lane. See CP-41 in the conformance findings.

AgentWorkflowResult Status Enum ​

typescript
interface AgentWorkflowResult {
  sessionId: string;
  status:
    | 'completed'
    | 'failed'
    | 'aborted'
    | 'interrupted'
    | 'suspended_client_tool' // v7
    | 'suspended_awaiting_children' // v7
    | 'suspended_step_partial'; // v7
  output?: unknown;
  error?: string;
  errorDetail?: { message: string };
  suspended?: AgentWorkflowResultSuspendedField;
}

v7 Chat Handler Routes ​

When the Workflows runtime is wired through @helix-agents/agent-server, the host server exposes five new chat-handler routes that drive the suspend/resume flow:

RouteMethodPurpose
/chatPOSTUnified entry point: dispatch fresh / continue / resume / attach / completed retry.
/chat/{sessionId}/streamGETRe-attach to in-flight stream; reads resume position from headers.
/chat/{sessionId}/submit-tool-resultPOSTDurable submit for client-executed and approval-gated tools.
/chat/{sessionId}/interruptPOSTDurable interrupt; records the stop, answers 202 / 200 / 409 / 404 at once.
/chat/{sessionId}/abortPOSTHard abort; writes terminal status.

See the @helix-agents/agent-server docs and the v6 → v7 migration guide for protocol details.

History, Checkpoints and Branching ​

  • History. executeAgentStep loads the complete history with core loadLLMHistory. A short read fails the run with errorDetail.code === 'state_history_incomplete' (retryable: false) and no model call. A store throw while loading is reported with the same code but retryable: true; the LLM step.do retry policy (llmStepRetry, 3 retries by default) re-runs the step, so a transient blip does not fail the run. step.do only preserves an error's name, message and stack, so the runtime carries the classified ErrorDetail across the step boundary inside the error message and decodes it in the workflow's outer catch; the marker never reaches user-visible error text. Failure-reporting steps (failAgentStream, endAgentStream, interrupt handling) read history leniently so they can still report such a failure.
  • Terminal checkpoints. Every terminal exit writes the K5 state commit (user ruling D1): completion (text-only reply, structured output, finishWith), a framework-forced tail, failure and stop. retry() never targets it: core planRetry restores the failed run's last step commit, or re-runs a first-step failure's trigger from its run-start commit; after committed steps a retry needs options.message.
  • resume({ mode: 'from_checkpoint' }) rewinds to the TARGET checkpoint (RM-31; a foreign checkpoint is rejected, CP-38). The checkpoint is resolved before any change; the rewind is part of the resume instance's run-start commit (truncateTo), so it is atomic with the new run and a losing caller writes nothing; discarded pending client-tool calls get an aborted tool_end. A completed or failed session is rewound too (C7).
  • resume() on a live run. continue / with_message reject an active session with no pending client-tool calls with AgentAlreadyRunningError before any write (CP-83). Concurrent resumes race only at their instances' run-start commits (a CAS on the current run): exactly one wins, and a loser writes nothing. A from_checkpoint also runs assertRewindable while the owner instance is not closed.
  • Branch (branch.checkpointId / branch.messageIndex) copies exactly the checkpoint's messages and state, or the first N messages (CP-33), when the source session is in the same D1 database. A foreign or unknown branch.checkpointId makes execute() throw (state_checkpoint_foreign / state_checkpoint_not_found) before any workflow instance or session is created.
  • retry({ checkpointId }) rejects an unknown or foreign checkpoint before any write. Without checkpointId, planRetry picks the target; after a first-step failure a retry without options.message re-runs the run's trigger (CP-58).

Workflow Steps ​

Cloudflare Workflows use steps for durability:

typescript
// Each step is durable - if worker restarts, execution continues
state = await step.do('step-1', async () => {
  // LLM call
  return await callLLM(messages, tools);
});

state = await step.do('step-2', async () => {
  // Tool execution
  return await executeTools(toolCalls);
});

Key points:

  • Steps are atomic and retried on failure
  • State between steps is persisted
  • Worker can restart between steps without data loss

Abort Handling ​

handle.abort(reason) is a hard stop of the handle's own run. It terminates the Workflow instance that owns the run, then records the run as failed with a typed, non-retryable cancellation, and fails the stream:

typescript
await handle.abort('User cancelled');

const result = await handle.result();
// result.status === 'failed'
// result.errorDetail: { message: 'User cancelled', code: 'framework_cancelled',
//   category: 'framework', retryable: false }
  • The abort is the terminal state commit: the session failed with the cancellation, then the run, then the stream. handle.result() and getHandle(...).result() report the session's persisted errorDetail.
  • If that commit cannot be written (a transient store error), nothing is written, the run stays live, and abort() rejects with a retryable transport_error (the store error as cause). Call abort() again.
  • A run whose terminal commit already landed keeps its committed outcome; the abort changes nothing.
  • A handle stops only its own run, never the session's current run. When the handle's run has ended, was superseded or has not committed, abort() writes nothing (it is logged as cfw.abort.not_stoppable). A newer run is never touched, whether it is executing or paused on a client call. Only the handle's own instance is terminated, best-effort, while it is still open. interrupt() follows the same rule.
  • With no reason, the abort records core's DEFAULT_ABORT_REASON ('Aborted by user'), and an interrupt records DEFAULT_INTERRUPT_REASON.
  • When the workflow body itself observes the abort between steps, it discards the step it was running and ends the run the same way: failed with framework_cancelled.

Use interrupt() instead when the session should be resumable.

Local dev on Miniflare 4

Miniflare 4 (older @cloudflare/vitest-pool-workers) does not implement WorkflowHandle.terminate(). There abort() still records the failure, but it cannot stop the instance, so result() waits until the instance ends on its own. The real Workflows engine and Miniflare 5 terminate it.

Instances closed outside the product ​

A Workflows instance can end without the run writing its terminal commit: someone terminates it from the Cloudflare dashboard, wrangler or the binding's terminate(), the engine marks it errored, or the binding no longer knows it. The run may have committed its terminal state, committed a suspension, or committed nothing past its entry before that, so the next executor reader reconciles it from durable state. The readers are each handle's result(), getHandle(), canResume() and abort(). They all go through core's closed-owner reconciliation (see planClosedOwnerReconciliation / reconcileClosedOwnerRun):

  1. Read the run, the session, its latest checkpoint and the run's own stream status, and plan. A stream status that cannot be read (a stream Durable Object answering non-2xx) is logged (cfw.settle.stream_read_failed) and planned as absent: every run-level plan still applies, and no stream repair is planned. A plan that writes nothing (the run already ended and converged, was superseded, or is not the session's current run) stops here, with no call to the Workflows binding.
  2. Probe the owner instance. Only a provably closed instance is reconciled. A live, operator-paused or unknown instance reconciles nothing, and neither does a probe that fails (logged as cfw.settle.owner_probe_failed): the next reader checks again.
  3. Once the instance is closed it can write nothing more, so read and plan again (a commit that landed just before the close is never overruled by a stale plan) and execute that plan, fenced on the run.
Durable state when the instance is closedPlanWhat the reader writesWhat result() reports
the run is live and its own terminal commit (K5) landedfinish_committedthe run's committed status; its own active or paused stream ended or failedthe committed outcome
the run is live and its suspension is committed (session paused, awaiting client calls or children)complete_suspensionthe run's suspended_* status when it still reads running; its own active stream pausedsuspended_client_tool / suspended_awaiting_children / suspended_step_partial with suspended
the run is live and nothing is committed past its entryfail_uncommittedthe terminal commit (session failed), the run failed, the stream failed, with how the instance closed (below)failed with that errorDetail
the run ended (completed / failed / interrupted), its own stream still active or pausedrepair_streamthe stream ended or failed from the run's committed outcomethe run's own record
anything elsenonenothingthe run's own record

A run settled failed carries a detail that says how the instance closed:

InstanceerrorDetail
terminated{ message, code: 'framework_cancelled', category: 'framework', retryable: false }
erroredthe engine's error message, no code (or a message naming the instance when the engine gave none)
complete without a terminal commit, gone{ message } naming the instance and what was observed; no code
complete, its output its own failurethat output's validated errorDetail (the workflow's error path returned it after its cleanup failed)
  • A committed suspension is reported, never failed. The run is waiting on its client or its children, so result() answers { status: 'suspended_*', runId, suspended }, where suspended comes from the session (toolCallIds: every pending client call, answered or not; children; stepId, whenever it is set), and canResume() is true. Submit the results and resume() as usual. The status comes from the run when it already reads suspended_*, else from the session's suspension maps.
  • A reader whose probe failed still reports its own run's committed suspension (the run is the session's current run, reads suspended_* and the session records the suspension) without writing anything.
  • abort() executes only finish_committed. A run whose terminal commit landed keeps its committed outcome. Any other run, a suspended one included, is failed with the caller's cancellation (framework_cancelled carrying the caller's reason), never with the closed-owner detail. A store call of that finish that fails rejects the abort with its own error, unless the run reads back as no longer live (it was ended anyway: the abort resolves, logged cfw.abort.settle_failed_run_ended); a read-back that fails too keeps the original error (logged cfw.abort.settle_failed).
  • The stream never hides the outcome. Once the run-level write is done, a stream read, claim or write that fails is logged (reconcile.stream_unavailable) and the reader still reports the run's outcome (a terminal commit completed is completed); the next reader repairs the stream.
  • A stream left active or paused behind an ended run is ended or failed by the next reader (repair_stream), not only by the session's next entry.
  • The reconnect handle (getHandle() after the instance closed) reconciles only its own run, and reports a suspension, a completion, an interrupt or a failure with that run's id. A handle built before the session had any run settles nothing: a later run's outcome is never its to report.
  • "Gone" means the engine's instance-level not-found (instance.not_found). A workflow-level not-found (workflow.not_found: a binding mismatch, a workflow renamed mid-deploy) is an unreadable instance.
  • A settle whose own terminal commit fails writes nothing; the run stays live and the next reader settles it again. A reader whose store read fails logs it and answers from what is persisted. A permanent error (a non-retryable one, such as an invariant broken in the plan, or a corrupt stored row: InvalidStoredStateError, CorruptMessageRowError, UnknownCommitKindError, also when a D1 D1StateError wraps one) is logged at error level and rethrown: the reader rejects typed. A bare D1StateError is a failed D1 call and is transient.
  • No lifecycle hook fires for a reconciled run (the instance that ran them is gone).
  • A child writing into its parent's stream never ends, fails or pauses the parent's stream.
  • The workflow body uses the same planner on its own error path (R27): when an error ends the workflow after the run's terminal commit landed, it executes only finish_committed and repair_stream and returns the committed outcome instead of a failure, whatever its stream does (an unreadable stream is planned as absent).
  • A host that only serves the chat route reconciles too: handleChatStream and buildSnapshot call the executor's reconcileSession (W1 MR2b), which runs the same settle and starts a submit continuation a stored answer is still owed (see Client-tool submit continuation).

Interrupt Handling ​

Unlike abort (which is a hard stop), interrupt is a soft stop that saves state for later resumption:

typescript
// Interrupt the agent (soft stop). Resolves once the stop is recorded.
await handle.interrupt('user_requested');

// The run ends at its next boundary
const result = await handle.result();
console.log(result.status); // 'interrupted'

// Later, resume execution
const { canResume } = await handle.canResume();
if (canResume) {
  const newHandle = await handle.resume();
  const result = await newHandle.result();
}

How It Works ​

Every stop goes through core stopRun (WA MR2, see Stop as one signal):

  1. The stop record - recorded on the run (requestStop), run-scoped and never cleared
  2. A wake-up event - sent to the run's open instance (instance.sendEvent())

The workflow reads the run's stop record in a step.do:

  • At each step boundary - before starting a new LLM call
  • During sub-agent waits - so a parent stops while children run
  • After a suspension commit - so a stop that landed meanwhile ends the paused run

A paused run (its instance already returned) or a run whose instance closed is ended by the stop call itself. abort() also terminates the instance and settles the run (failed, framework_cancelled) before it resolves. A reader that finds a live run with a stop record and a closed instance settles it from the record (settle_stopped).

Sub-Agent Interrupt Propagation ​

When an agent has running sub-agents, the stop propagates to them:

User calls handle.interrupt()
    │
    ▼
Stop recorded on the parent's run; the parent is woken by an event
    │
    ├──► Child 1: stop recorded on its run
    ├──► Child 2: stop recorded on its run
    └──► Child 3: stop recorded on its run
    │
    ▼
Each child stops at next safe point
    │
    ▼
Parent returns { status: 'interrupted' }

Each child's stop targets the run id derived from its instance id, so a child that is still committing its run honours it at its first boundary; a child whose session does not exist yet is terminated instead.

A companion terminateChild stops the child's current live run by id. It reports terminated: true only once that stop is recorded. A failure after the record, such as the owner probe or the stop's terminal, is logged (cfw.companion.stop_child_failed_after_recorded) and the terminate still counts, because the record ends the child. A stop that was not recorded is a tool failure, and the parent's ref is untouched: the stop is recorded before the ref is written terminated (CP-161 fix 4). This covers a failed record write, and an unstarted child instance whose terminate failed while it is still open. The stop names the consult the ref was read at: a child run serving another consult (a respawn's) is not stopped.

See Interrupt and Resume for complete documentation including resume modes and error handling.

Stream Resumption ​

The Workflows runtime supports stream resumption for handling client disconnections during agent execution.

Setting Up Stream Resumption ​

The v7 server-side pattern uses handleChatStream from @helix-agents/ai-sdk (typically wired via the chatHandler field on createAgentServer config when using the agent-server package). The handler reads resume position from Last-Event-ID / X-Resume-From-Sequence / X-Existing-Message-Id headers and serves the SSE response.

typescript
import { handleChatStream } from '@helix-agents/ai-sdk';
import { D1StateStore } from '@helix-agents/store-cloudflare';

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

    if (url.pathname.startsWith('/api/chat/') && url.pathname.endsWith('/stream')) {
      return handleChatStream({
        request,
        executor, // CloudflareAgentExecutor
        stateStore: new D1StateStore({ database: env.DB }),
        streamManager: new DOStreamManager(env.STREAM_MANAGER),
        registry,
      });
    }

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

Frontend Integration ​

The HelixChatTransport class has been replaced with the AI SDK v6 DefaultChatTransport plus prepareHelixChatRequest / prepareHelixReconnectRequest callbacks:

typescript
import { DefaultChatTransport } from 'ai';
import { prepareHelixChatRequest, prepareHelixReconnectRequest } from '@helix-agents/ai-sdk';
import { useChat } from '@ai-sdk/react';

function Chat({ sessionId, snapshot }) {
  const { messages } = useChat({
    id: sessionId,
    initialMessages: snapshot?.messages ?? [],
    resume: snapshot?.status === 'active',
    transport: new DefaultChatTransport({
      api: `/api/chat/${sessionId}`,
      prepareSendMessagesRequest: prepareHelixChatRequest,
      prepareReconnectToStreamRequest: prepareHelixReconnectRequest,
    }),
  });
}

Limitations ​

Subrequest Limit Impact

Stream resumption in the Workflows runtime is subject to the 1000 subrequest limit. Each stream chunk write to the Durable Object counts as a subrequest. For streaming-heavy agents that require frequent reconnections, consider the Durable Objects runtime which has unlimited streaming.

Key considerations:

  • Chunk storage - Stream chunks are stored in the Durable Object via DOStreamManager
  • Sequence tracking - Each chunk has a sequence number for resumption
  • TTL management - Configure appropriate chunk retention based on session duration

Deployment ​

Development ​

bash
wrangler dev

Production ​

bash
wrangler deploy

Secrets ​

bash
wrangler secret put OPENAI_API_KEY

Access in code:

typescript
const apiKey = env.OPENAI_API_KEY;

D1 State Store ​

The Workflows runtime uses D1 for state:

typescript
import { D1StateStore } from '@helix-agents/store-cloudflare';

const stateStore = new D1StateStore({ database: env.DB });

// Save state
await stateStore.saveState(sessionId, state);

// Load state
const state = await stateStore.loadState(sessionId);

// Append messages
await stateStore.appendMessages(sessionId, messages);

Limitations ​

Workspaces not supported ​

The Cloudflare Workflows runtime does NOT wire workspace providers. execute(), resume() and retry() reject an agent that declares a workspace before anything is written, with a HelixError (code framework_not_supported, retryable: false) of the form:

CloudFlare Workflows runtime does not support workspaces (yet — design-reserved).
Agent '<agentType>' declares a workspace.
Use one of: Cloudflare Durable Object runtime via @helix-agents/agent-server.

If your agent needs files, shell, code, or snapshots, run it on the Cloudflare DO runtime via @helix-agents/agent-server + createAgentServer (which DOES wire the workspace — see the workspaces overview for provider options). Workspace support on the Workflows runtime is design-reserved for a future plan.

Stop conditions: stopWhen and maxSteps ​

This runtime makes the same stop decision as JS and the Cloudflare DO: core resolveStopDecision, checked in the order terminal → stopWhen → maxSteps (see When maxSteps runs out).

  • Where it runs. The decision is computed inside the LLM step's step.do and recorded in its result (stopCause), so a replay reuses it and never calls stopWhen again. The workflow acts on it only after the step's tools ran: a suspension or a finishWith completion in that step wins.
  • stopWhen fires. An agent without outputSchema ends completed with completionReason: 'stop_when'; an outputSchema agent with no output enters forced completion.
  • A throwing stopWhen is treated like a throwing hook: the run fails once with the thrown error and the step is never retried. Unlike JS, it fails before that step's tools run, because the predicate runs inside the LLM step.
  • maxSteps cut-off. An agent without outputSchema ends failed with framework_max_steps_exhausted (Max steps (N) reached, retryable: false) and completionReason: 'max_steps'; retry() continues it from its last step with a fresh budget and no message.
  • An error stop reason at a stop cause (content_filter, refusal, max_tokens, …) fails with that reason first. A step without tool calls that fails this way is never a step commit: its rows land as an append commit and its staged state is discarded, whereas JS commits the step and then fails. So retry() after, for example, a content-filter stop on the cap step restores the step before it on this runtime, and the failed step on JS.

completionReason is persisted by the D1 store (migration V19).

Step results and state size (1 MiB, state_size_exceeded) ​

The Workflows engine persists every step.do result and caps one non-stream result at 1 MiB (2^20 bytes, "Maximum non-stream step result per step", Workflows limits). A model reply, a tool result and a sub-agent or companion outcome each ride a step result, so on this runtime 1 MiB (CFW_MAX_STEP_RESULT_BYTES, exported with its measure stepResultSize) is their effective limit, below the D1 store's 1.8 MB maxStateBytes. The engine stores a result with V8 structured serialization, so the runtime measures it with a conservative bound of that encoding before the step returns, never with JSON.stringify alone. A result the engine could not store at all (nested deeper than 256 levels, unreadable, or holding a value it cannot clone) is refused the same way.

  • A tool, sub-agent, remote sub-agent or companion result over the cap becomes a typed failed result: the call ran, and its result is replaced by a plain failed row saying it was too large to record ("Tool 'x' ran, but its result was too large to record: …"; helixToolFailed: true, no helixSynthetic marker). For a tool, afterTool sees success: false with a StateSizeExceededError. The run continues, and step.do never re-runs the tool or the child. The row comes from core's createResultTooLargeToolResult, the builder runtime-js and the CF DO use for a result row over the store's maxMessageBytes; only the measure clause differs ("the conservative measure passed N bytes, over the 1048576-byte step-result limit").
  • A model reply over the cap fails the run state_size_exceeded (non-retryable). The step is not retried and the model is not called again.
  • A hidden companion completion notice too large to record (MA-38) is replaced inside its own step (checkCompletedChildren) by one hidden notice saying so (Sub-agent 'y' completed, but its result was too large to record: …): first each notice against the D1 store's maxMessageBytes (the row measure; the notice embeds the output escaped, so the row is about twice it), then the whole step result against the cap, replacing the largest notice until it fits. The append lands, the child is marked delivered, and later turns end normally. It used to fail the turn (the check step over the cap, or the inject step's D1 refusal) on every later turn, the notice never delivered.
  • A row the store's limit refuses below the cap (MA-31: D1's maxStateBytes can be set under 1 MiB): a tool's row (a step's or the approve drain's), a sub-agent result record (recordSubSessionMessage, also fitted as a step result since it carries the row and the outcome; the remote error record and its last-resort row, the respawn record) and a respawn poll's or an accepted sub-agent-complete event's outcome are fitted with the same builder, so the record lands and the call is paired; a refusal used to be swallowed (an unpaired call) or retried on every resume.
  • A companion tool's result row over the D1 store's maxMessageBytes (a blocking spawn's, companion__waitForResult's, a continue's; the row embeds the outcome escaped, so it can be twice an outcome that fits the cap) is fitted before its tool_end: the row and the frame are "Companion tool 'x' ran, but its result was too large to record: …", and the run continues, as on the CF DO. D1 used to refuse the append, failing the run (MA-31). The child's ref keeps its true status. In a step the row is RETURNED for the step's one commit (RM-69), so it is fitted to both limits: the store's row limit and, cumulatively over everything its record step returns, the step-result cap (fitResultRowsInStep, largest row first). A row under D1's row limit can still be over the cap: D1's limit is above it, and two-byte text costs twice as much in V8's serialization as in JSON. A resumed run's deferred companion call still appends its row (appendCommitted with recordsCompletedWork).
  • Any other step result over the cap fails its step typed and non-retryable (the step guard measures every result), so the engine never retries the same bytes (FU-CFW-STEP-RESULT-CAP-FOLD-STEPS).

The D1 store's own limits apply as on the other runtimes (see State size and storage limits): a tool whose staged write is too large gets the typed state_write_rejected row with no step.do retry, and a step or terminal commit the store refuses fails the run typed and non-retryable. A tool that threw keeps its own failed row; its partial state write is staged as before, and discarded (no retry) when the store refuses it as too large (FU-CFW-FAILED-TOOL-PARTIAL-WRITES-PARITY).

Subrequest Limit ​

Important

Cloudflare Workers have a 1000 subrequest limit per invocation. Each stream chunk write to the Durable Object counts as a subrequest. For streaming-heavy agents, consider the Durable Objects runtime which bypasses this limit.

Workflow Duration ​

Cloudflare Workflows have time limits:

  • Individual steps: 15 minutes
  • Total workflow: varies by plan

For longer agents, implement checkpointing.

Cold Starts ​

Edge workers may have cold starts. Minimize initialization code.

D1 Limitations ​

  • Single region (replication to read replicas)
  • Write throughput limits
  • No full-text search

Durable Object Limits ​

  • Single instance per ID
  • Memory limits per instance
  • Geographic pinning

Best Practices ​

1. Efficient Steps ​

Group related operations in single steps:

typescript
// Good: One step for LLM + response processing
await step.do('llm-step', async () => {
  const response = await callLLM(...);
  const parsed = processResponse(response);
  await saveToD1(parsed);
  return parsed;
});

// Avoid: Separate steps for each operation (more overhead)

2. Handle Rate Limits ​

Cloudflare has request limits. Implement backoff:

typescript
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (error.message.includes('rate limit')) {
        await sleep(Math.pow(2, attempt) * 1000);
        continue;
      }
      throw error;
    }
  }
  throw new Error('Max retries exceeded');
}

3. Optimize D1 Queries ​

Use indexes and prepared statements:

typescript
// Good: Prepared statement with index
const state = await env.DB.prepare('SELECT * FROM __agents_states WHERE session_id = ?')
  .bind(sessionId)
  .first();

// Avoid: String interpolation, full scans

4. Stream Efficiently ​

Buffer small chunks before sending:

typescript
const buffer: StreamChunk[] = [];
const BATCH_SIZE = 10;

async function flushBuffer() {
  if (buffer.length > 0) {
    await streamManager.writeBatch(streamId, buffer);
    buffer.length = 0;
  }
}

for await (const chunk of stream) {
  buffer.push(chunk);
  if (buffer.length >= BATCH_SIZE) {
    await flushBuffer();
  }
}
await flushBuffer();

Next Steps ​

Released under the MIT License.