Skip to content

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"]))
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:

release.mjs
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.

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.

When the durable unit is a command, use step. It records the command’s stdout and exit code.

Terminal window
spool step build -- make release
script.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(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 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:

Terminal window
spool submit preview.py --follow

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 PYTHONPATH and NODE_PATH into scripts, so an SDK installed on the agent host is importable there. The scripts run from a scratch directory, not your checkout.
  • Locally, spool run and spool dev run the file in place from the directory you submit it in, so an SDK resolves from your virtualenv, node_modules or go.mod as 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 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=) or wait_event(type, timeout=), and returns None when it times out. A duration is seconds or a timedelta.
  • activity_submit(…, retries=, retry_backoff=, retry_on_exit=) sets a retry policy on an activity.
  • spawn takes 'pipeline' or 'script' and the document’s text; a graph document sent as 'pipeline' runs as a graph.
  • call_node and call_graph run one node kind or a stored graph. Running a document the script built is spool call <file>, on the CLI only.
  • state incr, state cas and state version are 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.

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 activity name, a bad argument (ScriptError). It’s a bug in the script and fails the run; replaying won’t fix it.

    One ScriptError is not a bug: when a call has to wait, the agent ends the script process and answers script suspended awaiting …, which the script can read before it is killed. A blanket except ScriptError around a waiting call can swallow that and carry on as though the wait returned. Don’t catch this kind broadly around sleep, a wait, lock, activity_collect or activity_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.

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))?;