Workflows in your language
A durable workflow is a normal program. The reason to write one in Go,
Python, TypeScript, or Rust instead of bash is that your own functions
become the durable steps: stepFn runs a native function, records its
return value, and on any replay hands that value back without running
the function again. No shelling out, no parsing stdout, no marshalling
your logic into a subprocess.
import { stepFn, sleep } from '@cryosleep/sdk';
// charge() runs once on the happy path. After a crash the script re-runs// from the top, but `receipt` comes back from the recorded result instead// of charging twice - unless the crash lands between charging and// recording the result, so keep step bodies safe to retry.const receipt = await stepFn('charge', () => charge(order));await sleep(24 * 60 * 60 * 1000);await stepFn('settle', () => settle(receipt.id));receipt is a real object, typed, returned straight from your function.
Expensive, non-deterministic, or side-effecting work goes inside a
stepFn, so once it succeeds its recorded result is reused on every
replay instead of running again.
| Language | Import | Package |
|---|---|---|
| Go | import cryosleep "cryosleep.io/go" |
module cryosleep.io/go |
| Python | from cryosleep import script |
cryosleep |
| TypeScript | import { stepFn, … } from '@cryosleep/sdk' |
@cryosleep/sdk |
| Rust | cryosleep::step_fn(…) |
crate cryosleep |
| bash | cryo verbs, or the prelude’s cryo_* helpers |
none - the cryo binary |
| anything else | exec cryo step/sleep/wait-signal/wait-event/emit |
none - the cryo binary |
The bash and raw-CLI rows need only the cryo binary you already
installed. The language SDKs are a package the running interpreter has to
be able to import. Running locally against cryo dev or cryo run, that
just works: the script runs from the directory you submit it in, so bun
and go run find your node_modules / go.mod and python3 uses the
virtualenv on your PATH - the same resolution you’d get running the file
by hand. Deploying to your own agents is the case that needs a bit of
setup - see getting the SDK onto the agent.
Every SDK wraps the same socket protocol and exposes the same
primitives, so the shapes below carry across languages with only naming
differences (stepFn/step_fn/StepFn). A Go stepFn closure returns
(T, error); the other three signal failure the way their language
does, and Rust’s takes an infallible FnOnce() -> T.
Read the authoring model once; it applies unchanged to every language.
stepFn: durable native functions
Section titled “stepFn: durable native functions”stepFn(name, fn) is the primitive that makes this worth reaching for.
- Go:
cryosleep.StepFn("charge", func() (Receipt, error) { … }) - Python:
script.step_fn("charge", lambda: charge(order)) - TypeScript:
stepFn('charge', () => charge(order)) - Rust:
cryosleep::step_fn("charge", || charge(order))
The rules are the same in all four:
- On the first run the function executes, its return value is
serialized (JSON), and that value is recorded durably under
name. - On every later replay the recorded value is returned and the function is not run.
- Only successful results are memoized. If the function throws (or returns an error in Go), nothing is recorded and the failure propagates, so the step runs again on the next attempt.
- The return type must round-trip through JSON.
That last rule has a sharp edge in Rust, whose closure cannot fail:
Result<T, E> round-trips through JSON, so returning one compiles and
records {"Err": …} as a successful memo. The step is then done
forever and no replay re-runs it. Return T and let a real failure
panic, or handle the error inside the closure and record the outcome you
actually want replayed.
Because the code around your stepFn calls re-runs on every replay,
keep that part cheap and free of side effects: parse inputs, build
clients, then push the real work into stepFn. See
the authoring model for why.
bash has no native functions, so it uses the command form below
instead. Everything else stepFn gives you - run-once, durable,
typed - it gives you because the function ran in your process, not the
agent’s.
step: a durable subprocess
Section titled “step: a durable subprocess”When the durable unit is a command rather than a function - a shell
one-liner, a build tool, anything in bash - use step. It runs the
command as a durable checkpoint and records its stdout and exit code.
# bash: the durable unit is a commandcryo step build -- cargo build --release# python: shelling out on purposescript.step("build", ["cargo", "build", "--release"])step and stepFn share one memo namespace, so a name records once
either way. Reach for step when you’re driving a subprocess and
stepFn when you’re calling your own code.
lock: serialize a section across runs
Section titled “lock: serialize a section across runs”lock(group, body) holds a durable, project-scoped concurrency lease
while body runs, so that section runs one-at-a-time across runs. On a
held group the script suspends (freeing its agent, like sleep) until
it’s granted the lease, FIFO. It’s the same lease a YAML job’s
concurrency: takes, so a job and a script naming the same group in one
project contend on one lock.
- Go:
cryosleep.Lock("deploy", func() error { return deploy() }) - Python:
with script.lock("deploy"): deploy() - TypeScript:
await lock('deploy', async () => { await deploy(); }) - Rust:
cryosleep::lock("deploy", || deploy())?
The lease is scoped to the closure: it is handed to the next waiter as
soon as the body returns, not when the run ends. A run parked on a lease
reports the awaiting-lease
substate.
The body is 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")The CLI’s bracketed form can’t do that. cryo lock g -- <cmd> spawns
<cmd> without the agent socket, so a nested cryo state there fails;
from bash you take the bare lease instead and keep the section in the
script. See cryo lock.
The same workflow, three ways
Section titled “The same workflow, three ways”A preview environment that deploys, parks until the PR closes (holding no agent), then tears down. The deploy and teardown are real functions whose results persist:
Go
import cryosleep "cryosleep.io/go"
env, _ := cryosleep.StepFn("deploy", func() (PreviewEnv, error) { return deploy(host)})cryosleep.WaitEvent("pr.closed")cryosleep.StepFn("teardown", func() (any, error) { return nil, teardown(env)})Python
from cryosleep import script
env = script.step_fn("deploy", lambda: deploy(host))script.wait_event("pr.closed")script.step_fn("teardown", lambda: teardown(env))TypeScript
import { stepFn, waitEvent } from '@cryosleep/sdk';
const env = await stepFn('deploy', () => deploy(host));await waitEvent('pr.closed');await stepFn('teardown', () => teardown(env));Rust
let env: PreviewEnv = cryosleep::step_fn("deploy", || deploy(host))?;cryosleep::wait_event("pr.closed")?;cryosleep::step_fn("teardown", || teardown(env))?;Submit by extension - the interpreter is auto-detected, so you don’t
pass --as:
cryo submit preview.py --follow # python3cryo submit preview.ts --follow # buncryo submit release.go --follow # go runRun these from the project directory where you installed the SDK. A
local cryo dev / cryo run runs the file in place from that directory,
so the import resolves the same way it would in your shell.
Getting the SDK onto the agent
Section titled “Getting the SDK onto the agent”Locally there’s nothing to do. cryo dev and cryo run execute the
script from the directory you submit it in, using the file on disk, so
the interpreter resolves the SDK exactly as it would if you ran the file
yourself: bun and go run walk up to your node_modules / go.mod,
and python3 uses the virtualenv active on your PATH. Get the SDK into
your project, cryo submit yourfile, done. A run in flight keeps the
copy it recorded at submit time, so editing the file mid-run is safe -
imports still resolve, and the edit takes effect on the next submit.
None of the four packages is published yet. npm install @cryosleep/sdk, pip install cryosleep, go get cryosleep.io/go and
cryosleep = "0.1" all 404 today. Until they land, point at the copy in
this repo:
# TypeScript: link sdks/typescript into your projectln -s /path/to/cryosleep/sdks/typescript node_modules/@cryosleep/sdk
# Python: put sdks/python on the interpreter's pathexport PYTHONPATH=/path/to/cryosleep/sdks/python
# Go: a replace directive in your go.mod# require cryosleep.io/go v0.0.0# replace cryosleep.io/go => /path/to/cryosleep/sdks/go
# Rust: a path dependency in Cargo.toml# cryosleep = { path = "/path/to/cryosleep/sdks/rust" }examples/preview-env/ is a worked version of the same thing.
A deployed agent is the case that needs setup: it runs jobs in a scrubbed scratch directory (nothing ambient leaks between tenants), so the SDK has to be reachable from there.
- Python: the SDK must be importable by the
python3on the agent’s PATH. The hosted runner image vendors it and setsPYTHONPATH; on your own runner, install it into that interpreter’s environment (or setPYTHONPATH, including via the pipeline’senv:). - TypeScript:
bunresolves@cryosleep/sdkfrom anode_modulesit can reach by walking up from the script. On a deployed runner, run the workflow from a checkout that has the SDK installed (asteps:job doingbun run .) so thatnode_modulesis present. - Go / Rust: compiled. Run from a checked-out module (
go run .,cargo run) so the toolchain has its module context. - bash and raw CLI-verb scripts have no import and work everywhere
the
cryobinary exists.
The verb set
Section titled “The verb set”Every SDK exposes the same primitives as native functions you call inside the script. The spelling follows each language’s convention - Go is PascalCase, Python and Rust are snake_case, TypeScript is camelCase:
| What it does | Go | Python / Rust | TypeScript |
|---|---|---|---|
| Durable native function - records its return value | StepFn |
step_fn |
stepFn |
| Durable command - records stdout + exit code | Step |
step |
step |
| Park for a duration; agent slot freed | Sleep |
sleep |
sleep |
Park until a named signal (cryo signal <run> <name>) |
WaitSignal |
wait_signal |
waitSignal |
| Park until a matching project bus event | WaitEvent |
wait_event |
waitEvent |
| The same two, bounded - nothing arrived by the deadline | WaitSignalTimeout / WaitEventTimeout |
py wait_signal(…, timeout=); rs wait_signal_timeout(…) |
waitSignal(…, {timeoutMs}) / waitEvent(…, {timeoutMs}) |
| Fan work out: submit, then collect | ActivitySubmit / ActivityCollect |
activity_submit / activity_collect |
activitySubmit / activityCollect |
| Wait for every child you submitted; returns how many failed | ActivityWaitAll |
activity_wait_all |
activityWaitAll |
| Fan out with a retry policy, and a condition on which exits earn one | ActivitySubmitWith(…, ActivityOptions{Retries, RetryBackoff, RetryOnExit}) |
py activity_submit(…, retries=, retry_backoff=, retry_on_exit=); rs activity_submit_with(…, ActivityOptions{..}) |
activitySubmit(…, { retries, retryBackoffMs, retryOnExit }) |
| Publish an event on the project bus; memoized | Emit |
emit |
emit |
| Start a child workflow run; memoized | Spawn |
spawn |
spawn |
| Run a node-kind / stored graph as an awaited child; returns its output | CallNode / CallGraph |
call_node / call_graph |
callNode / callGraph |
| Attach a markdown note to the run page (side-channel) | Annotate |
annotate |
annotate |
| Read/write a durable state cell that survives replay | StateGet / StateSet / StateDelete / StateList |
state_get / state_set / state_delete / state_list |
stateGet / stateSet / stateDelete / stateList |
Every SDK also has a one-shot activity (Activity in Go) that submits,
waits, and collects in one call - reach for the activitySubmit /
activityCollect pair when you want to fan several out before collecting
any. When you don’t need each child’s output, activityWaitAll waits for
the ones you submitted and haven’t already collected, and tells you how
many failed; without it a script can exit while children are still
running and their results are never read. stepFn is SDK-only - there’s
no CLI verb for it, because recording a function’s return value needs the
function to run in your process, not a subprocess.
The SDKs ship separately from the agent, so the two can drift. Three
things check what the agent advertises and fail at the call site rather
than sending a request it would ignore: a bounded wait, stateAdd, and
activityWaitAll. Every other verb goes out unguarded, and against an
agent too old to know it you get that agent’s unknown-verb error rather
than a message naming the skew. That is a gap in the checks, not a
statement about which verbs are old - several unguarded ones are newer
than the guarded ones.
A bash or raw-CLI script calls the same set as cryo verbs instead -
cryo step, cryo sleep, cryo wait-signal, cryo activity-submit,
cryo emit, cryo state get/set/delete/list, … - the hyphenated
names above. Only step/stepFn differ: bash has just cryo step (a
command), since it has no native functions to memoize.
State cells take a scope. With no entity, a cell is run-scoped -
private to this run and dropped when the run is collected, useful for
carrying a value between steps without threading it through outputs.
Pass an entity name (state_set("cursor", n, entity="crawler")) for the
project’s cross-run entity scope - state that outlives any single run,
like an agent’s memory or a poll cursor. The name is scoped to your
project; the value is a JSON document and is encrypted at
rest.
Mixing levels is normal: a YAML pipeline can embed a polyglot
script: job, a script can spawn a pipeline, and both share signals,
events, approvals, and the run page.
Failure handling
Section titled “Failure handling”Each SDK sorts every outcome into one of three kinds, surfaced with the
language’s native error idiom (exceptions, error returns, Result):
-
A step failed. The command ran and exited non-zero. This is a workflow-level result you may want to branch on, so it’s catchable (
StepFailed), and suppressible with an unchecked variant that returns the exit code instead of raising. -
A protocol error. A malformed or rejected request - a reserved step name, a reused
activityname, a bad argument (ScriptError). It’s a bug in the script, deterministic across replays, so it fails the run; replaying won’t fix it.One
ScriptErroris not a bug: when a call has to wait, the agent kills the script process and answersscript suspended awaiting …, and the script can read that answer before the signal lands. So a blanketexcept ScriptError(orcatch, or a matchedErr) around a waiting call can swallow a suspension and carry on as though the wait returned. Don’t catch this kind broadly aroundsleep, a wait, alock,activityCollectoractivityWaitAll; let it end the process so the run resumes from the top. -
The agent went away. The socket dropped mid-call - the agent crashed or is shutting down (
AgentUnavailable). This is infrastructure, not your program’s state: the run’s lease expires, the control plane re-dispatches it, and the script replays from the top with already-completed steps returning their recorded results. Don’t catch and continue - if you wrap a primitive in a catch-all, re-raise this one. Swallowing it runs the rest of the script against a dead agent.
Two rules keep that last case safe. The SDK retries only the connect
phase (a bounded backoff for momentary contention during an agent
restart, tunable with CRYO_CONNECT_ATTEMPTS / CRYO_CONNECT_BACKOFF_MS);
once a request is on the wire it is never re-sent, so replay is the only
thing that re-runs a step. Never retry a dispatch yourself.
Reacting to an event from outside a workflow is a different job with a different failure story - see events and handlers.