Workflows in your language
A durable workflow is a normal program. Any language that can run a
process can use the spool CLI verbs. A language SDK goes further: your
own functions become the durable steps. step_fn runs a function,
records its return value, and on any replay hands that value back
without running the function again.
from hyperspool import script
# charge() runs once. After a crash or a wait the script re-runs from# the top, and `receipt` comes back from the recorded result - unless the# crash lands between charging and recording, so keep step bodies safe to# retry.receipt = script.step_fn("charge", lambda: charge(order))script.sleep(24 * 3600)script.step_fn("settle", lambda: settle(receipt["id"]))What you can use today
Section titled “What you can use today”| Language | How | Where it works |
|---|---|---|
| bash | spool verbs |
everywhere the spool binary is |
| Python | the SDK, from hyperspool import script |
managed environments, where it is preinstalled |
| any language | exec spool verbs as subprocesses |
everywhere the spool binary and the interpreter are |
| TypeScript, Go, Rust | the SDKs (@hyperspool/sdk, hyperspool.dev/go, the hyperspool crate) |
not distributed yet |
The Python SDK is not on PyPI yet. It is on the import path in the
managed environments Hyperspool runs polyglot work in, so a script that
imports it works when you submit it to the service. Locally, or on an
agent you run, call the spool verbs instead; they do the same things.
The TypeScript, Go and Rust SDKs exist but aren’t published, so you can’t install them yet. This page uses Python and the CLI verbs throughout; the forms for the other three are gathered under Other languages (not published yet).
Calling the verbs from another language is one subprocess per durable call, with stdout as the recorded result. In Node, which the managed environments have:
import { execFileSync } from 'node:child_process';
const spool = (...args) => execFileSync('spool', args, { encoding: 'utf8' }).trim();
const version = spool('step', 'version', '--', 'date', '+%Y%m%d.%H%M%S');spool('sleep', '2s');spool('step', 'deploy', '--', 'echo', 'deploying', version);console.log(`released ${version}`);Read the authoring model once; it applies unchanged to every language.
Durable functions
Section titled “Durable functions”script.step_fn("charge", lambda: charge(order))The rules are the same in every SDK:
- On the first run the function executes, and its return value is
serialized as JSON and recorded under
name. - On every later replay the recorded value is returned and the function is not run.
- Only successful results are recorded. If the function raises, nothing is recorded and the failure propagates, so the step runs again on the next attempt.
- The return value must round-trip through JSON.
The code around your step_fn calls re-runs on every replay, so keep it
cheap and free of side effects: parse inputs and build clients there,
and push the real work into step_fn. There is no CLI verb for
step_fn, because recording a function’s return value needs the
function to run in your process.
step: a durable subprocess
Section titled “step: a durable subprocess”When the durable unit is a command, use step. It records the command’s
stdout and exit code.
spool step build -- make releasescript.step("build", ["make", "release"])step and step_fn share one set of names: a name is a place in the
script, and reaching it with either counts toward the same sequence of reaches.
lock: serialize a section across runs
Section titled “lock: serialize a section across runs”lock(group, body) takes a durable, project-scoped lease and runs body
while holding it, so the section runs one run at a time. On a held group
the script is waiting for the lock and holds no agent until the lease is
granted, in arrival order. A YAML job’s concurrency: takes the same
lease.
with script.lock("deploy"): deploy()The SDK lease belongs to the run: it stays held after the body returns
and passes to the next waiter when this run ends, so a second lock on
the same group in one run is refused. The CLI’s bracketed spool lock g -- <cmd> hands the lease back when its command finishes. A run waiting
for a lease reports the awaiting_lease
substate.
The body runs in your own process, so it can make durable calls, which is the read-modify-write a lock usually exists for:
with script.lock("tally"): n = script.state_get("total", entity="tally") or 0 script.state_set("total", n + 1, entity="tally")From bash, take the bare lease instead (spool lock).
A complete workflow
Section titled “A complete workflow”A preview environment that deploys, waits for the PR to close (holding no agent), then tears down:
from hyperspool import script
env = script.step_fn("deploy", lambda: deploy(host))script.wait_event("pr.closed")script.step_fn("teardown", lambda: teardown(env))Submit by extension; the interpreter is picked from it:
spool submit preview.py --followGetting the SDK
Section titled “Getting the SDK”Managed environments have the Python SDK on PYTHONPATH, with nothing to
install. That is the only place an SDK is available today. Once the
packages are published, this is where they will resolve:
- An agent you run forwards its own
PYTHONPATHandNODE_PATHinto scripts, so an SDK installed on the agent host is importable there. The scripts run from a scratch directory, not your checkout. - Locally,
spool runandspool devrun the file in place from the directory you submit it in, so an SDK resolves from your virtualenv,node_modulesorgo.modas it would if you ran the file yourself.
A run records its own copy of the script at submit, so editing the file while a run is waiting takes effect on the next submit.
The verb set
Section titled “The verb set”The Python functions map onto the spool commands one for one:
step, sleep, wait_signal, wait_event, lock, activity,
activity_submit, activity_collect, activity_wait_all, emit,
spawn, call_node, call_graph, artifact_put, artifact_get,
artifact_list, state_get, state_set, state_add, state_delete,
state_list, output_set, annotate and checkout, plus step_fn,
which has no command. Script commands
has the table of which function is which command and what each one does;
the semantics are the same from either side. The Python-specific parts:
- A wait with a deadline is
wait_signal(name, timeout=)orwait_event(type, timeout=), and returnsNonewhen it times out. A duration is seconds or atimedelta. activity_submit(…, retries=, retry_backoff=, retry_on_exit=)sets a retry policy on an activity.spawntakes'pipeline'or'script'and the document’s text; a graph document sent as'pipeline'runs as a graph.call_nodeandcall_graphrun one node kind or a stored graph. Running a document the script built isspool call <file>, on the CLI only.state incr,state casandstate versionare CLI only.
State cells take a scope. With no entity, a cell is run-scoped: private
to this run. Pass an entity name (state_set("cursor", n, entity="crawler")) for the project’s cross-run scope, state that
outlives any single run. See durable state.
The SDKs and the agent can drift apart. A bounded wait, state_add and
activity_wait_all check what the agent supports and fail at the call
site on an agent too old for them; other verbs sent to an older agent
fail with that agent’s unknown-verb error.
Failure handling
Section titled “Failure handling”The SDK sorts every outcome into one of three kinds:
-
A step failed. The command ran and exited non-zero. It’s catchable (
StepFailed) so you can branch on it, and there is an unchecked form that returns the exit code instead:check=False. -
A protocol error. A malformed or refused request - a reserved step name, a reused
activityname, a bad argument (ScriptError). It’s a bug in the script and fails the run; replaying won’t fix it.One
ScriptErroris not a bug: when a call has to wait, the agent ends the script process and answersscript suspended awaiting …, which the script can read before it is killed. A blanketexcept ScriptErroraround a waiting call can swallow that and carry on as though the wait returned. Don’t catch this kind broadly aroundsleep, a wait,lock,activity_collectoractivity_wait_all. -
The agent went away. The socket dropped mid-call (
AgentUnavailable). The run is handed to another agent and the script replays from the top. Don’t catch and continue; if you wrap a primitive in a catch-all, re-raise this one.
The SDK retries only the connect phase (a short backoff, tunable with
SPOOL_CONNECT_ATTEMPTS / SPOOL_CONNECT_BACKOFF_MS); once a request is
sent it is never re-sent, so replay is the only thing that re-runs a
step. Never retry a durable call yourself.
Reacting to an event from outside a workflow fails differently; see events and handlers.
Other languages (not published yet)
Section titled “Other languages (not published yet)”The TypeScript, Go and Rust SDKs follow the same rules as Python. These forms apply once the packages ship.
Names follow each language’s casing: Rust uses the Python names
(snake_case), TypeScript is camelCase (stepFn, waitSignal,
activitySubmit), and Go is PascalCase (StepFn, WaitSignal,
ActivitySubmit). Beyond casing, a few verbs differ:
| What it does | TypeScript | Go | Rust |
|---|---|---|---|
| A wait with a deadline | waitSignal(…, {timeoutMs}) |
WaitSignalTimeout / WaitEventTimeout |
wait_signal_timeout |
| Retry policy on a child | activitySubmit(…, { retries, retryBackoffMs, retryOnExit }) |
ActivitySubmitWith |
activity_submit_with |
Unchecked step, returning the exit code |
{ check: false } |
the *StepFailed error carries the result; errors.As gets you the exit code |
step_unchecked |
Errors use each language’s own idiom: exceptions in TypeScript, error
returns in Go, Result in Rust. A failing durable function throws, or
returns an error in Go, and nothing is recorded. The warning about a
blanket catch around a waiting call applies to a TypeScript catch and
a matched Rust Err too.
Rust’s closure cannot fail, and Result<T, E> round-trips through JSON,
so returning one records {"Err": …} as a successful result that no
replay re-runs. Return T and let a real failure panic.
A durable function:
- TypeScript:
stepFn('charge', () => charge(order)) - Go:
hyperspool.StepFn("charge", func() (Receipt, error) { … }) - Rust:
hyperspool::step_fn("charge", || charge(order))
A lock:
- TypeScript:
await lock('deploy', async () => { await deploy(); }) - Go:
hyperspool.Lock("deploy", func() error { return deploy() }) - Rust:
hyperspool::lock("deploy", || deploy())?
The preview environment from A complete workflow:
import { stepFn, waitEvent } from '@hyperspool/sdk';
const env = await stepFn('deploy', () => deploy(host));await waitEvent('pr.closed');await stepFn('teardown', () => teardown(env));env, _ := hyperspool.StepFn("deploy", func() (PreviewEnv, error) { return deploy(host)})hyperspool.WaitEvent("pr.closed")hyperspool.StepFn("teardown", func() (any, error) { return nil, teardown(env)})let env: PreviewEnv = hyperspool::step_fn("deploy", || deploy(host))?;hyperspool::wait_event("pr.closed")?;hyperspool::step_fn("teardown", || teardown(env))?;