Skip to content

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(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.

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.

Terminal window
# bash: the durable unit is a command
cryo step build -- cargo build --release
# python: shelling out on purpose
script.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(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.

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:

Terminal window
cryo submit preview.py --follow # python3
cryo submit preview.ts --follow # bun
cryo submit release.go --follow # go run

Run 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.

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:

Terminal window
# TypeScript: link sdks/typescript into your project
ln -s /path/to/cryosleep/sdks/typescript node_modules/@cryosleep/sdk
# Python: put sdks/python on the interpreter's path
export 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 python3 on the agent’s PATH. The hosted runner image vendors it and sets PYTHONPATH; on your own runner, install it into that interpreter’s environment (or set PYTHONPATH, including via the pipeline’s env:).
  • TypeScript: bun resolves @cryosleep/sdk from a node_modules it can reach by walking up from the script. On a deployed runner, run the workflow from a checkout that has the SDK installed (a steps: job doing bun run .) so that node_modules is 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 cryo binary exists.

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.

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 activity name, 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 ScriptError is not a bug: when a call has to wait, the agent kills the script process and answers script suspended awaiting …, and the script can read that answer before the signal lands. So a blanket except ScriptError (or catch, or a matched Err) around a waiting call can swallow a suspension and carry on as though the wait returned. Don’t catch this kind broadly around sleep, a wait, a lock, activityCollect or activityWaitAll; 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.