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.shsubmits the script as a freshhyperspool/script/v1workflow.
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.
Determinism contract
Section titled “Determinism contract”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-collectwith 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 outsidespool 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.
Doing a thing exactly once
Section titled “Doing a thing exactly once”The rule above in one concrete mistake. This script posts to an API on every replay pass:
#!/usr/bin/env bashcurl -fsS -X POST https://api.example.com/deploy # ← runs every passspool wait-signal approvedspool step notify -- ./notify.shYour 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:
spool step deploy -- curl -fsS -X POST https://api.example.com/deployspool wait-signal approvedspool step notify -- ./notify.shThe 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 passawait waitSignal('approved');script.step("deploy", ["curl", "-fsS", "-X", "POST", DEPLOY_URL])script.wait_signal("approved")When the request fails inside the step
Section titled “When the request fails inside the step”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:
spool step deploy -- curl -fsS --retry 5 --retry-connrefused \ -X POST https://api.example.com/deployDon’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:
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.shfiA 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.
In-script CLI primitives
Section titled “In-script CLI primitives”spool step <name> -- <cmd...>
Section titled “spool step <name> -- <cmd...>”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.
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.
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:
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.
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.
spool sleep <duration>
Section titled “spool sleep <duration>”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.
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.
spool wait-signal <name>
Section titled “spool wait-signal <name>”Suspend until a signal of name is delivered. Returns the
signal’s payload as JSON on stdout.
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.
Bounded waits
Section titled “Bounded waits”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:
if payload="$(spool wait-signal "approved" --timeout 7d)"; then spool step "ship" -- ./deploy.shelse spool step "expire" -- ./close-out.shfispool 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.
Waiting on one name more than once
Section titled “Waiting on one name more than once”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:
while :; do verdict="$(spool wait-signal "approve")" [ "$verdict" = '"stop"' ] && break spool step apply -- ./apply.sh "$verdict"doneThree 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.
spool wait-event <type>
Section titled “spool wait-event <type>”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.
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.
spool lock <group> [-- <cmd...>]
Section titled “spool lock <group> [-- <cmd...>]”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:
spool lock tallyn="$(spool state get total --entity tally)"spool state set total "$(( ${n:-0} + 1 ))" --entity tallyecho "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.
./build.sh # runs concurrently across runsspool lock deploy -- ./deploy.sh # waits for the lease, then deploysA 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.
spool activity <name> [flags] -- <cmd...>
Section titled “spool activity <name> [flags] -- <cmd...>”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:
# runs on a machine tagged arm64-mac; this script may be on Linuxsha="$(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.
# every submit goes out before the first collect - that's what makes# the shards overlap instead of running one at a timefor 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=0for shard in 1 2 3; do passed=$(( passed + $(spool activity-collect "test-$shard") ))doneecho "$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:
for shard in $(seq 8); do spool activity-submit "test-$shard" --requires shell -- run-shard "$shard"donefailed="$(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.
# a router script that decides what to run, then submits itchild="$(spool spawn .spool/deploy.yaml --tag from-dispatch)"echo "spawned $child"
spool sleep 30secho "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-14f9593fa81a407dspool: script suspended for 30000ms; agent re-dispatch on wakeup
- replaying from checkpoint (completed steps are cached, not re-run)spawned dev:default:run-14f9593fa81a407dstill watching dev:default:run-14f9593fa81a407dThat 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 $(...):
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.jsonspool 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.
spool emit <type> [--payload <json>]
Section titled “spool emit <type> [--payload <json>]”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.
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.
cursor="$(spool state get cursor --entity crawler)"process-from "${cursor:-0}"spool state set cursor "$new_offset" --entity crawlerSide channels
Section titled “Side channels”spool annotate works from a script too - it attaches a markdown
note to the run without creating a durable boundary. See the
annotations guide.
The event, and routing
Section titled “The event, and routing”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 bashset -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 ;;esacspool 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.
Structured output (spool output set)
Section titled “Structured output (spool output set)”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.
spool step "metrics" -- bash -c 'echo "checking metrics"'spool output set verdict=ok score=0.97A 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: { ... }Env exposed to scripts
Section titled “Env exposed to scripts”Each script body sees:
$SPOOL_AGENT_SOCKET- path to the IPC socket thespoolCLI 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 onespool agents lslists. 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.$PATHfrom the agent process (so thespoolCLI 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 atype:push(webhook, plusforge/ref/sha/repo/sender/paths),manual(aspool submit),schedule.tick(plus thescheduleid), orspawn(plus theparentrun 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.
Choosing the interpreter
Section titled “Choosing the interpreter”The interpreter defaults to bash -eu. spool submit picks it up
from the file extension - .py → python3, .ts → bun run,
.js/.mjs → node, .go → go run - and --interpreter
overrides (space-split, so --interpreter "bun run" works):
spool submit deploy.py --follow # python3, auto-detectedspool submit deploy.ts --interpreter "deno run" # explicit overrideThe 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:
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")spool run ./probe.pyfrom 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.
What survives kill-9
Section titled “What survives kill-9”- 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-collectreturning 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-collectstraddling either restart: the suspended activity stays parked; on server start the eligible agent resumes it at wake / signal / completion.
Rerunning from a point
Section titled “Rerunning from a point”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:
spool rerun dev:default:run-3EMw --from deployThe 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:
curl -X POST https://example.com/announce # fires againspool step deploy -- ./deploy.sh # returns its recorded resultA 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.
Logs and replay
Section titled “Logs and replay”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.