Skip to content

Polyglot scripts

A polyglot script is a normal program (bash, python, node, a binary) that runs on an agent and uses the spool CLI to cross durable boundaries. The “SDK” is a single CLI binary plus shell; no per-language library to install. Python and TypeScript scripts can alternatively use the SDK packages’ in-script primitives (hyperspool.script / @hyperspool/sdk’s step, sleep, …), which speak the same agent protocol over the socket directly - identical semantics, no process spawn per call.

The workflow type is hyperspool/script/v1. Two ways to invoke:

  • Inside a YAML pipeline as a workflow: job (see Composition).
  • Top-level: spool submit --as=script ./my-script.sh submits the script as a fresh hyperspool/script/v1 workflow.

Bash scripts run under bash -eu (choosing the interpreter), so errexit and nounset are already on before your first line. Two consequences worth knowing up front: any command that fails ends the run unless you handle it (|| true, an if, a case), and reading an unset variable is a hard error, so optional env goes through ${VAR:-default}. The set -e at the top of the examples below is habit, not a requirement.

This is the rule you need to keep in your head:

Your script must call spool step / spool activity / spool activity-submit / spool wait-signal / spool sleep / spool activity-collect with the same names in the same order across replays of the same input. State you want preserved across calls must come back through their stdout. Anything between those calls (env reads, clocks, intermediate variables, files written outside spool step) is non-durable, will re-execute on every replay pass, and may differ between passes - that’s fine as long as it doesn’t change which cryo-call names are emitted next, and as long as you can tolerate it running multiple times.

sleep, wait-signal, activity-collect and activity-wait-all all suspend the script and the server re-runs it from the top on resume - so “every replay pass” includes one pass per wait you go through. Wrap real side-effecting work in spool step so it’s cached after the first pass. The server enforces the structural half: a replay that reaches a different durable call than the one recorded at that position is rejected with a clear error.

examples/replay-proof is this paragraph as a run you can count: a body that executes twice, and two steps that each execute once. Worth doing before writing a script whose steps cost money, because the run’s own log will not show you the difference - replayed output is recorded once rather than re-emitted, so a run that parked twice reads like a run that parked once.

The rule above in one concrete mistake. This script posts to an API on every replay pass:

#!/usr/bin/env bash
curl -fsS -X POST https://api.example.com/deploy # ← runs every pass
spool wait-signal approved
spool step notify -- ./notify.sh

Your script restarts from the top on every wait, so that curl fires once when the script first reaches the wait, and again on the pass that resumes. Two deploys, one run. Nothing warns you: the live tail shows each pass’s output, so it looks like it ran once per attempt because it did.

Put it in a step and it happens once:

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

The second pass replays deploy from its recorded stdout without running curl at all. Same in the SDKs, where the durable unit can be your own function rather than a subprocess:

import { stepFn, waitSignal } from '@hyperspool/sdk';
await stepFn('deploy', () => deploy()); // once, across every pass
await waitSignal('approved');
script.step("deploy", ["curl", "-fsS", "-X", "POST", DEPLOY_URL])
script.wait_signal("approved")

A step records its result whether it succeeded or not, so a failed request is remembered as a failure. Under bash -eu the non-zero exit ends the run, and replaying that run - a resume, a lease redelivery - returns the recorded failure rather than trying again. That is the point of a memo, and it means “just run it again” needs saying more precisely. Four ways, cheapest first.

Retry inside the step. The step only records once the command settles, so the retry never becomes durable state:

Terminal window
spool step deploy -- curl -fsS --retry 5 --retry-connrefused \
-X POST https://api.example.com/deploy

Don’t fail - capture the outcome and decide. The step records a success whose stdout says what happened, and the script branches on it. Deterministic, because the recorded value is stable across passes:

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 -- ./page.sh
fi

A policy retry (retry: on the job, --retry at submit) re-runs the whole script for real: between attempts the server purges that activity’s recorded steps and releases its signal claims, so nothing replays. That is what you want when the failure was environmental. It redoes everything, not just the step that failed - earlier steps run again too.

spool rerun --from <step> after the fact, when a person has looked at it. A fresh run keeps the steps before that point and re-runs from there. See rerunning from a point for what that does and does not promise.

The choice is mostly about who notices. Transient network failure: retry inside the step. A dependency that might legitimately be down: capture and branch. A bad deploy that needs a human: rerun from the step once they have fixed it.

Durable boundary. On first run it executes <cmd>, records the stdout and exit code under the step name, echoes that stdout verbatim, and exits with that code. A replay returns the cached output and exit code without re-running the command.

Terminal window
result="$(spool step "fetch-prices" -- curl -fsS https://api/prices)"
spool step "publish" -- post-prices "$result"

A step name is a program point, so reaching it again is an ordinary loop: the name is paired with an ordinal counting how many times this run has reached it, and each iteration records its own result. Three passes through spool step process are three steps, and all three run.

Terminal window
for host in web-1 web-2 web-3; do
spool step process --label "$host" -- ./drain.sh "$host"
done

--label is free text for a reader - it shows up in spool steps and the run view beside the ordinal, and it never affects which memo a call resolves to. Put the loop variable there rather than in the name.

Mutating step names between replays is a determinism violation.

A step’s output is capped at 2 MiB, and a step that goes over is refused with a message saying how far over it went. The output is memoized, so it is stored in the run and handed back on every replay pass rather than transferred once - a step that returns a build artifact or a full dataset makes every later pass carry it. Write large output to a file, or to an artifact with spool artifact put, and record the path:

Terminal window
spool step "render" -- bash -c 'render > /tmp/out.bin && echo /tmp/out.bin'

A step body runs without $SPOOL_AGENT_SOCKET, so <cmd> can’t call spool itself - a nested spool step or spool sleep exits 1 with SPOOL_AGENT_SOCKET is not set. Durable calls belong in the script, between steps, where the log can sequence them; a step is the leaf.

The one SPOOL_* a step body does get is $SPOOL_IDEMPOTENCY_KEY: a token for this step of this job of this run, to hand to a remote API that dedupes on one.

Terminal window
spool step charge -- sh -c '
curl -fsS https://api.example.com/charges \
-H "Idempotency-Key: $SPOOL_IDEMPOTENCY_KEY" \
-d "account=$1"
' _ "$account"

Note the single quotes: the variable only exists inside the body, so writing it unquoted on the spool step line expands it in the script’s own shell, where it is not set - under set -u the script aborts with SPOOL_IDEMPOTENCY_KEY: unbound variable, and without it the call goes out with no key at all. The script’s own values, like $account here, come in as arguments; the body sees a sealed env.

A step that dies after acting but before its result is recorded runs again, and so does one whose agent was lost. Both send the same token, so the remote system recognises the second call as the first. Two steps get two tokens, and a rerun of the run gets new ones. See the rules for the graph-side equivalent.

In the Go, Python, TypeScript, and Rust SDKs, stepFn is the same durable boundary around a native function rather than a subprocess - it records the function’s return value, so you call your own code instead of shelling out and parsing stdout. See workflows in your language.

Wait for a wall-clock duration; the agent slot is freed during the sleep (the activity is parked server-side and re-dispatched on wake). Humantime: 5s, 1m, 2h, 30d.

Terminal window
user="$(spool step "lookup" -- fetch-user "$EMAIL")"
spool step "send-welcome" -- send-email "$EMAIL" welcome "$user"
spool sleep "7d"
spool step "send-followup" -- send-email "$EMAIL" followup "$user"

On wake the script restarts from the top - spool step calls above the sleep return their cached outputs immediately, so the script “resumes” at the next uncached call. Plain shell commands between spool step calls re-execute on every replay; wrap side-effecting work in spool step if that matters.

That restart is a fresh process, and the wake-up is dispatched to whichever agent is free, so it may well be a different machine a week later. $user is still there on the far side because lookup replays what it recorded - a value is durable exactly when it came out of a step’s stdout. send-welcome doesn’t send a second email either, for the same reason.

Suspend until a signal of name is delivered. Returns the signal’s payload as JSON on stdout.

Terminal window
payload="$(spool wait-signal "approved")"
echo "got approval: $payload"

Mechanism (same suspend-and-replay primitive as spool sleep): on the first call the signal isn’t there yet, so the script suspends and parks server-side until it arrives - spool signal <run-id> <name> delivers it and wakes the run. A future claim re-runs the script from the top; this same call now finds the delivered signal and returns it. The parent’s claim slot is freed during the wait.

Without --timeout the wait has no upper bound. With one it gives up after that long and exits 124, the timeout(1) convention, so bash branches on it without parsing anything:

Terminal window
if payload="$(spool wait-signal "approved" --timeout 7d)"; then
spool step "ship" -- ./deploy.sh
else
spool step "expire" -- ./close-out.sh
fi

spool wait-event takes the same flag.

Two things to know. Errexit is on by default, so a bare bounded wait ends the run on timeout, which is rarely what you want - put it in an if, or append || true. And the outcome is pinned the first time it resolves: a wait that took an arrival returns that same one on every later pass, and a wait with no arrival and a recorded timeout keeps the timeout - so a signal landing a moment after the deadline does not change a branch the script already took.

A name is a queue, so each wait on it takes the next signal that arrived. A loop can wait on one name every time round:

Terminal window
while :; do
verdict="$(spool wait-signal "approve")"
[ "$verdict" = '"stop"' ] && break
spool step apply -- ./apply.sh "$verdict"
done

Three spool signal <run-id> approve calls feed three turns of that loop, in the order they arrived. Signals sent while nothing is waiting queue up rather than being dropped, so a sender that runs ahead of the script does not lose them.

That also means the sender never has to know where the script is. It addresses the run and the name, and the script’s own position decides which arrival it gets. If a particular turn needs a particular answer, put the discriminator in the payload - the sender can see that, and cannot see a loop counter.

Two jobs in one run waiting on the same name draw from the same queue, one signal each. spool wait-event works the same way: waiting on one event type repeatedly hands you successive events of that type.

From an SDK the same wait returns your language’s “nothing arrived” value instead of an exit code:

if script.wait_signal("approved", timeout=timedelta(days=7)) is None:
script.step("expire", ["./close-out.sh"])

waitSignal(name, { timeoutMs }) resolves null in TypeScript, WaitSignalTimeout(name, d) returns ok=false in Go, and wait_signal_timeout(name, d) returns None in Rust. All four need an agent speaking socket protocol v2; an older one is refused at the call site rather than quietly dropping the deadline and waiting forever.

Suspend until an event of type lands on the project bus (emitted by another run via spool emit, or forwarded from a forge webhook). Returns the event envelope as JSON on stdout.

Terminal window
event="$(spool wait-event "deploy.approved")"
echo "unblocked by: $event"

Sugar over wait-signal: the call registers a one-shot event-to-signal bridge server-side, then waits on a signal of its own, named for the event type and the job doing the waiting. Everything about the signal wait applies - suspend-and-replay and the freed claim slot.

Every waiting job gets its own wait, so any number of jobs, runs and graph nodes can wait for one event type and all of them are handed the event when it arrives. A job that starts waiting after an earlier event of that type already fired waits for the next one; it never inherits an event that arrived before it.

Waiting twice on the same type in one script hands you two events - the type’s signal is a queue like any other, so the second wait takes the next one. The bridge is registered again on the pass that reaches that second wait.

event: is the engine’s namespace, shared with a pipeline’s wait_for_event: jobs, which park on event:<node> - the node the job compiles to, so a ship-it: job parks on event:ship_it (see the YAML reference). To unblock one of those by hand, spool signal <run-id> event:<node>. A script’s wait has a longer name of its own - read it off spool status <run-id>, which reports the name under substate.name for the run’s most recent parked activity.

For the same reason spool wait-signal will not take a name starting with event:. Pick a name of your own, or use spool wait-event for the event you meant.

Serialize a critical section across runs. Acquire a durable, project-scoped lease on group; while another run holds it, this one waits.

What a lock protects is the read-modify-write, where two runs interleaving lose an update:

Terminal window
spool lock tally
n="$(spool state get total --entity tally)"
spool state set total "$(( ${n:-0} + 1 ))" --entity tally
echo "wrote $(( ${n:-0} + 1 ))"

Submit three of those at once against two agents and they print 1, 2, 3. Delete the spool lock line and they print 1, 1, 2: two runs read 0, both write 1, and one increment is gone. Nothing errors

  • the tally is just wrong afterwards, which is why the lock goes in before you can measure the problem.

Keep the read and the write in one pass. State operations are not checkpoints, so they re-execute on every replay; the shape above is safe because the only suspension is the lock itself, which happens before the read. Put a spool sleep between the get and the set and the resumed pass increments a second time, lock or no lock.

A bracketed command cannot call spool. The bracketed form records <cmd> as a step, which is what makes it run once however many times the script replays. Being a step, it gets a step’s rule: it runs without $SPOOL_AGENT_SOCKET, so a nested spool state exits 1 with SPOOL_AGENT_SOCKET is not set. And since the inner shell usually isn’t under errexit, the section carries on and writes nothing.

Bracket external commands; take the bare lease when the critical section is itself spool work, as above.

Terminal window
./build.sh # runs concurrently across runs
spool lock deploy -- ./deploy.sh # waits for the lease, then deploys

A bare spool lock <group> (no -- <cmd>) acquires and returns, holding the lease for the rest of the run.

Mechanism: the same suspend-and-replay primitive as wait-signal and sleep. On a held group the script suspends and its agent slot is freed; the control plane grants the lease FIFO when the current holder finishes and wakes the run, which replays and proceeds into <cmd>. While parked the run reports the awaiting_lease substate.

It’s the same lease a YAML job’s concurrency: takes: a script’s spool lock deploy and a job’s concurrency: { group: deploy } in the same project contend on one lock, so whichever gets there first runs and the other waits.

The lease is scoped to the bracketed command: it is handed back as soon as <cmd> finishes, so the next waiter starts then rather than when your run ends. A bare spool lock GROUP (no command) has no closure to scope to and holds until the run is terminal (released on run terminal, then handed to the next waiter). That fits a deploy at the tail of a run; a mid-run release verb is future work.

Dispatch a hyperspool-registered activity to a different agent matching its requirements. Synchronous (blocks until complete), and the child’s stdout comes back on yours:

Terminal window
# runs on a machine tagged arm64-mac; this script may be on Linux
sha="$(spool activity "build-mac" --requires arm64-mac \
-- bash -c 'cd /srv/repo && ./build.sh --print-sha')"
spool step "record" -- ./publish-manifest.sh "$sha"

--requires is how the work gets to hardware this script isn’t running on. That machine is a different filesystem: the command is evaluated there, so a relative path resolves against its working directory, not this script’s. Send absolute paths, or cd first as above.

spool activity-submit / spool activity-collect

Section titled “spool activity-submit / spool activity-collect”

Fan-out primitive: submit many activities up front, collect results later. activity-submit returns immediately once the child is enqueued; activity-collect suspends the parent until the child terminates.

Terminal window
# every submit goes out before the first collect - that's what makes
# the shards overlap instead of running one at a time
for shard in 1 2 3; do
spool activity-submit "test-$shard" --requires shell \
-- bash -c "cd /srv/repo && ./run-tests.sh --shard $shard --count"
done
passed=0
for shard in 1 2 3; do
passed=$(( passed + $(spool activity-collect "test-$shard") ))
done
echo "$passed tests passed across 3 shards"

Each child returns its own count and the parent adds them up, so the total is only right if all three ran and each was collected separately. Submitting inside the first loop and collecting in the second is the part that matters: interleaving them (submit; collect; submit; collect) serializes the shards and throws the parallelism away.

A failed child fails the parent. activity-collect exits with the child’s exit code, and with errexit on by default that ends the run at the collect - which is usually what you want for a test shard. To decide for yourself, catch it: out="$(spool activity-collect "test-1" || echo skipped)".

A child you never collect is a different matter: nothing reads its result, so a failure among those children leaves the run green and says nothing. A script can also exit while they are still running, and then the run reaches a terminal state before they report at all. When you don’t need each child’s output, wait for them as a group:

Terminal window
for shard in $(seq 8); do
spool activity-submit "test-$shard" --requires shell -- run-shard "$shard"
done
failed="$(spool activity-wait-all)"
[ "$failed" = 0 ] || { echo "$failed shard(s) failed"; exit 1; }

Note run-shard, not ./run-shard.sh: a child runs on whichever agent claims it, in that agent’s own working directory rather than the script’s, so a relative path resolves against somewhere you didn’t choose. Name a program on the agent’s PATH, or an absolute path.

activity-wait-all suspends the same way collect does - once per child it actually has to wait for, which is usually far fewer than one per child - and prints how many of them failed. It exits 0 whatever that number is, so the decision above stays yours.

It covers the children this script submitted and has not already collected. A child you collected and handled yourself doesn’t get counted a second time, and a script in a different script: job can’t wait on this one’s children - each job’s children are its own. A run that succeeds with children still unreported gets an annotation on its page saying so.

activity-collect uses the same suspend-and-replay primitive as wait-signal and sleep: if the child hasn’t finished yet the parent suspends. Crucially, the parent’s agent slot is freed during the wait - a single-agent deployment can drain the children the parent is waiting on without head-of-line blocking. (No local bash wait / & backgrounding needed; the suspend-and- replay model handles that for you.)

Polyglot fan-out children are tracked separately from workflow: child runs - they don’t nest in the parent’s event log; instead the web UI surfaces them in a dedicated “Activities (fan-out)” panel with status, exit code, and captured stdout per child.

The loop above scales as written: swap 1 2 3 for $(seq 100) and one script drives a hundred children across every agent that will claim them, on one suspend-and-replay collect each. Names have to stay distinct, which is what test-$shard is doing.

spool spawn <file> [--as pipeline|script] [--tag <t>]...

Section titled “spool spawn <file> [--as pipeline|script] [--tag <t>]...”

Submit a child run (a YAML pipeline or another polyglot script) from inside a script, and print its run id. - reads the source from stdin. The child runs independently (fire-and-forget); it’s a separate run, not a fan-out activity.

Terminal window
# a router script that decides what to run, then submits it
child="$(spool spawn .spool/deploy.yaml --tag from-dispatch)"
echo "spawned $child"
spool sleep 30s
echo "still watching $child"

This is the primitive that makes a script an orchestrator: inspect inputs, then spawn the workflows you choose.

Each spawn is checkpointed by its position in the script, so the sleep above is safe: the replay pass prints the same id it printed the first time and no second child is submitted.

spawned dev:default:run-14f9593fa81a407d
spool: script suspended for 30000ms; agent re-dispatch on wakeup
- replaying from checkpoint (completed steps are cached, not re-run)
spawned dev:default:run-14f9593fa81a407d
still watching dev:default:run-14f9593fa81a407d

That is worth checking rather than assuming, because a spawn that re-ran would deploy twice and the second run id would be the only evidence. Position is the key here, not a name - spawn takes none, so moving a spawn across a suspend point changes which memo it lands on. Like the other durable primitives it only works inside a polyglot script run, not a YAML shell step.

spool call node <kind> / spool call graph <name> / --definition <file|->

Section titled “spool call node <kind> / spool call graph <name> / --definition <file|->”

Submit a child run and wait for it. The script parks (suspend-and-replay, so the agent slot is freed), and when the child terminates its output is printed on stdout - capture it with $(...):

Terminal window
status="$(spool call node http --config '{"url":"https://api.example/ping"}')"
report="$(spool call graph nightly-report --input '{"day":"mon"}')"
# a graph the script just computed
./plan.py > plan.json
spool call graph --definition plan.json

--definition takes an inline hyperspool-graph/v1 document (- reads it from stdin), so a script can run work it decided at run time without publishing a graph first. spool spawn, by contrast, returns a run id straight away and never yields the child’s result.

Publish a custom event to the project’s event bus and print its event id. If a project handler matches the type, the emit triggers a handler run - and a handler’s own custom emits chain on to the next handler, so scripts can hand work down a routing chain. Durable and replay-safe - each emit is checkpointed by its position, so a replay returns the same event id instead of emitting a duplicate.

Some types are reserved and rejected: push, manual, schedule.tick, spawn, and the platform-emitted run.* / spool.* namespaces.

Terminal window
spool emit deploy.finished --payload '{"env":"prod","sha":"'"$SHA"'"}'

spool state get|set|delete|list [--entity <name>]

Section titled “spool state get|set|delete|list [--entity <name>]”

Read and write durable state cells - named, mutable state that survives replay. No --entity = run scope (private to this run); --entity <name> = the project’s cross-run entity scope. get prints the value (empty if unset); set takes JSON (or a bare string) and reads stdin on -; list prints key<TAB>value lines.

Unlike the durable primitives above, state is a plain read/write, not a suspend-and-replay boundary - it runs on every replay pass, so prefer last-write-wins or idempotent writes.

Terminal window
cursor="$(spool state get cursor --entity crawler)"
process-from "${cursor:-0}"
spool state set cursor "$new_offset" --entity crawler

spool annotate works from a script too - it attaches a markdown note to the run without creating a durable boundary. See the annotations guide.

Every run carries an event describing what started it, written to a file named by $SPOOL_INPUT_FILE - a JSON envelope with a type and type-specific fields, complete whatever its size. A push is {"type":"push","forge":…,"ref":…,"sha":…,"repo":…,"sender":…}; scheduler, manual, and custom triggers add their own type on the same shape. The same facts are also present as CI_TRIGGER_* vars (see env below).

Because any workflow can read the event and start more workflows with spool spawn, routing is just a workflow that spawns. A push runs one entrypoint pipeline; a job in it reads the event and picks what to run. No separate “dispatcher” concept - it’s the same spool spawn primitive.

This router job reads that file with jq, picks workflows by branch, and spawns them:

#!/usr/bin/env bash
set -euo pipefail
branch="$(jq -r '.ref | sub("refs/heads/"; "")' "$SPOOL_INPUT_FILE")"
echo "routing branch=$branch"
case "$branch" in
main)
spool spawn .spool/ci.yaml --tag routed
spool spawn .spool/deploy.yaml --tag routed
;;
release/*)
spool spawn .spool/ci.yaml --tag routed
spool spawn .spool/release.yaml --tag routed
;;
*)
spool spawn .spool/ci.yaml --tag routed
;;
esac

spool spawn is memoized (see above), so if the router suspends and replays it won’t submit duplicate children. The spawned runs are independent - the router doesn’t wait on them. To collect results instead, use spool activity-submit / spool activity-collect.

spool spawn <file> reads a local file, so a router that spawns repo-tracked pipelines needs those files on the agent: check out the repo first (the job has $CI_TRIGGER_SHA), the same clone a normal CI job does. Every push is durable regardless - the entrypoint run is created when the push arrives and waits for an agent if none is free, so nothing is dropped between delivery and execution.

The script stages structured output with spool output set. The keys are merged onto the child workflow’s final output alongside exit_code and duration_ms. They’re committed only if the script exits successfully.

Terminal window
spool step "metrics" -- bash -c 'echo "checking metrics"'
spool output set verdict=ok score=0.97

A YAML pipeline that spawns this as a workflow: job can pick those keys via its outputs: mapping:

canary:
outputs:
verdict: verdict
score: score
workflow:
type: hyperspool/script/v1
script: { ... }

Each script body sees:

  • $SPOOL_AGENT_SOCKET - path to the IPC socket the spool CLI uses to talk to the server through this agent.
  • $SPOOL_AGENT_PID - agent process pid.
  • $SPOOL_AGENT_ID - the id of the agent running this body, the same one spool agents ls lists. Scoped to this execution on purpose: a run that suspends is re-dispatched to whichever agent is free, so a script that slept twice may have run on three agents and no single value describes the run. Read it where you need it and record it in a step if it has to survive. Shell jobs in a YAML pipeline get it too.
  • $SPOOL_WORKFLOW_ID - the parent workflow’s id.
  • $SPOOL_ACTIVITY_ID - this script_run activity’s id.
  • $PATH from the agent process (so the spool CLI is reachable).

On top of those, a script run gets whatever env its workflow supplies, plus the context of whatever started it - a webhook-dispatched script (above) gets the push, a manual one gets CI_TRIGGER_EVENT=manual:

  • CI_TRIGGER_* - one per scalar field on the run’s event: EVENT (the type), EVENT_ID (the bus id, on a delivery), SHA, SHORT_SHA, REF, REPO, SENDER, FORGE, SCHEDULE, PARENT. Which ones appear depends on the event; a field that isn’t a scalar (paths, payload) has no flat form and stays in the file.
  • $SPOOL_INPUT_FILE - a file holding what started the run, as one JSON object with a type: push (webhook, plus forge/ref/sha/repo/sender/paths), manual (a spool submit), schedule.tick (plus the schedule id), or spawn (plus the parent run id). Every run with an input carries one, and it is never truncated - a forwarded forge body arrives whole.

The cryo-internal vars (SPOOL_AGENT_SOCKET and the others above) are applied last and always win, so a workflow’s env can’t shadow them.

The ambient agent environment does not pass through. Scripts run with a sealed env: besides the vars above, only a fixed allowlist of standard vars is forwarded from the agent process (HOME, USER, LOGNAME, SHELL, TERM, LANG/LANGUAGE/LC_*, TZ, TMPDIR, and the SSL_CERT_FILE/SSL_CERT_DIR/NIX_SSL_CERT_FILE cert paths). Any other env you need, set it inside the script or supply it via the workflow’s env.

The interpreter defaults to bash -eu. spool submit picks it up from the file extension - .pypython3, .tsbun run, .js/.mjsnode, .gogo run - and --interpreter overrides (space-split, so --interpreter "bun run" works):

Terminal window
spool submit deploy.py --follow # python3, auto-detected
spool submit deploy.ts --interpreter "deno run" # explicit override

The runtime has to be installed where the script runs. The interpreter is invoked on the machine running the agent, so a .py workflow needs python3 on that machine’s PATH, .ts needs bun, .go needs the Go toolchain, and so on. Running locally with spool run / spool dev, that machine is yours, so spool run probe.py fails if you don’t have python3. On a deployed agent the requirement is the agent host’s PATH (see deploying agents). Only bash and raw spool-verb scripts have no extra runtime beyond the spool binary itself.

A runnable local example - install the CLI plus python3, then:

probe.py
from hyperspool import script
# `step` runs a command as a durable checkpoint; on replay it
# returns the recorded stdout instead of re-running.
reading = script.step(
"probe", ["python3", "-c", "import random; print(random.randint(0, 99))"]
)
print(f"probe read {reading.stdout.strip()}")
# Seconds or a timedelta, not the CLI's "5s" humantime string.
script.sleep(5)
# Same reading: the second pass replayed the memo rather than
# sampling again.
print(f"still {reading.stdout.strip()} after the sleep")
Terminal window
spool run ./probe.py

from hyperspool import script is the Python SDK - a script that imports it needs the SDK importable by the python3 that runs it (see workflows in your language). A script that only shells out through the spool CLI (spool step …, spool sleep …) has no import at all and runs anywhere the spool binary and the interpreter both exist.

Local spool dev / spool run execute the file in place from the directory you submit it in, so bun, go run, and python3 resolve the SDK from your node_modules / go.mod / virtualenv with no extra setup - spool submit release.go just works from your module. Editing that file while a run is parked won’t change what the parked run does: it recorded its own copy at submit time and replays that, so the edit lands on your next spool submit. A deployed agent instead runs the script from a scrubbed scratch dir, so there a Go or TypeScript workflow that imports the SDK needs its module context: run it from a checked-out repo (a steps: job doing go run . / bun run .). See getting the SDK onto the agent.

  • The agent dying mid-script: the activity is re-dispatched; some other agent claims it; the script restarts from the top, with every spool step / spool sleep / spool wait-signal / spool activity-collect returning its cached result until reaching the point of the original death.
  • The server dying mid-script: the script keeps running on the agent. When the server restarts, the agent’s pending spool step / completion calls land normally.
  • A spool sleep / wait-signal / activity-collect straddling either restart: the suspended activity stays parked; on server start the eligible agent resumes it at wake / signal / completion.

spool rerun <run-id> --from <site> starts a fresh run at one of your spool step sites, reusing what the original recorded before it. The build was fine, the deploy flaked, don’t rebuild:

Terminal window
spool rerun dev:default:run-3EMw --from deploy

The new run’s build returns the bytes the original captured. deploy and everything after it actually run.

It promises less than the same flag on a pipeline or a graph. There, the upstream nodes do not execute. Here, the calls before your point don’t re-perform, but the script around them runs again from line one, the same way it does after a spool wait-signal. So:

Terminal window
curl -X POST https://example.com/announce # fires again
spool step deploy -- ./deploy.sh # returns its recorded result

A script that already survives a wait is safe here by construction, for the same reason it survives the wait. If yours has never suspended, this is the first time that discipline is tested: wrap side effects in spool step and they happen once.

A loop reaches one site many times, so a bare name means the first time - which drops every iteration of it - and --from check#2 starts at the third, keeping the two before it.

If the prefix no longer reproduces (the world changed and your script takes a different branch), the run fails loudly on the determinism guard rather than doing something subtly wrong.

Replay paths return cached spool step / spool sleep / spool activity-collect output to your script’s $(...) capture, but don’t re-emit those bytes to the live log sink: logs record each execution attempt once, which keeps the live tail readable on long-running workflows. The web UI shows only the latest pass’s tail, trimming at the start of each script re-run. Per-step terminal panels in the event stream and the “Activities (fan-out)” panel keep prior-pass output durably available.

Plain shell between spool calls (e.g. a bare printf) re-runs on every pass and will appear once per pass in the live tail. Wrap it in spool step if you only want to see it once.

To see kill-9 durability for yourself, run spool init and follow the crash test in getting started.