Skip to content

Polyglot scripts

A durable script is a normal program (bash, Python, Node, Go, a binary) that runs on an agent and calls the spool CLI at the points that should survive a crash or a wait. Like this:

Terminal window
n="$(spool step pick -- bash -c 'echo $(( RANDOM % 100 ))')"
spool sleep 5s
echo "picked $n, still $n after the sleep"

spool sleep ends the process and frees the agent. Five seconds later the script starts again from the top, pick returns the number it drew the first time, and the last line prints it:

Terminal window
$ spool run hello.sh
run: dev:default:run-Je468jx72lfdwpYv
waiting for the local agent to start
- replaying from checkpoint (completed steps are cached, not re-run)
picked 33, still 33 after the sleep
dev:default:run-Je468jx72lfdwpYv completed

The spool CLI is the whole SDK for bash and for any language that can start a process. Python also has a library, hyperspool.script, that does the same without a process per call; see workflows in your language. Every command a script can call is listed in script commands.

There are four ways to run one:

  • spool run hello.sh runs it on a throwaway instance on your machine and exits with the run’s result. spool submit hello.sh --follow sends it to your server or to spool dev.
  • A script: job in a pipeline runs a script as one step of that run (YAML reference).
  • A code step on a graph (“Polyglot script” on the canvas) does the same in a graph, with an interpreter (["python3"]) when the script isn’t bash.
  • A workflow: job starts a script as a separate child run (composition).

spool submit and spool run pick the interpreter from the file’s extension: .py runs under python3, .js under node, .go under go run, and anything else under bash (the full table). The interpreter has to be where the script runs; managed environments lists what Hyperspool’s own environments carry.

Bash scripts run under bash -eu, so errexit and nounset are on before your first line. A failing command ends the run unless you handle it (|| true, an if), and an unset variable is an error, so optional variables go through ${VAR:-default}.

A run keeps its own copy of the script from when it was submitted, so editing the file while a run waits changes nothing for that run.

Each durable call (spool step, spool sleep, a wait, a child run) records its result in the run the first time it completes. That recorded result is a checkpoint. When the run resumes, after a sleep, a wait or a crash, the script runs again from the top: calls with a checkpoint return it at once, and execution carries on from the first call without one. Replay explains the model, and the authoring model has the rules.

The rule that matters most: anything with a side effect goes inside a spool step. Code between durable calls runs on every pass, possibly on a different machine each time. This script deploys twice, once on the pass that reaches the wait and again on the pass that resumes:

Terminal window
curl -fsS -X POST https://api.example.com/deploy # runs on every pass
spool wait signal approved

In a step it happens once, because the resuming pass gets the recorded result back:

Terminal window
spool step deploy -- curl -fsS -X POST https://api.example.com/deploy
spool wait signal approved

The same goes for anything you branch on, such as the time, a random number or a file: read it inside a step so every pass sees the same value. A pass that reaches a different durable call than the first one did fails the run; when replay goes wrong shows how to find the cause.

spool step <name> -- <cmd> runs the command once, records its stdout and exit code, and prints the stdout. Capture it to use it later:

Terminal window
version="$(spool step version -- date +%Y%m%d)"
spool step build -- echo "building $version"

A step name is a place in the script, so a loop can reach one name many times and each reach gets its own checkpoint. Put the loop variable in --label, which only changes how the step shows on the run page:

Terminal window
for host in web-1 web-2 web-3; do
spool step drain --label "$host" -- echo "draining $host"
done

Keep step output small: a path, an id or a short summary, up to 2 MiB. A step body runs in a sealed environment and can’t call spool itself; spool step has the details, including the idempotency key a body gets for APIs that dedupe.

spool sleep <duration> waits for wall-clock time (30s, 2h, 7d) holding no agent. A week-long sleep costs nothing while it waits:

Terminal window
user="$(spool step lookup -- echo "ada@example.com")"
spool step welcome -- echo "welcome mail to $user"
spool sleep 7d
spool step follow-up -- echo "follow-up mail to $user"

A week later the script runs again from the top, probably on another machine. $user is still set because lookup returns its checkpoint, and welcome doesn’t send a second mail.

spool wait signal <name> waits until someone sends that signal to the run, with spool run signal <run> <name> or from the run page, and prints its payload. spool wait event <type> waits for an event on the project bus, from a webhook, a handler or spool event emit.

Both take --timeout. A wait that gives up exits 124, so put a bounded wait in an if:

Terminal window
if spool wait signal approve --timeout 1d > /dev/null; then
spool step ship -- echo "shipping"
else
spool step expire -- echo "nobody approved in time"
fi

Signals queue: one sent before the script reaches the wait is kept for it. When a person should answer with a form instead of a bare signal, use an approval job or step.

A script can start other work and, if it needs the answer, wait for it holding no agent:

Command Starts Waits
spool spawn <file> a separate run; prints its id no
spool call <file>, spool call -w <name> a run that belongs to this one; prints its output yes
spool activity submit <name> -- <cmd> one command on any agent with the capabilities it needs no; collect with spool activity wait
spool activity run <name> -- <cmd> the same, then waits yes

Every start is recorded, so a replay doesn’t start a second child. Use activities to fan one script out over many shards; submit them all before the first wait so they overlap:

Terminal window
for shard in 1 2 3; do
spool activity submit "test-$shard" --requires shell -- echo "shard $shard passed"
done
spool activity wait --all # exits 1 if any shard failed

Use a child run when each piece should have its own run page and history. Composition covers both.

A step records its result whether it succeeded or not, so a replay of a failed step returns the same failure. Under bash -eu a failed step ends the run. Ways to try again, cheapest first:

  • Retry inside the step. The step records once the command settles, so curl --retry 5 --retry-connrefused never becomes durable state.

  • Make the step succeed with a value that says what happened, and branch on it:

    Terminal window
    result="$(spool step deploy -- sh -c 'curl -fsS -X POST "$0" || echo UNREACHABLE' \
    https://api.example.com/deploy)"
    if [ "$result" = UNREACHABLE ]; then
    spool step page-oncall -- echo "deploy endpoint unreachable"
    fi
  • A retry: on the job or graph step re-runs the whole script for real. Earlier checkpoints are dropped between attempts, so earlier steps run again too. Use it when the failure was environmental.

  • spool run rerun <run> --from <step> after a person has looked at it. The new run reuses the original’s checkpoints up to that step and runs it and everything after it for real. The script around them still runs from line one, so a side effect outside a step fires again.

A crash is not a failure. If the agent dies mid-script, another agent picks the run up and the script replays to where it was. A server restart doesn’t interrupt a running script, and a run asleep or waiting across either kind of restart resumes when its timer, signal or child completes. Getting started shows a run surviving a crash.

A release that builds, waits up to an hour for someone to approve, deploys one run at a time, and announces it:

release.sh
version="$(spool step version -- date +%Y%m%d.%H%M%S)"
spool step build -- echo "built $version"
echo "waiting for approval of $version"
if spool wait signal approve --timeout 1h > /dev/null; then
spool lock deploy -- echo "deployed $version"
spool event emit release.done --payload "{\"version\":\"$version\"}" > /dev/null
echo "released $version"
else
spool step give-up -- echo "nobody approved $version"
fi

spool lock deploy -- <cmd> holds a project-wide lease while the command runs, so two releases never deploy at once. Submit it, approve it, and read the log:

Terminal window
run="$(spool submit release.sh)"
spool run signal "$run" approve
spool logs "$run"
Terminal window
built 20261101.213423
waiting for approval of 20261101.213423
deployed 20261101.213423
released 20261101.213423

The version stayed the same across every pass because it came from a step. In a real release the echo steps become your build and deploy commands.

Every pass writes its own output. A step replayed from its checkpoint prints its recorded output again without running the command, and plain shell output between calls appears once per pass. spool logs shows the latest pass, which reads as one complete run; spool logs --follow shows every pass. When replay goes wrong shows how to read both.