Skip to content

Script commands

What each spool command does when a script calls it from inside a run. Polyglot scripts is the guide; this page is the lookup. Flags and examples for every command are in the command reference, and spool help job prints a summary offline.

These commands talk to the agent running the script, over the socket in $SPOOL_AGENT_SOCKET. They need no server address or token.

Recorded commands store their result in the run the first time they complete; a replay returns that result without doing the work again. Live commands run again on every pass. Commands marked “waits” end the script process and free its agent until the wait is over; the script then runs again from the top.

Command Python SDK What it does Recorded Waits
spool step step, step_fn Runs a command once, records stdout and exit code yes
spool sleep sleep Sleeps for a wall-clock duration yes yes
spool wait signal wait_signal Waits for a named signal, prints its payload yes yes
spool wait event wait_event Waits for a project bus event, prints the envelope yes yes
spool lock lock Holds a project-wide lease yes while held elsewhere
spool activity run activity Runs a command on any matching agent, prints its output yes yes
spool activity submit activity_submit Queues a command on any matching agent yes
spool activity wait activity_collect, activity_wait_all Collects submitted work yes yes
spool spawn spawn Starts another run, prints its id yes
spool call call_node, call_graph Starts another run, waits, prints its output yes yes
spool event emit emit Publishes an event on the project bus yes
spool state add, cas, incr state_add (no cas, incr) Claims, swaps or counts a state cell yes
spool state get, set, delete, list, version state_get, state_set, state_delete, state_list Reads and writes a state cell live
spool artifact put, get artifact_put, artifact_get Stores and restores files yes
spool artifact list artifact_list Lists artifacts live
spool output set output_set Stages this job’s output live
spool annotate annotate Posts a markdown note to the run page live
spool checkout checkout Clones the run’s repository at its commit live

When replay goes wrong lists which of the recorded commands count toward the order check a replay runs.

spool step <name> [--label <text>] -- <cmd...>

Runs <cmd> once, records its stdout and exit code, echoes the stdout and exits with that code. A replay returns the recorded output without running the command. A failed step is recorded too, so its replay returns the failure.

  • The name is a place in the script. A loop reaches one name many times and each reach gets its own recorded result. --label is display text for spool run steps and the run page; it never changes which result a call replays.
  • A step records at most 2 MiB of output; more fails the step. Record a path or an id and put large files in an artifact.
  • Exit codes are the command’s own; 125, 126 or 127 when it couldn’t start, isn’t executable or doesn’t exist.

The body runs in a sealed environment: the agent’s working directory (not the script’s current directory), PATH, the agent variables on the allowlist and the run’s credentials. The job’s env:, variables the script exported and CI_TRIGGER_* don’t reach it, so pass what <cmd> needs as arguments.

A body can’t call spool itself (a nested call exits 2 with this runs only inside a job). The one SPOOL_* variable it gets is $SPOOL_IDEMPOTENCY_KEY, a token for this step of this job of this run, for an API that dedupes on one:

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

Keep the single quotes: the variable exists only inside the body, and under set -u expanding it in the script’s own shell aborts the script. A step that dies after acting but before its result is recorded runs again with the same token. Two steps get two tokens, and a rerun gets new ones.

spool sleep <duration>

Sleeps for 500ms, 30s, 5m, 2h, 7d and so on, holding no agent. The deadline is fixed on the first pass, so an agent crash mid-sleep resumes toward the original wake time.

spool wait signal <name> [--timeout <duration>]

Waits until a signal called name is sent to the run (spool run signal <run> <name>), then prints its payload as JSON.

  • With --timeout, a wait that gives up exits 124. Under bash -eu put a bounded wait in an if. The outcome is fixed the first time it resolves, so a signal landing just after a recorded timeout doesn’t change a branch already taken.
  • A name is a queue. Each wait takes the next signal, signals sent while nothing is waiting are kept, and two jobs in one run waiting on one name take one signal each. A loop can wait on the same name every time round.
  • The sender addresses the run and the name and can’t see where the script is. If a particular turn needs a particular answer, put the discriminator in the payload.
  • Names starting with event: are refused.

A signal has no approve/reject form. For a person answering a form, use an approval job or step.

spool wait event <type> [--timeout <duration>] [--accept-untrusted]

Waits until an event of type lands on the project bus (from spool event emit, a handler, or a webhook) and prints the event envelope as JSON. --timeout works as for wait signal.

  • Every waiting job gets its own copy, so any number of jobs, runs and graph steps can wait for one type.
  • A wait that starts after an event fired waits for the next one. Waiting on one type twice takes two successive events.
  • A run started for a subject (a pull request, a customer) only takes events for that subject, or events emitted without one.
  • spool event emit <type> from your terminal unblocks a wait by hand.
  • An untrusted event, such as a fork’s pull request, ends the wait only with --accept-untrusted; the envelope then carries "untrusted": true.
spool lock <group> [-- <cmd...>]

Takes a durable lease on group, shared across the project. While another run holds it, this one waits holding no agent. Leases are granted in arrival order.

Form Holds the lease Belongs to
spool lock g -- <cmd> while <cmd> runs; <cmd> is recorded as a step the job
spool lock g until the run ends the run, so once per run

The bracketed command runs as a step, so it can’t call spool. When the critical section is itself spool work, such as a state read and write, take the bare lease. A job’s concurrency: takes the same lease, so a script and a job naming one group contend on one lock. A run waiting for a lease shows the awaiting_lease substate.

spool activity run <name> [flags] -- <cmd...>

Runs a command as a separate unit of work that any agent matching the flags can claim, waits for it, and prints its stdout. It exits with the command’s code.

Flag Meaning
--requires <cap> Capability the claiming agent must advertise (repeatable)
--agent <id> Only this agent may claim it
--attempts <n> Total attempts, counting the first
--backoff <duration> Wait between attempts (needs --attempts)
--retry-on-exit <code> Retry only on these exit codes (repeatable); default any non-zero

The command runs on the claiming agent’s filesystem in its own working directory, so use absolute paths or cd first. Capabilities beyond shell, polyglot, connector and build come from agents you run. Names are unique in the run.

spool activity submit <name> [flags] -- <cmd...>
spool activity wait <name>... | --all

submit takes the flags of activity run and returns once the work is queued. wait <name> waits for that activity and prints its output, exiting with its code; with several names it waits in order and exits with the first failure’s code. wait --all covers every activity this run submitted, prints a table of names, statuses and exit codes, and exits 1 if any failed.

Submit everything before the first wait, or the work runs one item at a time. An activity nobody waits for is never read, so its failure goes unnoticed and the script can end before it reports; a run that succeeds with activities unreported gets a note on its page. Activities don’t appear in the run’s event log: the run page lists them under Activities, and spool run activities <run> [--failed] lists them in a terminal.

spool spawn <file|-> [--as pipeline|graph|script] [--input <json>] [--tag <t>]...
spool spawn -w <name[@version]> [--input <json>]
spool spawn --node <kind> --config <json>

Starts another run and prints its id without waiting. A file whose version: is hyperspool-graph/v1 runs as a graph, a .yaml or .yml file as a pipeline, anything else as a script; --as overrides that. - reads the source from stdin.

  • The spawned run goes through admission like a submit: its own concurrency: and throttle: apply (run admission). When admission turns it away, the spawn fails with the spawned pipeline was refused: <reason>.
  • It runs on its own: cancelling or finishing the run that spawned it doesn’t end it. Its tags record who spawned it.
  • It inherits the parent’s untrusted mark, reserved tags passed with --tag are dropped, and spawn chains are capped in depth.
  • A spawned script runs under bash -eu whatever its extension, with no env: and no credentials. Spawn a pipeline with a script: job when the child needs either, or another interpreter.

spool spawn reads a local file, so the script needs a checkout of the repo to spawn from it.

spool call <file|-> [--as pipeline|graph|script] [--input <json>] [--timeout <duration>]
spool call -w <name[@version]> [--input <json>]
spool call --node <kind> --config <json>

Starts another run, waits for it holding no agent, and prints its output. The file can be a graph document in JSON or YAML, a pipeline or a script, so a script can run work it decided at run time.

  • The called run goes through the same admission as a spawn.
  • It belongs to the caller: it’s listed with the calling run as its parent, and ends when the calling run ends.
  • A child that fails makes spool call exit 1. With --timeout, a wait that gives up exits 124 and the child keeps running until the caller ends.
spool event emit <type> [--key <subject>] [--payload <json>]

Publishes an event on the project bus and prints its id. Handlers subscribed to the type start runs, and waits on the type wake. A replay returns the same id and doesn’t publish again. --key sets the event’s subject.

Refused types: push, manual, schedule.tick, spawn, the run.*, spool.* and feedback.* namespaces, and types that belong to a signed webhook in the project.

From a terminal, outside any run, spool event emit publishes every time it runs.

spool state get <key> [--required] | version <key> | set <key> <value>|-
| add <key> [<value>] | incr <key> [<by>] [--op <name>] | delete <key> | list
spool state cas <key> <value> --expect <version> [--op <name>]
all take [--entity <name>]; get, version and list also take [--run <run>]

Durable state cells are named values that outlive a pass. No --entity means run scope, private to this run; --entity <name> is the project’s cross-run scope.

Subcommand Recorded Prints Exit codes
get live the value; nothing when unset 0, or 4 with --required on an unset cell
version live the version, for cas --expect; nothing when unset 0
set live nothing 0
delete live nothing 0
list live key<TAB>value<TAB>version lines in a pipe 0
add yes, by cell nothing 0 you hold it, 3 someone else does
incr yes, by --op the new value 0
cas yes, by --op nothing 0 swapped, 3 the version moved

set takes JSON or a bare string, and - reads stdin. incr and cas default --op to the key; a loop that swaps one cell several times needs a distinct --op per attempt, and reusing one in a run is refused. Durable state has the patterns.

spool artifact put <path> [--name <name>] [--cache <key>]
spool artifact get <name> [<path>] [--cache <key>] [--fallback-prefix <prefix>]
spool artifact list [--cache <key>]

put stores a file or directory and prints its digest. Without --cache the artifact belongs to this run, which is how one job hands a build to the next; with --cache it goes in the project cache. get exits 4 on a miss, so a cache lookup is plain shell, and --fallback-prefix takes the newest key with that prefix on a miss.

put and get are recorded, so a later pass gets the same digest and restores the same artifact, even on another machine where the file the first pass stored isn’t there. spool run has no artifact store; use spool dev to try them locally.

spool output set <key>=<value>|<key>:=<json>... | --values <json>

Stages values as this job’s output, committed if the script exits 0. key=value is a string and key:=json any JSON value. A pipeline publishes them with the job’s outputs:, and later jobs read steps.<job>.output.<name>:

name: canary
jobs:
canary:
outputs:
verdict: verdict
score: score
script: |
score="$(spool step metrics -- echo 0.97)"
spool output set verdict=ok score:="$score"
report:
depends_on: [canary]
steps:
- name: report
run: echo "verdict=${{ steps.canary.output.verdict }} score=${{ steps.canary.output.score }}"

On a graph, a later step reads nodes.<id>.output.<key>.

Posts a markdown note to the run page. --name lets a later call replace or --append to it. See annotations.

Clones the run’s repository at the commit that started it, into the current directory (which must be empty) or a directory you name. --depth <n> makes it shallow. See CI from a repo.

Every run carries the event that started it, as one JSON object in the file named by $SPOOL_INPUT_FILE, never truncated. It has a type (manual for a submit, schedule.tick, spawn with the parent run id, or the bus type of an event a handler received) and fields for that type. An event from a webhook carries its shaped fields (sha, ref, repo) at the top level and the forge’s raw JSON under payload.body. Scalar fields also appear as CI_TRIGGER_* variables.

The managed environments have no jq, so parse it with python3 or node:

Terminal window
branch="$(python3 -c '
import json, os
event = json.load(open(os.environ["SPOOL_INPUT_FILE"]))
print(event.get("ref", "").removeprefix("refs/heads/"))')"
echo "branch: ${branch:-none}"
Variable What it is
SPOOL_AGENT_SOCKET Socket the spool CLI talks to the agent over
SPOOL_JOB_PROTOCOL Job protocol version the agent speaks
SPOOL_AGENT_PID Agent process id
SPOOL_AGENT_ID The agent running this pass (spool agent list); may differ per pass
SPOOL_WORKFLOW_ID The run’s id
SPOOL_ACTIVITY_ID This script’s activity id, one value for the whole job
SPOOL_INPUT_FILE The run’s event, as JSON (above)
SPOOL_IDEMPOTENCY_KEY Inside a spool step body only (above)
CI_TRIGGER_* One per scalar field of the event: EVENT (the type), EVENT_ID, SHA, SHORT_SHA, REF, REPO, SENDER, FORGE, SCHEDULE, PARENT, as present
PATH, PYTHONPATH, NODE_PATH From the agent, so spool and vendored SDKs resolve

A script also gets its job’s env: and credentials. The SPOOL_* variables are applied last, so a workflow’s env: can’t shadow them.

Nothing else from the agent’s environment passes through except a fixed list: USER, LOGNAME, SHELL, TERM, LANG, LANGUAGE, LC_ALL, LC_CTYPE, TZ, TMPDIR, SSL_CERT_FILE, SSL_CERT_DIR, NIX_SSL_CERT_FILE. On an agent you run, SPOOL_ENV_PASSTHROUGH forwards more, and HOME is an empty directory of the job’s own unless the agent says otherwise (the job environment).

spool submit and spool run pick the interpreter by extension:

Extension Interpreter
.py python3
.ts bun run
.js, .mjs node
.go go run
anything else bash -eu

On spool submit, --interpreter overrides it (space-split, so --interpreter "bun run" works); spool run has no override. A graph code step sets interpreter in its config. A pipeline script: job always runs bash.

The interpreter has to exist where the script runs. Managed environments lists what Hyperspool’s environments carry; an agent you run uses whatever is on its host’s PATH; spool run and spool dev use your machine and run the file in place from the directory you submit it in.

The exit code table applies inside a job too. The ones a script branches on most:

Code From
3 state add or state cas lost
4 artifact get miss, state get --required of an unset cell
124 a wait or call with --timeout gave up
the command’s own step, lock -- <cmd>, activity run, activity wait <name>