DBOS Runtime
The DBOS runtime (@helix-agents/runtime-dbos) executes agents as durable DBOS Transact workflows backed by Postgres. Workflow state — which steps have completed, their cached results, in-flight recv messages — lives in ordinary Postgres tables managed by DBOS, so you get crash recovery, automatic step retries, and durable interrupt/abort without standing up a separate workflow server.
When to Use
Good fit:
- You already run Postgres and don't want to operate separate Temporal infrastructure
- Production workloads requiring durable execution and crash recovery
- Long-running agents that must survive process restarts
- Chat assistants or stateful sessions that benefit from a hibernating, always-addressable workflow (persistent mode)
- Teams that want to leverage existing Postgres tooling for observability and querying
Not ideal for:
- Quick development iteration where the JS runtime's zero-infra story is simpler
- Agents that need workspaces — DBOS does not support them (see Known Limitations)
- Deployments with no Postgres and no appetite to add one
Prerequisites
You need a Postgres database. DBOS uses it as its system database (workflow status, step cache, in-flight messages) and @helix-agents/store-postgres shares the same instance for session state.
# Local development with Docker
docker run -d --name dbos-postgres \
-e POSTGRES_PASSWORD=dbos \
-p 5432:5432 \
postgres:16Token-by-token streaming requires Redis (RedisStreamManager) — Postgres write latency is too high for per-token chunk fan-out.
Installation
npm install @helix-agents/runtime-dbos @helix-agents/store-postgres @helix-agents/store-redis @helix-agents/llm-vercel @dbos-inc/dbos-sdkArchitecture
graph TB
subgraph App ["Your Application Process"]
Executor["<b>DBOSAgentExecutor</b><br/>execute / resume / retry / submitToolResult<br/>Binds step deps · registers workflows"]
Workflow["<b>DBOS Workflow Body</b> (deterministic)<br/>planStepProcessing() · buildMessagesForLLM()<br/>shouldStopExecution()"]
Steps["<b>@DBOS.step()s</b><br/>loadState · callLLM · executeTool<br/>checkpoint · client-tool · approval-gate"]
Executor --> Workflow --> Steps
end
Steps --> PG["Postgres<br/>(session state + DBOS system tables)"]
Steps --> LLM["LLM API"]
Steps --> Redis["RedisStreamManager<br/>(token streaming)"]The workflow body must be deterministic so DBOS can replay it on crash recovery. All I/O (LLM calls, Postgres reads/writes, tool execution, streaming) is wrapped in @DBOS.step() calls whose results are checkpointed in DBOS system tables. See Determinism Rules below and the architecture deep-dive.
Setup Guide
The DBOS runtime registers its workflow bodies via module-level decorator evaluation. The critical ordering rule is: register workflows and bind your LLM adapter before DBOS.launch(), because DBOS.launch() asynchronously recovers any pending workflows, and each recovered workflow body reads the live LanguageModel registry at entry.
import { DBOS } from '@dbos-inc/dbos-sdk';
import { defineAgent } from '@helix-agents/core';
import { DBOSAgentExecutor, registerDBOSAgentWorkflows } from '@helix-agents/runtime-dbos';
import { PostgresStateStore } from '@helix-agents/store-postgres';
import { RedisStreamManager } from '@helix-agents/store-redis';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { openai } from '@ai-sdk/openai';
const agent = defineAgent({
name: 'assistant',
systemPrompt: 'You are a helpful assistant.',
tools: [],
llmConfig: { model: openai('gpt-6-luna') },
maxSteps: 10,
});
// 1. Register workflow bodies (evaluates @DBOS.workflow() decorators). Idempotent.
registerDBOSAgentWorkflows();
// 2. Launch DBOS (begins async recovery of pending workflows).
await DBOS.launch();
// 3. Construct the executor. This binds every @DBOS.step's static deps
// (state store, stream manager, LLM adapter, hooks, etc.) and calls
// registerDBOSAgentWorkflows() again internally (idempotent).
const executor = new DBOSAgentExecutor({
stateStore: new PostgresStateStore({ connectionString: process.env.DATABASE_URL! }),
streamManager: new RedisStreamManager({ url: process.env.REDIS_URL! }),
llmAdapter: new VercelAIAdapter({ model: openai('gpt-6-luna') }),
// runStartFailSafeMs: 120_000, // run-start fail-safe (default)
});
// 4. Execute. sessionId is REQUIRED on the DBOS runtime.
const handle = await executor.execute(agent, { message: 'Hello' }, { sessionId: 'session-1' });
const result = await handle.result();Constructor config differs from { registry }
Unlike Temporal's AgentRegistry-based wiring, DBOSAgentExecutor takes { stateStore, streamManager, llmAdapter } directly. There is no registry argument — agents are registered lazily (per execute() / resume() / retry() call) via the internal registerAgent helper. The { registry } snippet in some older overview tables is not the actual constructor shape.
Registering agents for recovery
DBOSAgentExecutor.execute() / resume() / retry() register the agent internally before starting a workflow. You only need to call registerAgent explicitly when a process may host a recovered workflow on DBOS.launch() without first going through one of those calls — for example, a process whose only job is delivering executor.submitToolResult(...):
import { registerAgent } from '@helix-agents/runtime-dbos';
// Before DBOS.launch(), in a submit-only / recovery-only process:
registerAgent(agent);registerAgent is idempotent. If the same agent.name is later re-registered with a different live LanguageModel, it logs a warning and overwrites (last writer wins) to surface config drift.
bindCallLLMStep
bindCallLLMStep(llmAdapter, streamManager) wires the LLM adapter into the DBOS CallLLMStep static slot. Application code should not call this directly — the DBOSAgentExecutor constructor calls it for you from your config. It is exported only because e2e test infrastructure needs to swap the adapter between runs.
Execution Model
The DBOS runtime is architecturally closest to the Temporal runtime — both derive workflow IDs from sessionId, wrap I/O in durable step functions, and checkpoint intermediate results. The key architectural difference is the suspension primitive: where the v7 stateless-suspension runtimes (JS, Temporal, Cloudflare) use a durable-state suspensionContext and exit the workflow at every HITL boundary, DBOS uses its native DBOS.recv / DBOS.send primitives over Postgres-backed workflow replay. The workflow suspends in-place on await DBOS.recv(toolCallId) and wakes when a submission is delivered via DBOS.send. This is functionally equivalent for callers but architecturally separate from the unified suspensionContext model.
Standard vs Persistent Mode
DBOS supports two operational modes. The mode is fixed at the first execute() for a session and cannot change (mixing throws DBOSModeMismatchError).
| Standard mode (default) | Persistent mode | |
|---|---|---|
| Workflow lifetime | One short-lived workflow per execute() / resume() / retry(), runs to completion then exits | One long-lived DBOS.recv()-loop workflow per session, forever |
| Workflow ID | agent__{name}__{sessionId}__run__{runId}; resume() and retry() use …__resume__{runId} | agent__{name}__{sessionId}; a restart after a terminal turn …__p__{runId} |
| Multi-turn | Each turn starts a fresh workflow that loads accumulated state | Subsequent turns route into the existing workflow via DBOS.send(workflowId, msg, 'inbox') |
| Hibernation | N/A (workflow exits between turns) | Blocks on DBOS.recv — zero compute, instant wake via Postgres LISTEN/NOTIFY |
| Best for | Request-response, batch, long analyses | Chat assistants, agents holding resources/locks across turns |
Opt into persistent mode per-call ({ mode: 'persistent' }), per-agent (defaultMode: 'persistent'), or rely on the framework default ('standard'). Other runtimes ignore mode / defaultMode — they are no-ops there. See the persistent-mode deep-dive for the full lifecycle, idle-TTL semantics, and interrupt-vs-abort behavior.
Crash Recovery & Determinism
On process restart, DBOS replays the workflow body from the last completed step. For replay to be correct, the workflow body must produce the same sequence of step calls in the same order every time:
- Inside the workflow body: use only pure functions (
planStepProcessing,buildMessagesForLLM,shouldStopExecutionfrom@helix-agents/core),DBOS.now(),DBOS.randomUUID(), andawait @DBOS.step()calls. AvoidDate.now(),Math.random(),crypto.randomUUID(),fetch(), andprocess.envreads. - Inside a
@DBOS.step(): non-determinism is fine. LLM calls, Postgres/Redis I/O, tool execution,Date.now(), andcrypto.randomUUID()are all allowed because the step result is checkpointed and replayed from cache.
Two independent checkpoint layers coexist: the DBOS step cache (replay optimization, in DBOS system tables) and Helix checkpoints (user-facing time-travel / branching / retry, in store-postgres). They do not conflict.
Run start and ownership
Every entry (execute(), resume(), retry(), a recv-driven continuation, a child or companion turn) starts its run in the workflow's first step: the run-start commit, which appends the entry's input rows, applies any rewind (from_checkpoint, retry()), and creates the run record and its input checkpoint atomically. Nothing is written before it, so a crash between execute() and the workflow start leaves no orphan run, and an entry that loses writes nothing (no input row, no resumeCount, no stream change). Only the winner claims the stream, makes it live and writes run_started.
- Run ids and owner. The executor mints the run id (a UUID) and names the workflow after it, so naming it writes nothing. A run's
ownerTokenis its workflow id (one execution). An entry into a session whose current run's owner workflow is still pending is refused withAgentAlreadyRunningError; once the owner workflow is no longer live, the next entry supersedes the stale run. - The call waits for the outcome.
execute()/resume()/retry()return once their workflow's run-start has an outcome: a winner gets its handle, a loser the typedAgentAlreadyRunningError(RunStartConflictError) orRunStartRejectedError, synchronously. - Fail-safe. With no outcome within
runStartFailSafeMs(default 120 000 ms), the call gives the entry up atomically with the store (abandonRunStart), cancels the workflow and throws coreRunStartAbandonedError(transport_error, retryable; nothing was persisted), orRunStartOutcomeUnknownError(non-retryable: read the session before retrying) when the tombstone could not be written. Both are exported by@helix-agents/core; they replace the DBOS-onlyRunStartFailSafeError. - Hooks. A run that fails at its entry (the run-start commit, the stream claim or
run_started) fires no lifecycle hook.onAgentFailfires only for a run whoseonAgentStart/onAgentResumedfired, keyed on a durable start record (agent-start-fired-<runId>). resumeCountis bumped by the winningresume()/retry()entry, never by a loser.- Superseded runs. Every write carries the run id and is fenced to it; a tool, spawn or registration first checks that the run still owns the session. A run that lost its session stops with
AgentResult.status === 'superseded'(errorDetail.code === 'state_run_superseded') and writes nothing more; the error is excluded from step retries.onAgentComplete/onAgentFaildo not fire (one exception: a supersede that lands afteronAgentCompletefired and before the run's terminal commit leavesonAgentCompletethenonAgentSuperseded; the last terminal hook is authoritative);onAgentSupersededdoes. - Retries exhausted. A step that runs out of DBOS retries (
DBOSMaxStepRetriesError) — the LLM call, a tool, a commit, any step — settles the turn: the terminal commit withfailedand the last attempt's error, the runfailed, oneerrorchunk, the failed stream andonAgentFailonce. It used to leave the session and its streamactive.
Conversation history and checkpoints
- Complete history.
getMessagesSteploads the whole log with coreloadLLMHistory. It used to callgetMessages({ limit: 10_000 }), which returned the oldest 10,000 messages of a longer session (RM-11). A short read fails the run witherrorDetail.code === 'state_history_incomplete'(retryable: false) and no model call. A store throw while loading is retryable: the step re-attempts the load up to 3 times (1 s, then 2 s back-off), so a transient Redis / Postgres blip does not fail the run; only when every attempt throws does the run fail with the same code andretryable: true. - Step-atomic commits. A step's assistant message, its tool results, its state writes and its checkpoint land in ONE commit (
step), with the live stream head as itsstreamSequence; tool results are staged and committed in the step's promote, never appended outside it (RM-39, RM-40). Every checkpoint'smessageCountis the live row count (RM-12). - Terminal checkpoints. Every terminal exit — completion, failure and stop — writes the terminal commit (a
statecheckpoint with the status, the error and the terminal hooks' writes) after the terminal hooks and before the run status and the stream end. Earlier releases wrote none on a failure or stop (ruling R28); user ruling D1 changed that.retry()never targets it (see below). DBOS currently treats acontent_filter/refusal/max_tokens/error/unknownfinish of an agent withoutoutputSchemaas completed, not failed (DI-26). - No content-less rows.
retry()andresume()start the workflow with no input, and the body appends nothing for it (RM-28). Previously each retry/resume wrote a{ role: 'user', content: undefined }row. resume({ mode: 'from_checkpoint', checkpointId })resolves the checkpoint first (another session's id throwsstate_checkpoint_foreign, an unknown idstate_checkpoint_not_found, before any state change), then the new workflow's run-start commit performs the rewind atomically with the new run (startRun.truncateTo, CP-32). If the session is suspended on a client tool with its workflow still waiting inDBOS.recv, that workflow is cancelled first and a fresh one starts instead of attaching to it. Discarded pending calls get an abortedtool_end, and the winner writes onestream_resync(RM-60). Acompletedorfailedsession is rewound too, and a workflow starts from the checkpoint (C7; it used to throw an untyped "not in a resumable state" error).- Branch with
checkpointIdresolves it against the source session beforecloneSession(which does not check ownership). retry()honoursRetryOptions(CP-30). A retry is a run-start commit in a new…__resume__{runId}workflow. By default it targets the failed run's last committed step (passmessage, or the retry throwsvalidation_error), or, when the run failed on its first step, its run-start commit (the triggering message is re-run, thenmessageif given), through coreplanRetry, as on every runtime.checkpointIdis restored as given (truncateTo). It used to ignoreRetryOptionsand continue from the persisted history. See JS runtime: retry() for the full rules.- Cost: the loaded history is a DBOS step output, so it is written to the DBOS system database once per step. For very long sessions this grows as O(N²); see
FU-B2-01indocs/dev/follow-ups.md.
Drain in-flight workflows before deploying this release
This release changes the workflow input (runMeta became entry) and the step sequence (the run-start commit is the first step, steps were added and reordered, a step commits its tool results in its promote, terminal turns add the terminal commit), and workflow ids changed (…__resume__{runId}). No DBOS.patch marker guards the change, so a workflow that is pending when you deploy cannot replay on the new code (nor the reverse, so a rollback needs the same drain). A paused (suspended) session is a pending workflow blocked in DBOS.recv, so it counts as in-flight too. Let running and suspended workflows finish, or cancel them, before rolling out. Persistent-mode session workflows never finish on their own: cancel them (DBOS.cancelWorkflow); the next execute() on the session starts a fresh workflow. See the deploy checklist (and, for the previous release, the v0.50 migration guide).
Multi-Turn Conversations
In standard mode, multi-turn works exactly as on the JS / Temporal runtimes: call execute() again with the same sessionId after the previous run completes. Each call starts a new workflow that loads accumulated state.
await (
await executor.execute(agent, { message: 'My name is Alice.' }, { sessionId: 's1' })
).result();
// Continue the conversation — same sessionId, new run, accumulated state.
const h2 = await executor.execute(agent, { message: 'What is my name?' }, { sessionId: 's1' });A turn started while the previous run is still finishing (CP-85)
The new turn's run-start winner claims the session's stream (a compare-and-set), and the previous run's endStream / failStream / pauseStream are fenced to its own run id, so a finishing run no longer ends the new turn's stream. The finding record stays open until its conformance cell lands; see CP-85 in the conformance findings.
In persistent mode, subsequent execute() calls route the message into the already-running workflow rather than starting a new one.
Routed turns skip structured-output validation (MA-21)
A turn delivered through the persistent workflow's inbox (a routed follow-up execute(), or a companion sendMessage to a persistent child) runs without the agent's outputSchema. For an outputSchema agent, __finish__ arguments are then saved unvalidated, and a turn that ends without calling it completes with no output instead of entering forced completion. The first turn and a message drained at idle timeout are not affected. See MA-21 in the conformance findings.
An interrupt right after execute() (CP-79, CP-57)
The user's input is now part of the run-start commit, the workflow's first step, and execute() returns its handle only once that commit has an outcome, so an interrupt through the returned handle can no longer drop the turn's input (it used to be persisted several steps in, CP-79). interrupt() on that handle, and on a getHandle() handle, records the stop on the run (WA MR2), sends the workflow its control message, cancels it (DBOS.cancelWorkflow) and then, after 500 ms, settles the run by the stop (interrupted; a K5 that landed first stands). Whether result() now reports interrupted rather than failed (CP-57) is re-checked by its conformance cell. The finding records stay open until their cells land; see the interrupt guide and the conformance findings.
abort() with no live run. An abort over a session with no live run has nothing to stop. This covers an interrupted session, a paused one whose run already ended, and a finished one. The abort answers not_stoppable and writes nothing: the session keeps its status, so an interrupted session stays resumable (logged runtime-dbos: abort not_stoppable). Only the handle's own named workflow is cancelled, best-effort. The exception is a persistent loop idling between turns whose workflow is still open: the abort sends it a shutdown, cancels it and marks the session failed, so the next execute() starts a fresh loop. An abort with no reason records core's DEFAULT_ABORT_REASON ('Aborted by user'), and an interrupt records DEFAULT_INTERRUPT_REASON.
Human-in-the-Loop (HITL)
DBOS supports the full HITL surface on its DBOS-native suspension primitive.
Client-Executed Tools
A tool declared with execute: 'client' suspends the run while the client computes the result. The DBOS flow:
- The LLM emits a client-tool call. The workflow suspends in a fixed order: the run's
suspended_*status, then the pending calls' frames (tool_start/tool_approval_request), then the suspension commit (pendingClientToolCalls[toolCallId]with the step's rows and checkpoint), thenpauseStream(run_paused). The stream'spausedstatus means the suspension is committed.onAgentSuspendedfires after the commit. - The workflow body calls
await DBOS.recv(toolCallId, deadlineSec)and suspends. The process can be restarted here — therecvre-arms on replay. - The client posts to
POST /submit-tool-result; the server callsexecutor.submitToolResult(...), which routes aDBOS.send(workflowId, payload, toolCallId)to the owner workflow. - The
recvreturns; the workflow clears pending state, appends a tool-result message, and the loop continues.
Submission is exactly-once: concurrent or retried submits on the same toolCallId are serialized by an OCC-stamped submittedAt marker (first writer wins; losers return already_completed), and the DBOS.send idempotency key is scoped by workflow ID. A durable completedClientToolCalls marker makes already_completed survive executor restart and persistent-workflow exit. Deadline timeouts append a synthetic failed tool result ({"error": ...} with the humanized client_tool_timeout text, helixToolFailed: true), emit a tool_end chunk, and fire the timeout hooks — matching the cross-runtime contract.
A lapsed deadline is reported, not skipped. Once its suspension is complete (after pauseStream), the run publishes it as a DBOS event. When every suspended call's deadline lapses unanswered, the workflow continues the session inline as a new run with no caller action. The suspended run's handle still reports suspended_client_tool for it, even when that continuation finished before the handle's pending-client-tool poll looked, and a plain resume() afterwards returns the continuation's outcome (as a JS or Temporal resume after the deadline drains the expired calls). An answered round keeps the auto-continue contract: handle.result() reports the final outcome.
See the client-tools deep-dive for sub-agent ownership routing and the full race-condition analysis.
Approval-Gated Tools
A tool with requireApproval: true (or a function-form predicate) suspends on a tool_approval_request chunk and shares the same pendingClientToolCalls + DBOS.recv primitive as client tools. Resume by calling executor.submitToolResult({ kind: 'approval-response', toolCallId, approved, reason? }):
- Approve (
approved: true) runs the tool'sexecute()with the original input. - Deny (
approved: false) persists a synthetic failed tool result ({"error": "Tool call was not approved by the user"},metadata.helixToolFailed: true) and skipsexecute(). Other runtimes currently persist the deny differently (CP-66).
Known regression: approved tools do not stream live through handleChatStream (W1 MR2b)
Until FU-DBOS-CONTINUATION-ENTRY-ORDER lands, an approved tool runs under the suspended run, before the continuation run's run_started, and handleChatStream's submit attach goes live only on that run. So (a) the approved tool's live frames (state_patch, custom ctx.emit events, subagent_*) are not streamed: they reach the client only through the snapshot (the re-anchor, retry() or a reload); and (b) an approved tool that runs past the 30 s live-wait bound gets the non-retryable 500 run_not_live, and the client must call retry(). Client-tool continuations are unaffected. This is a regression MR2b introduces.
Checkpointed requireApproval predicate: function-form predicates are evaluated inside a @DBOS.step (ApprovalGateStep.evaluateApprovalGatePredicateStep), so the boolean result is checkpointed in workflow history. This makes the suspend-vs-run decision replay-deterministic even for non-pure predicates (a predicate that reads the clock or a DB row is safe). Fail-closed semantics live inside the step — an exception is treated as requireApproval = true — so the checkpointed value is always a boolean (parity with Temporal's activity-wrapped predicate evaluation; GL #111 Batch C).
Driving the loop after submitToolResult
submitToolResult delivers the answer to the parked workflow (DBOS.send → its DBOS.recv), which continues the turn itself: a complete batch answers continuation: { kind: 'new_run', runId } (W1 MR2b): the run the woken workflow continues the turn in, <suspended run>:continuation (or the current run once that continuation committed); caller_resumes only when no run is current, a partial one awaiting_batch. The caller attaches to the run's stream instead of calling resume(): handleChatStream does this for you (with useChat / useResumeClientTools); a direct caller reads the session stream or getHandle(). See Hooks for how the continuation run links to the suspended one.
Resume before submit is refused (R44). resume() in continue / with_message mode on a session whose client-tool batch is still partial (some awaited call has no submitted result) throws a retryable state_not_resumable and writes nothing. It used to resume while a call was still awaited, and the late result was lost. Instead:
- submit every result of the batch (
submitToolResult); once it is complete the run continues, or let a call's deadline lapse (a lapsed deadline counts as its timeout result), then resume if needed; - to abandon the batch, call
interrupt()(it ends the paused run at once,interrupted) or submit error results for the calls; - a
from_checkpointrewind that discards the unanswered calls is allowed.
A submit for a call of a stopped run (WA MR2 §7.1). The submit reads the stop of the call's run first and answers { status: 'unknown_tool_call', reason: 'run_stopped' }; nothing is stored or sent to the workflow. A stop that is recorded but not yet resolved is settled by this submit: the parked workflow is signalled and cancelled, and the run is settled by the stop after a fixed wait (DEFAULT_STATUS_UPDATE_DELAY_MS, 500 ms, for a step already in flight), so a submit over a stopped run whose owner workflow is still live (parked in recv) answers about 500 ms later than an ordinary submit (FU-DBOS-STOP-CANCEL-EXECUTING-OWNER). A stop that already ended the run answers at once.
See the Client-Executed Tools and Approval Gates guides for cross-runtime concepts.
Hooks
Per-call hooks fire on the DBOS runtime with full parity. agent.hooks (the AgentConfig field), plus ExecuteOptions.hooks and ExecuteOptions.hookManager, fire on every execute() / resume() / retry(). The executor computes the merged HookManager (constructor → agent.hooks → options.hooks order) before DBOS.startWorkflow and registers it in a process-local HookManagerRegistry indexed by DBOS.workflowID. The hook and tool steps resolve the manager per-invocation, falling back to the constructor-bound static on cross-worker recovery (a documented split-brain risk mirroring Temporal/CF replaceAgent semantics).
Canonical firing order matches all five runtimes (JS, Temporal, Cloudflare Workflows, Cloudflare DO, and DBOS — the first four use the v7 stateless-suspension model; DBOS uses its own DBOS-native DBOS.recv/DBOS.send primitives but converges on the same observable hook order):
beforeTool → execute → onStateChange → onMessage → afterToolbeforeTool + onStateChange fire inside the @DBOS.step tool body (so they are checkpointed by the step boundary); onMessage + afterTool are deferred to the workflow body so they land after the phase-1 Promise.all settles and after each tool-result message is appended.
onMessage, onStateChange, onAgentSuspended, and onAgentResumed all fire — including for both client-tool and approval-gate suspend/resume cycles.
onAgentResumed.previousRunId
onAgentResumed fires with { runId, previousRunId, sessionId, resumedFromCheckpointId }. DBOS has two resume branches:
- Branch 2 (fresh resume workflow):
runId !== previousRunId— a new run is allocated and a fresh workflow starts (same as JS / Temporal / CF). - Branch 1 (recv-driven continuation in the same workflow): the client-tool submit path detects a still-PENDING workflow blocked on
DBOS.recv, returns a handle wrapping the same workflow, and the body wakes there. No new workflow. A client-tool / approval round still continues as a new run: the woken workflow writes a run-start commit (acontinuationentry carrying the answered calls) andrunId !== previousRunId. When only children were awaited, the same run continues andrunId === previousRunId.
In both branches previousRunId is always populated with the suspended run's runId. Hook consumers should use previousRunId as the linkage signal for span stitching — do not rely on runId !== previousRunId to detect a resume, because that test fails when only children were awaited.
See the Hooks (Observability) guide for the full hook reference.
State
stateSchema.default() values are seeded into customState at session start so tools that append to a key (e.g. customState.items via an Immer /items/- push) see the default-initialized container rather than undefined. Seeding is best-effort and validated; dropped keys log a warning.
Parallel-tool in-step state visibility
Each DBOS tool runs as an independent durable step that loads state.customState fresh from Postgres at the top of its execution. A parallel sibling tool that calls ctx.getState() to read a key written by another sibling within the same LLM step sees the pre-sibling value, not the cumulative state. This is by design — DBOS steps must be idempotent for replay determinism.
Tools that need to read sibling writes within a single step should either be sequenced through a single parent tool that orchestrates the work, or run on the JS / Cloudflare DO runtimes (which apply in-memory state merging between parallel tool calls before persisting).
Remote Sub-Agent Handling
DBOS is a first-class consumer of remote sub-agents: a parent running on DBOS can delegate to a child agent hosted on a separate HTTP or Cloudflare service via createRemoteSubAgentTool. Remote calls run through a dedicated durable step (executeRemoteSubAgentCall), separate from both regular tool steps and local sub-agent handling.
The step provides:
- Deterministic session IDs —
{parentSessionId}-remote-{toolCallId}, so a replayed step re-addresses the same child instead of starting a new run. - Crash recovery — on step retry it checks
transport.getStatus()to avoid re-executing an already-completed child (Postgres-backed workflow replay). - Ref-cadence reconnection — seeds
lastSequencefrom the persistedSubSessionRefat step entry and persists it every N proxied chunks (and on every failure path), so a retry resumes the SSE stream mid-flight rather than replaying from zero. - Stream proxying — the remote child's chunks flow through the parent's stream, stamped with
remoteSourceprovenance. - SubSessionRef tracking — registers with
remotemetadata. - Interrupt propagation — interrupts abort the in-flight remote call, and
StreamDropErrortriggers step retry. - State & usage fold — on completion the child's terminal
customStateand usage rollup fold into the parent via the sharedprocessRemoteSubAgentCompletionpath (output is re-validated against the tool'soutputSchema).
Client-executed and approval-gated tools inside a remote child are not supported across the service boundary (a v1 limitation shared by all runtimes). See the Remote Agents guide for setup and the cross-service guide for the state/usage/ reconstruction contracts.
Persistent Sub-Agent Handling
DBOS supports persistent sub-agents: a parent that declares persistentAgents gets the same companion__* tools as every other runtime, with identical LLM-facing semantics (terminate-truth, exactly-once completion delivery, deterministic child ids).
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).
- Dispatch.
companion__listChildren/getChildStatus/terminateChildroute through the shared core dispatcher.spawnAgent,sendMessage, andwaitForResultare handled by DBOS-local logic so they can use durable primitives: a child runs as its own durable workflow (startPersistentWorkflow),sendMessageappends to the child's durable inbox viaDBOS.send, andwaitForResultpolls with durableDBOS.sleep(the wait survives a crash). - Delivery is truthful.
companion__sendMessagereportsdelivered: trueonly when the message reached the child (its run-start committed with the message as its input row, or the child's inbox loop took it). Otherwise the result isdelivered: falsewith a typedreason—child_busy(another run owns the child),child_exited(its loop exited before taking the message),child_not_running,child_start_unconfirmed(the child's run-start outcome did not arrive in time; the message may still be delivered) orchild_entry_failed— so the model can retry. A consult that reopens acompletedchild and loses or fails its start returns an error result instead of claiming a spawn. - Terminate is truthful.
companion__terminateChildrecords an abort stop on the child's current run and wakes its loop with ashutdown. It reportsterminated: trueonly once the stop is recorded. A failure after the record, such as the stop's terminal or the shutdown send, is logged 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 goes back to what it was. For an idle loop with no live run, theshutdownis the stop, so a failed send is a tool failure. - Completion delivery. A completion notifier (a
@DBOS.step) injects a finished non-blocking child's outcome into the parent's next turn, deduplicated by the durablecompletionDeliveredflag on theSubSessionRef. - Blocking spawn (caveat). Non-blocking spawn is fully supported. The blocking spawn path currently blocks until the workflow is idle and can mis-report a failed child as completed (tracked as
FU-DBOS-BLOCKING-SPAWN-SEMANTICS). Prefer non-blocking spawn +companion__waitForResulton DBOS until that is resolved. - No workspaces on persistent children (C8). A persistent child that declares a
workspacefails fast at spawn — workspaces are unsupported on DBOS (see below). UseinheritWorkspace-free children with no workspace, or run workspace-bearing children on the JS / Cloudflare DO runtimes.
Known Limitations
- Approved tools do not stream live through
handleChatStream(a W1 MR2b regression, until FU-DBOS-CONTINUATION-ENTRY-ORDER lands). (a) An approved tool's live frames (state_patch, customctx.emitevents,subagent_*) are not streamed; they reach the client only through the snapshot (the re-anchor,retry()or a reload). (b) An approved tool that runs past the 30 s live-wait bound gets the non-retryable500run_not_live, and the client must callretry(). Client-tool continuations are unaffected. See Approval-Gated Tools. - Workspaces are unsupported. Unlike Temporal and CF Workflows (which fail-fast at run-start), DBOS silently passes
workspaces: undefined.describeCapabilities()returns an emptyworkspaceProviderKindslist, soAgentServersurfaces aRUNTIME_NO_WORKSPACE_SUPPORT404 on the HTTP workspace route. Agents needing workspaces should run on the JS or Cloudflare DO runtimes. - The
history:input prefix is not honored.executor.execute(agent, { message, history: [...] }, { sessionId })silently drops thehistory:prefix on DBOS — the message log starts at the new user message. Tracked as the open follow-upFU-DBOS-HISTORY-PREFIX-NEVER-HONORED(low priority; parity gap with JS / CF / Temporal). - Doubly-nested HITL is unsupported. A top-level agent's client-executed or approval-gated tool suspends and resumes correctly. The doubly-nested case — an ephemeral child sub-agent itself calling an
execute: 'client'tool and expected to suspend the parent — is not supported; the parent returns'completed'rather than suspending. Hoist the client tool to the top-level agent, or use a persistent sub-agent instead. Tracked in GitLab #73 (documented-as-unsupported). - Submit-side
outputSchemavalidation is skipped. The DBOS agent registry storesSerializedAgentrecords, which strip Zod schemas, so submitted client-tool results are accepted without server-side schema validation. Client-side validators should be the primary source of correctness. - In-step parallel-sibling state visibility — see State above.
- Memory auto-injection/extraction is not supported. The
memory:config field is accepted at the type level but is never invoked on DBOS. Agents requiring memory recall or storage should run on the JS or Cloudflare runtimes.
Forced completion on the 5.5 generation still fails on DBOS (support planned)
DBOS does not resolve completion strategies yet. Every forced call uses the legacy request (a named tool choice on the one completion tool, a trimmed system prompt, reasoning disabled) whatever the adapter reports, so on Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1, which reject forced tool choice, each forced call fails with HTTP 400. A forced call's provider error is still a recoverable miss here, so the run ends framework_forced_completion_exhausted with the provider's code lost. There is also no early refusal of an unrecognised model and no prompt-prefix check. DBOS also drops providerOptions and maxOutputTokens from LLMConfig across its step boundary (only temperature survives), so thinking and effort settings do not reach the model, and it renders a systemPrompt function once, with empty state, when the agent is serialized. Unlike the other runtimes, a skills provider that throws does not fail the run on DBOS: the workflow body logs a warn (dbos.skills.catalog_unavailable) and runs the turn without the skills catalog or any preloaded skill body (before 0.50 a getSkill failure for one preloaded name skipped only that body), because the body resolves the catalog outside any step and failing there could strand a recovering run. The planned DBOS support moves resolution into a step and fails closed (FU-COMPLETION-DBOS-SKILLS-RECOVERY-STRAND). An outputSchema agent on those models that finishes by calling __finish__ or its finishWith tool itself is not affected. Until DBOS support lands, run outputSchema agents on those models on runtime-js, a Cloudflare runtime or Temporal. Ordered thinking parts are still captured and replayed verbatim (that is adapter-side).
stopWhen is never evaluated on DBOS (DI-29)
The workflow calls the stop check (shouldStopExecution) with maxSteps only, so an agent's stopWhen predicate never runs: after it would have fired, the agent keeps calling the LLM and running tools until the model stops on its own or maxSteps is reached. Observed live (a stopWhen: () => true agent made 3 LLM calls where JS made 1). JS, the Cloudflare DO and Cloudflare Workflows honour it. Use a finishWith tool or an outputSchema to end the loop. See the stopWhen guide and DI-29 in the conformance findings.
A maxSteps cut-off completes on DBOS (DI-25)
An agent without outputSchema that runs out of maxSteps mid-tool-loop ends completed with no output and no error code here. JS, the Cloudflare DO and Cloudflare Workflows fail it with framework_max_steps_exhausted and let retry() continue it; on DBOS retry() refuses the session because it is not failed. Wiring core resolveStopDecision and the run's completionReason into the workflow is FU-CFD1C-DI29-DI25-DBOS (CF-D1 MR 3). See When maxSteps runs out.
A blocked, refused or truncated final reply completes the run (DI-26)
For an agent without outputSchema, the workflow never classifies a text step's stop reason. A final step that stops with content_filter, refusal, max_tokens, error or unknown ends the run completed with no error, where JS and both Cloudflare runtimes fail it. Observed live for content_filter; the other reasons follow from source. The run's completionReason does not flag it: DBOS writes 'failed' there for every text answer, ordinary ones included (DI-27). Agents with an outputSchema are not affected, except on persistent-mode follow-up turns, which run without the schema (MA-21, above). See the error handling guide and DI-26 in the conformance findings.
Stream parity in this release (RM-55, RM-56, RM-57, RM-58)
From source, the DBOS stream gaps observed on the previous release are closed:
- The LLM step streams every chunk live as the model produces it —
text_delta(also beside tool calls),thinking, tool-argument streaming, sources, files,step_end— through one writer stamped with the run and the attempt, like runtime-js and Temporal (RM-55). A retried step re-streams under a newattemptId; readers drop the superseded attempt. - A suspension pauses the stream (
run_paused) after the suspension commit (RM-56). - An
interrupt()that lands while a step's LLM call is in flight discards the step: nothing of it is committed and its content is markedstep_discarded; a cancelled owner's settle writes the same marker (RM-57). - Every chunk carries the 1-based step number of the step commit it belongs to (RM-58).
The finding records stay open until their conformance cells land. See the stream protocol parity table and the conformance findings.
Prompt caching is supported.
LLMConfig.cachestrategies (anthropicCache,openaiCache,xaiCache, or a customCacheStrategy) are applied on DBOS at parity with the JS, Temporal, and Cloudflare runtimes. Strategies are not JSON-serializable, so the executor registers the live strategy in a process-local registry (alongside the live model) and the workflow body applies it before each LLM call; a strategy that throws is logged and the step continues un-annotated.
Best Practices
- Use
store-postgresfor state — it shares the same Postgres instance DBOS uses for its system database, keeping infrastructure to one service. - Use
RedisStreamManagerfor streaming — Postgres write latency is too high for per-token chunk fan-out. - Register before launch — call
registerDBOSAgentWorkflows()and construct the executor (or callregisterAgent) beforeDBOS.launch()so recovered workflows find their liveLanguageModel. - Keep workflow bodies deterministic — push all I/O into
@DBOS.step()calls. See Determinism. - Choose persistent mode deliberately — only when subsequent messages need to arrive into a running workflow context (in-memory state across turns); otherwise standard mode is simpler.
See Also
These deep-dive docs live alongside the package source:
- Architecture — why DBOS, the two execution modes, determinism boundary, runtime comparison
- Client-Executed Tools — DBOS-specific client-tool flow, ownership routing, durable
already_completed - Persistent Mode — recv-loop lifecycle, hibernation, idle TTL, interrupt vs abort
- Race Conditions — exhaustive race analysis with test pointers
- Determinism Rules — the full determinism contract and pitfall catalog
- Error Reference —
DBOSModeMismatchError,DBOSWorkflowNotFoundError,DBOSPersistentSessionTerminatedError - Migrating from Other Runtimes — constructor-swap checklist
- Versioning — DBOS workflow versioning policy
Doc-site cross-links:
@helix-agents/runtime-dbosAPI Reference- Runtime Overview — comparison tables across all runtimes
- Temporal Runtime — the closest architectural peer
- Storage: Postgres — the recommended state store