The One Clock They Left On: Rebuilding /loop and /schedule Inside a Dynamic Workflow
I spent last week taking Dynamic Workflows apart — the deterministic JavaScript controller that spawns and awaits LLM subagents, and the sandbox contract that decides what the controller is allowed to touch. The contract is strict on purpose. The script has to be replayable: a workflow can pause, resume, and return cached results for every agent() call it already made, which only works if the script itself is deterministic. So the sandbox confiscates every source of nondeterminism it can find. Date.now() throws. Math.random() throws. Even an argument-less new Date() throws. The harness takes the clocks down off the wall, because a clock is just a machine for telling two runs apart.
And then, almost as an afterthought, it leaves one timer running.
setTimeout survives the sandbox. Not the clock that tells you what time it is — that one's gone — but the clock that agrees to wait. And waiting, it turns out, is the entire substance of the two things I most missed having inside a workflow: /loop's cadence and /schedule's fire-this-later. You don't need to know the hour to run something three times, ten seconds apart. You only need something patient enough to count to ten.
So I rebuilt them. Here's the whole thing running — cold claude start to three completed agent runs — as a director's cut with chapter markers on the seek bar. The two long pauses you'll see between runs aren't dead air. They're the setTimeout waits, live:
{
"src": "/blog/stories/claude-dynamic-workflows-settimeout-loop/scheduled-workflows-directors-cut.cast",
"poster": "npt:1:07",
"theme": "dracula",
"pauseOnMarkers": false,
"rows": 30,
"cols": 100
}
A self-rescheduling agent: run 1 returns 100, then a ten-second pause, run 2 returns 200, another pause, run 3 returns 300. The original capture ran a little over a minute and a half; this cut trims the exit and tail to seventy seconds and chapters each beat.
The trick is recursion, not a loop
The naive instinct is a for loop with a sleep in it. But there's no sleep you can await against a forbidden clock, and a tight loop wouldn't give you the gap — the deliberate spacing that makes this a schedule rather than a burst. The move is to let each run register the next one, and to wrap the timer in a promise so the workflow stays alive until the whole chain resolves:
const TOTAL_RUNS = (args && args.runs) || 3
const DELAY_MS = (args && args.delayMs) || 10_000
const results = []
function runChain(runNo) {
return new Promise((resolve, reject) => {
agent(`Scheduled run ${runNo}. Return value=${runNo * 100}…`, { schema: ANS })
.then((res) => {
results.push({ run: runNo, value: res.value })
if (runNo >= TOTAL_RUNS) return resolve() // base case: stop
setTimeout(() => { // self-register the next run
runChain(runNo + 1).then(resolve).catch(reject)
}, DELAY_MS)
})
.catch(reject)
})
}
await runChain(1)
Read it once and the shape is clear: run N's agent resolves, pushes its result, and — if we're not done — arms a setTimeout that will fire run N+1. The outer await runChain(1) is the load-bearing line. It's a quiet act of faith that the recursion will eventually return, that the last promise will travel all the way back up the chain to the place where the first one was made. Swap the runNo >= TOTAL_RUNS base case for a budget.remaining() check and you've rebuilt /loop. Widen the delay and read the task from args and you've rebuilt /schedule. Both reconstructed from the one instrument the sandbox forgot was a clock at all.
The seam where it doesn't quite reach
I should be honest about what this is, because the most interesting thing about a workaround is usually the place it stops working. This is not durable. The ten-second gaps live only inside the single, continuous breath of one execution. Kill the run and resume it, and the finished agents return instantly from cache while the timers — never written to the journal — simply fail to wait. The schedule is a performance of scheduling, convincing while the lights are on, gone the moment they go down. For genuinely durable, restart-surviving cadence you still want real cron, the kind /schedule gives you above the line.
There's one more honest detail, and the recording keeps it in frame rather than hiding it: that capture was invoked asking for five runs, and exactly three executed. The generator was capped at three, and the cap won — which is its own small argument for why the deterministic controller sits outside the agents. The number of times you loop is a fact about the script, not a thing you want a model improvising turn by turn.
That's the through-line of everything I wrote about Dynamic Workflows last week. The platform draws a hard line between the part that must be reliable and the part that gets to think. The clocks come down so the controller stays replayable. And then setTimeout gets left on — not as an oversight, I suspect, but because waiting is the one form of time-telling that doesn't threaten a replay. It was enough to rebuild the two tools I missed. Inside one run, anyway. Until someone gives that timer a memory.
Related: Dynamic Workflows: A Deterministic Controller Over LLM Subagents and The Confused Deputy in Your Workflow. The self-rescheduling-agent workflow is parameterized — { runs, delayMs } — and installed as a named workflow you can invoke directly.