ONE

One of Them Can Wait Ninety Days. The Other Dies With the Session.

Two things in our codebase are called a workflow. We went to build the wire between them and found it already there, pushed over SSE, with nothing to write. The reason they compose so cleanly turned out to be physics rather than design: one of them can park for ninety days and the other has no clock at all. Here is the split, what it costs to get backwards, and the bug hiding at the end of it.

workflows engine agents architecture

The build file had one line left unfinished, and it had been sitting there for days:

⟨design⟩ The wire for A is a poll, and it is small: read ready-tasks, take one, claim it, print its notes as the brief.

We went to build the poll. It was already there, and it was not a poll.

Two different things in this codebase answer to the word workflow. The first is a configured workflow: a graph you draw on a canvas, stored in the database, executed by a Durable Object. Eight step kinds, all locked. The second is a dynamic workflow: a JavaScript file that Claude Code runs to orchestrate a fan-out of subagents, with the loop and the branching held in code instead of in a context window.

They look like two implementations of one idea. Everyone who reads both assumes a compiler belongs between them.

They differ on one thing, and it decides everything else

Not the syntax. Not the step vocabulary. How long each one can wait.

A configured run is a database row plus an alarm. It parks for up to ninety days (MAX_PARK_MS, workflow-executor.ts:126) while a person approves something, a timer runs down, or a buyer pays. It survives worker eviction, deploys, and reboots. Nobody has to be watching.

A dynamic workflow is an async function inside a session. It has no clock at all: Date.now() and new Date() throw, on purpose, because resume replays the script and a script that reads the wall clock cannot be replayed.

So three of the eight locked step kinds, human, delay and sell, cannot be expressed in a dynamic workflow. Not “shouldn’t.” There is no mechanism. A script that waits for a person is a script holding a session open for three days and failing when the laptop sleeps.

Which settles the direction, and settles it before anyone gets to have an opinion:

The configured workflow is the durable unit. The dynamic workflow is the composer that does the work between two parks.

   a task lands
        │
        ▼
   ┌──────────────────────────────────────────────────────┐
   │ CONFIGURED WORKFLOW — a row in D1, a Durable Object  │
   │ parks up to 90 days · survives deploys and reboots   │
   └──────────────────────────────────────────────────────┘
        │
        │  trigger              the world starts it
        │  tool                 0 model calls · direct dispatch
        │  condition            0 model calls · pure function
        │  human · delay · sell ── park ──▶ cursor written, row says 'picked'
        │                                   nobody has to be watching
        │
        │  tool { receiver: "space:post" }
        ▼
   channels   POST /signal/space:engineering
        │
        ▼
   SSE        GET /stream/:group        push · no poll · no interval to tune
        │
        ▼
   ┌──────────────────────────────────────────────────────┐
   │ DYNAMIC WORKFLOW — a .js file in .claude/workflows/  │
   │ no clock · cannot park · dies with the session       │
   └──────────────────────────────────────────────────────┘
        │
        │  phase Recon     agent()             read the surface
        │  phase Decide    agent()             pin the shape
        │  phase Edit      parallel(agent ×N)  file-disjoint, worktree-isolated
        │  phase Verify    agent()             the gates, then the rubric
        │
        │  one event per wave
        ▼
   workflow_run  +  workflow_run_event         the same two tables
        │
        ▼
   the runs canvas                             every wave, live

Everything above the SSE line survives a reboot. Everything below it dies with the session, and is expected to. The only traffic across the boundary is a signal going down and an event coming back, which is why there is no adapter in the middle.

The wire was already a push

Once the split is right, the connection stops being a thing to design. Every configured step compiles to one primitive: signal(receiver, data), through a single function (stepBinding, workflow-executor.ts:407). A step whose config names a receiver dispatches straight to it.

One of those receivers, space:post, posts into a group. Channels already serves that group as server-sent events (channels/src/index.ts:362), and the client that holds the stream open already exists, with a 120-second watchdog and message dedup.

So the step is this, and nothing else:

{"receiver": "space:post", "args": {"space": "engineering", "content": "…the brief…"}}

Which reaches a listening Claude Code session pushed, sub-second, with no interval to tune and no new receiver to write. The whole “wire” was three facts we hadn’t put next to each other.

There is one ceiling, and it fails quietly if you ignore it. Every dispatch runs through guardDispatch (workflow-receivers.ts:279), so a step may not reach a receiver above the rung its author held when they saved the workflow. Denied does not raise. It dissolves. Author the briefing workflow from a low-rung account and you get a wire that does nothing, forever, with a green run behind it.

The half that reports back was already running

The other direction needed no code either, which we found out by looking rather than by building.

Every phase boundary in a build cycle already fires an event at the substrate. That event gets projected into the same two tables the runs canvas reads: a run row keyed to the cycle, then one event row per wave, closed as done on a learn and failed on a halt (do-event.ts:282, :356, :404). Order-free, so a wave arriving before the opening event still creates the run.

Which means every wave of an agent’s work has been rendering on the runs canvas this whole time. We had been describing it as a thing to build.

What it costs to get this backwards

The tempting move, once you see the mapping, is to compile the configured graph down into a script. Everything in one language, one runtime, one file.

Look at what each step costs on each side. A tool step is a direct dispatch in the executor: zero model calls. In a dynamic script the only primitive is agent(), so the same step costs one full agent. A condition step is a pure function on one side and a model call on the other.

A ten-step procedure with seven tool steps costs three model calls in the executor and ten in a script.

That is determinism traded for probability, paid per run, forever, to make a diagram tidier. The first rule of this system is that the language model is the only probabilistic step in it. Compiling the graph down inverts that rule and charges you for the privilege.

The factory spawns dynamic workflows. It does not compile its procedures into them.

The bug waiting at the end

Then we wrote the composer’s poll loop, and it hung.

A configured run that parks reports paused back to whatever called it. So the obvious loop waits for paused. That loop never finishes, because the executor writes picked to the database row while returning paused to its caller (workflow-executor.ts:1163). The word paused never appears on a run row. A composer waiting for it waits forever on a run that is parked correctly, on a human, doing exactly what it should.

The engine already knew. Its own park queries never look at status alone; they ask for status IN ('open','picked') AND cursor IS NOT NULL. The cursor is the token. Status is a decoy.

But the read API returned neither. workflow:runs selected the id, the status, two timestamps, a duration and a test flag, and no cursor. So nothing outside the executor could tell waiting on a person from still working — which is the one distinction anything driving these runs has to make.

One column, and a boolean on the wire. Two test cells pin it: a run holding a cursor comes back parked while its status reads picked, and a human step writes picked to the row while returning paused, so nobody re-fixes it by polling for a word that is never written.

Where this stands

The composition is seamless because there was no seam to build. Two runtimes, one primitive between them, and the reporting half already live.

The ignition is not. Nothing can wake a session that isn’t listening. Nothing is lost while it sleeps, since the backlog replays on reconnect, but “immediate” holds only while something is attached. The cloud door that would close it wants an OAuth user credential rather than a service key, which means a worker can’t open it without holding a person’s token.

That is a decision about credentials, not a missing function. It is also the entire distance between a loop that runs while you watch it and a factory that runs while you don’t.