Durable waits
A workflow can sleep('3 days') at zero suspended cost. The wait is a row and a timer in
the World, not a process held open.
Every agent in Subspace runs on one durable engine: the Workflow DevKit (WDK) hosted in-process over a self-hosted Postgres World. This is the only engine. Because the World is the same Postgres that holds everything else, an agent’s checkpoint, its output nodes, and its approval card all commit in one transaction, so a paused run and its task card cannot split-brain. This chapter is the conceptual picture; for authoring and operating agents, start with the agents overview.
The engine drives registered workflows: any exported "use workflow" function is one
entry in the registry. The default entry, agent-loop, is the familiar tool-calling loop
(prompt, model call, tool calls, repeat). Other entries are arbitrary compiled topologies:
follow-up-nudge is a durable timer workflow, and sub-agents are child runs spawned with
agents.spawn.
Running over the Postgres World buys three things the hand-rolled loop could not offer cheaply:
Durable waits
A workflow can sleep('3 days') at zero suspended cost. The wait is a row and a timer in
the World, not a process held open.
Sub-agents
A workflow spawns child runs and the parent/child edges are recorded, so an orchestration is a real run tree. See orchestration.
Idempotency claims
Steps are at-least-once, and side effects claim an idempotency key so a replay never sends twice (below).
Workflows are not executed from their TypeScript source. They compile to a standalone bundle that the engine imports as a library.
pnpm build:workflows # rebuild the WDK agent bundleBoot ordering is strict and load-bearing: the World is set before any bundle is imported,
and all bundles are imported before world.start. Getting that order right is what
lets in-flight runs (including runs pinned to older or plugin-shipped workflow versions) resume
cleanly after a restart. Installed plugins can ship their own compiled
workflow bundles, and every version directory on disk is imported at boot so its in-flight runs
can still resume.
Workflow bodies stay deliberately thin. The real business logic (model calls, tool execution,
persistence, approval gating) lives in a shared runtime, and steps reach it through a
Symbol handshake (Symbol.for('subspace.agents.runtime')). Keeping logic in the runtime
rather than the workflow body matters for two reasons:
ops.run_steps is the shared journalEvery meaningful thing a run does, a model call, a tool call, an output, an approval, appends
a row to ops.run_steps. This one journal is the product surface: it powers the run tree UI
(virtual subnode rows under the invoking bullet), the per-step logs, and the transcript. Run
introspection, judges, and evals all read from it too. See
run introspection.
The run’s checkpoint (its message history) lives in ops.agent_runs. Because the checkpoint,
the run_steps row, the output nodes (ordinary kb commands), and, on pause, the approval row
and its task card all commit in one transaction, a run’s durable state is always
internally consistent.
When a run hits an outbound or irreversible tool, or when it asks the user a question, it does not busy-wait. It suspends on a hook and becomes a durable row:
Pause is a row
The run pauses, an approvals row (or a question node) opens, and a task-queue card
appears, all in one commit. The process is free to do other work or restart entirely.
A decision rides the outbox
When you approve, reject, or answer, the decision commits and emits a run-resumed or
question-answered outbox notice. The engine consumes
that notice and resumes the workflow exactly where it suspended.
Boot sweeps catch the gap
If the server crashes in the window between a decision and its delivery, a boot sweep re-delivers decided approvals on startup. A separate sweep fails any run left with no workflow id (the crash window inside run start), so a run is never silently stuck.
A step that fails is retried up to the engine’s attempt budget. If it still fails, the failure
propagates to the workflow’s catch, which calls markRunFailed, flipping the run’s product
status to failed. Nothing is left half-committed, because each step’s effects were themselves
transactional.
Steps are at-least-once: a retry or a replay can run the same step body twice. For pure
computation that is harmless, but a tool that sends mail or writes to a calendar must not fire
twice. Those side-effecting tools take an effect claim in ops.effect_claims, keyed
<tool>:<callId> (the durable tool-call id checkpointed with the assistant message). The first
execution claims the key; a replay finds the claim already taken and returns the recorded
result instead of re-running the effect. This closes the at-least-once window that a naive loop
would leave open. See tools and approvals.