Skip to content

Polyglot scripts

A polyglot script is a normal program (bash, python, node, a binary) that runs on an agent and uses the cryo CLI to cross durable boundaries. The “SDK” is a single CLI binary plus shell; no per-language library to install. Python and TypeScript scripts can alternatively use the SDK packages’ in-script primitives (cryosleep.script / @cryosleep/sdk’s step, sleep, …), which speak the same agent protocol over the socket directly - identical semantics, no process spawn per call.

The workflow type is cryosleep/script/v1. Two ways to invoke:

  • Inside a YAML pipeline as a workflow: job (see Composition).
  • Top-level: cryo submit --as=script ./my-script.sh submits the script as a fresh cryosleep/script/v1 workflow.

Bash scripts run under bash -eu (choosing the interpreter), so errexit and nounset are already on before your first line. Two consequences worth knowing up front: any command that fails ends the run unless you handle it (|| true, an if, a case), and reading an unset variable is a hard error, so optional env goes through ${VAR:-default}. The set -e at the top of the examples below is habit, not a requirement.

This is the rule you need to keep in your head:

Your script must call cryo step / cryo activity / cryo activity-submit / cryo wait-signal / cryo sleep / cryo activity-collect with the same names in the same order across replays of the same input. State you want preserved across calls must come back through their stdout. Anything between those calls (env reads, clocks, intermediate variables, files written outside cryo step) is non-durable, will re-execute on every replay pass, and may differ between passes - that’s fine as long as it doesn’t change which cryo-call names are emitted next, and as long as you can tolerate it running multiple times.

sleep, wait-signal, activity-collect and activity-wait-all all suspend the script and the server re-runs it from the top on resume - so “every replay pass” includes one pass per wait you go through. Wrap real side-effecting work in cryo step so it’s cached after the first pass. The server enforces the structural half: a replay that reaches a different durable call than the one recorded at that position is rejected with a clear error.

examples/replay-proof is this paragraph as a run you can count: a body that executes twice, and two steps that each execute once. Worth doing before writing a script whose steps cost money, because the run’s own log will not show you the difference - replayed output is recorded once rather than re-emitted, so a run that parked twice reads like a run that parked once.

The rule above in one concrete mistake. This script posts to an API on every replay pass:

#!/usr/bin/env bash
curl -fsS -X POST https://api.example.com/deploy # ← runs every pass
cryo wait-signal approved
cryo step notify -- ./notify.sh

Your script restarts from the top on every wait, so that curl fires once when the script first reaches the wait, and again on the pass that resumes. Two deploys, one run. Nothing warns you: the live tail shows each pass’s output, so it looks like it ran once per attempt because it did.

Put it in a step and it happens once:

Terminal window
cryo step deploy -- curl -fsS -X POST https://api.example.com/deploy
cryo wait-signal approved
cryo step notify -- ./notify.sh

The second pass replays deploy from its recorded stdout without running curl at all. Same in the SDKs, where the durable unit can be your own function rather than a subprocess:

import { stepFn, waitSignal } from '@cryosleep/sdk';
await stepFn('deploy', () => deploy()); // once, across every pass
await waitSignal('approved');
script.step("deploy", ["curl", "-fsS", "-X", "POST", DEPLOY_URL])
script.wait_signal("approved")

A step records its result whether it succeeded or not, so a failed request is remembered as a failure. Under bash -eu the non-zero exit ends the run, and replaying that run - a resume, a lease redelivery - returns the recorded failure rather than trying again. That is the point of a memo, and it means “just run it again” needs saying more precisely. Four ways, cheapest first.

Retry inside the step. The step only records once the command settles, so the retry never becomes durable state:

Terminal window
cryo step deploy -- curl -fsS --retry 5 --retry-connrefused \
-X POST https://api.example.com/deploy

Don’t fail - capture the outcome and decide. The step records a success whose stdout says what happened, and the script branches on it. Deterministic, because the recorded value is stable across passes:

Terminal window
result="$(cryo step deploy -- sh -c 'curl -fsS -X POST "$0" || echo UNREACHABLE' \
https://api.example.com/deploy)"
if [ "$result" = UNREACHABLE ]; then
cryo step page-oncall -- ./page.sh
fi

A policy retry (retry: on the job, --retry at submit) re-runs the whole script for real: between attempts the server purges that activity’s recorded steps and releases its signal claims, so nothing replays. That is what you want when the failure was environmental. It redoes everything, not just the step that failed - earlier steps run again too.

cryo rerun --from <step> after the fact, when a person has looked at it. A fresh run keeps the steps before that point and re-runs from there. See rerunning from a point for what that does and does not promise.

The choice is mostly about who notices. Transient network failure: retry inside the step. A dependency that might legitimately be down: capture and branch. A bad deploy that needs a human: rerun from the step once they have fixed it.

Durable boundary. On first run it executes <cmd>, records the stdout and exit code under the step name, echoes that stdout verbatim, and exits with that code. A replay returns the cached output and exit code without re-running the command.

Terminal window
result="$(cryo step "fetch-prices" -- curl -fsS https://api/prices)"
cryo step "publish" -- post-prices "$result"

A step name is a program point, so reaching it again is an ordinary loop: the name is paired with an ordinal counting how many times this run has reached it, and each iteration records its own result. Three passes through cryo step process are three steps, and all three run.

Terminal window
for host in web-1 web-2 web-3; do
cryo step process --label "$host" -- ./drain.sh "$host"
done

--label is free text for a reader - it shows up in cryo steps and the run view beside the ordinal, and it never affects which memo a call resolves to. Put the loop variable there rather than in the name.

Mutating step names between replays is a determinism violation.

A step’s output is capped at 2 MiB, and a step that goes over is refused with a message saying how far over it went. The output is memoized, so it is stored in the run and handed back on every replay pass rather than transferred once - a step that returns a build artifact or a full dataset makes every later pass carry it. Write large output to a file, or to an artifact with cryo artifact put, and record the path:

Terminal window
cryo step "render" -- bash -c 'render > /tmp/out.bin && echo /tmp/out.bin'

A step body runs without $CRYO_AGENT_SOCKET, so <cmd> can’t call cryo itself - a nested cryo step or cryo sleep exits 1 with CRYO_AGENT_SOCKET is not set. Durable calls belong in the script, between steps, where the log can sequence them; a step is the leaf.

The one CRYO_* a step body does get is $CRYO_IDEMPOTENCY_KEY: a token for this step of this job of this run, to hand to a remote API that dedupes on one.

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

Note the single quotes: the variable only exists inside the body, so writing it unquoted on the cryo step line expands it in the script’s own shell, where it is not set - under set -u the script aborts with CRYO_IDEMPOTENCY_KEY: unbound variable, and without it the call goes out with no key at all. The script’s own values, like $account here, come in as arguments; the body sees a sealed env.

A step that dies after acting but before its result is recorded runs again, and so does one whose agent was lost. Both send the same token, so the remote system recognises the second call as the first. Two steps get two tokens, and a rerun of the run gets new ones. See the rules for the graph-side equivalent.

In the Go, Python, TypeScript, and Rust SDKs, stepFn is the same durable boundary around a native function rather than a subprocess - it records the function’s return value, so you call your own code instead of shelling out and parsing stdout. See workflows in your language.

Wait for a wall-clock duration; the agent slot is freed during the sleep (the activity is parked server-side and re-dispatched on wake). Humantime: 5s, 1m, 2h, 30d.

Terminal window
user="$(cryo step "lookup" -- fetch-user "$EMAIL")"
cryo step "send-welcome" -- send-email "$EMAIL" welcome "$user"
cryo sleep "7d"
cryo step "send-followup" -- send-email "$EMAIL" followup "$user"

On wake the script restarts from the top - cryo step calls above the sleep return their cached outputs immediately, so the script “resumes” at the next uncached call. Plain shell commands between cryo step calls re-execute on every replay; wrap side-effecting work in cryo step if that matters.

That restart is a fresh process, and the wake-up is dispatched to whichever agent is free, so it may well be a different machine a week later. $user is still there on the far side because lookup replays what it recorded - a value is durable exactly when it came out of a step’s stdout. send-welcome doesn’t send a second email either, for the same reason.

Suspend until a signal of name is delivered. Returns the signal’s payload as JSON on stdout.

Terminal window
payload="$(cryo wait-signal "approved")"
echo "got approval: $payload"

Mechanism (same suspend-and-replay primitive as cryo sleep): on the first call the signal isn’t there yet, so the script suspends and parks server-side until it arrives - cryo signal <run-id> <name> delivers it and wakes the run. A future claim re-runs the script from the top; this same call now finds the delivered signal and returns it. The parent’s claim slot is freed during the wait.

Without --timeout the wait has no upper bound. With one it gives up after that long and exits 124, the timeout(1) convention, so bash branches on it without parsing anything:

Terminal window
if payload="$(cryo wait-signal "approved" --timeout 7d)"; then
cryo step "ship" -- ./deploy.sh
else
cryo step "expire" -- ./close-out.sh
fi

cryo wait-event takes the same flag.

Two things to know. Errexit is on by default, so a bare bounded wait ends the run on timeout, which is rarely what you want - put it in an if, or append || true. And the outcome is pinned the first time it resolves: a wait that took an arrival returns that same one on every later pass, and a wait with no arrival and a recorded timeout keeps the timeout - so a signal landing a moment after the deadline does not change a branch the script already took.

A name is a queue, so each wait on it takes the next signal that arrived. A loop can wait on one name every time round:

Terminal window
while :; do
verdict="$(cryo wait-signal "approve")"
[ "$verdict" = '"stop"' ] && break
cryo step apply -- ./apply.sh "$verdict"
done

Three cryo signal <run-id> approve calls feed three turns of that loop, in the order they arrived. Signals sent while nothing is waiting queue up rather than being dropped, so a sender that runs ahead of the script does not lose them.

That also means the sender never has to know where the script is. It addresses the run and the name, and the script’s own position decides which arrival it gets. If a particular turn needs a particular answer, put the discriminator in the payload - the sender can see that, and cannot see a loop counter.

Two jobs in one run waiting on the same name draw from the same queue, one signal each. cryo wait-event works the same way: waiting on one event type repeatedly hands you successive events of that type.

From an SDK the same wait returns your language’s “nothing arrived” value instead of an exit code:

if script.wait_signal("approved", timeout=timedelta(days=7)) is None:
script.step("expire", ["./close-out.sh"])

waitSignal(name, { timeoutMs }) resolves null in TypeScript, WaitSignalTimeout(name, d) returns ok=false in Go, and wait_signal_timeout(name, d) returns None in Rust. All four need an agent speaking socket protocol v2; an older one is refused at the call site rather than quietly dropping the deadline and waiting forever.

Suspend until an event of type lands on the project bus (emitted by another run via cryo emit, or forwarded from a forge webhook). Returns the event envelope as JSON on stdout.

Terminal window
event="$(cryo wait-event "deploy.approved")"
echo "unblocked by: $event"

Sugar over wait-signal: the call registers a one-shot event-to-signal bridge server-side, then waits on a signal of its own, named for the event type and the job doing the waiting. Everything about the signal wait applies - suspend-and-replay and the freed claim slot.

Every waiting job gets its own wait, so any number of jobs, runs and graph nodes can wait for one event type and all of them are handed the event when it arrives. A job that starts waiting after an earlier event of that type already fired waits for the next one; it never inherits an event that arrived before it.

Waiting twice on the same type in one script hands you two events - the type’s signal is a queue like any other, so the second wait takes the next one. The bridge is registered again on the pass that reaches that second wait.

event: is the engine’s namespace, shared with a pipeline’s wait_for_event: jobs, which park on event:<node> - the node the job compiles to, so a ship-it: job parks on event:ship_it (see the YAML reference). To unblock one of those by hand, cryo signal <run-id> event:<node>. A script’s wait has a longer name of its own - read it off cryo status <run-id>, which reports the name under substate.name for the run’s most recent parked activity.

For the same reason cryo wait-signal will not take a name starting with event:. Pick a name of your own, or use cryo wait-event for the event you meant.

Serialize a critical section across runs. Acquire a durable, project-scoped lease on group; while another run holds it, this one waits.

What a lock protects is the read-modify-write, where two runs interleaving lose an update:

Terminal window
cryo lock tally
n="$(cryo state get total --entity tally)"
cryo state set total "$(( ${n:-0} + 1 ))" --entity tally
echo "wrote $(( ${n:-0} + 1 ))"

Submit three of those at once against two agents and they print 1, 2, 3. Delete the cryo lock line and they print 1, 1, 2: two runs read 0, both write 1, and one increment is gone. Nothing errors

  • the tally is just wrong afterwards, which is why the lock goes in before you can measure the problem.

Keep the read and the write in one pass. State operations are not checkpoints, so they re-execute on every replay; the shape above is safe because the only suspension is the lock itself, which happens before the read. Put a cryo sleep between the get and the set and the resumed pass increments a second time, lock or no lock.

A bracketed command cannot call cryo. The bracketed form records <cmd> as a step, which is what makes it run once however many times the script replays. Being a step, it gets a step’s rule: it runs without $CRYO_AGENT_SOCKET, so a nested cryo state exits 1 with CRYO_AGENT_SOCKET is not set. And since the inner shell usually isn’t under errexit, the section carries on and writes nothing.

Bracket external commands; take the bare lease when the critical section is itself cryo work, as above.

Terminal window
./build.sh # runs concurrently across runs
cryo lock deploy -- ./deploy.sh # waits for the lease, then deploys

A bare cryo lock <group> (no -- <cmd>) acquires and returns, holding the lease for the rest of the run.

Mechanism: the same suspend-and-replay primitive as wait-signal and sleep. On a held group the script suspends and its agent slot is freed; the control plane grants the lease FIFO when the current holder finishes and wakes the run, which replays and proceeds into <cmd>. While parked the run reports the awaiting_lease substate.

It’s the same lease a YAML job’s concurrency: takes: a script’s cryo lock deploy and a job’s concurrency: { group: deploy } in the same project contend on one lock, so whichever gets there first runs and the other waits.

The lease is scoped to the bracketed command: it is handed back as soon as <cmd> finishes, so the next waiter starts then rather than when your run ends. A bare cryo lock GROUP (no command) has no closure to scope to and holds until the run is terminal (released on run terminal, then handed to the next waiter). That fits a deploy at the tail of a run; a mid-run release verb is future work.

Dispatch a cryosleep-registered activity to a different agent matching its requirements. Synchronous (blocks until complete), and the child’s stdout comes back on yours:

Terminal window
# runs on a machine tagged arm64-mac; this script may be on Linux
sha="$(cryo activity "build-mac" --requires arm64-mac \
-- bash -c 'cd /srv/repo && ./build.sh --print-sha')"
cryo step "record" -- ./publish-manifest.sh "$sha"

--requires is how the work gets to hardware this script isn’t running on. That machine is a different filesystem: the command is evaluated there, so a relative path resolves against its working directory, not this script’s. Send absolute paths, or cd first as above.

cryo activity-submit / cryo activity-collect

Section titled “cryo activity-submit / cryo activity-collect”

Fan-out primitive: submit many activities up front, collect results later. activity-submit returns immediately once the child is enqueued; activity-collect suspends the parent until the child terminates.

Terminal window
# every submit goes out before the first collect - that's what makes
# the shards overlap instead of running one at a time
for shard in 1 2 3; do
cryo activity-submit "test-$shard" --requires shell \
-- bash -c "cd /srv/repo && ./run-tests.sh --shard $shard --count"
done
passed=0
for shard in 1 2 3; do
passed=$(( passed + $(cryo activity-collect "test-$shard") ))
done
echo "$passed tests passed across 3 shards"

Each child returns its own count and the parent adds them up, so the total is only right if all three ran and each was collected separately. Submitting inside the first loop and collecting in the second is the part that matters: interleaving them (submit; collect; submit; collect) serializes the shards and throws the parallelism away.

A failed child fails the parent. activity-collect exits with the child’s exit code, and with errexit on by default that ends the run at the collect - which is usually what you want for a test shard. To decide for yourself, catch it: out="$(cryo activity-collect "test-1" || echo skipped)".

A child you never collect is a different matter: nothing reads its result, so a failure among those children leaves the run green and says nothing. A script can also exit while they are still running, and then the run reaches a terminal state before they report at all. When you don’t need each child’s output, wait for them as a group:

Terminal window
for shard in $(seq 8); do
cryo activity-submit "test-$shard" --requires shell -- run-shard "$shard"
done
failed="$(cryo activity-wait-all)"
[ "$failed" = 0 ] || { echo "$failed shard(s) failed"; exit 1; }

Note run-shard, not ./run-shard.sh: a child runs on whichever agent claims it, in that agent’s own working directory rather than the script’s, so a relative path resolves against somewhere you didn’t choose. Name a program on the agent’s PATH, or an absolute path.

activity-wait-all suspends the same way collect does - once per child it actually has to wait for, which is usually far fewer than one per child - and prints how many of them failed. It exits 0 whatever that number is, so the decision above stays yours.

It covers the children this script submitted and has not already collected. A child you collected and handled yourself doesn’t get counted a second time, and a script in a different script: job can’t wait on this one’s children - each job’s children are its own. A run that succeeds with children still unreported gets an annotation on its page saying so.

activity-collect uses the same suspend-and-replay primitive as wait-signal and sleep: if the child hasn’t finished yet the parent suspends. Crucially, the parent’s agent slot is freed during the wait - a single-agent deployment can drain the children the parent is waiting on without head-of-line blocking. (No local bash wait / & backgrounding needed; the suspend-and- replay model handles that for you.)

Polyglot fan-out children are tracked separately from workflow: child runs - they don’t nest in the parent’s event log; instead the web UI surfaces them in a dedicated “Activities (fan-out)” panel with status, exit code, and captured stdout per child.

The loop above scales as written: swap 1 2 3 for $(seq 100) and one script drives a hundred children across every agent that will claim them, on one suspend-and-replay collect each. Names have to stay distinct, which is what test-$shard is doing.

cryo spawn <file> [--as pipeline|script] [--tag <t>]...

Section titled “cryo spawn <file> [--as pipeline|script] [--tag <t>]...”

Submit a child run (a YAML pipeline or another polyglot script) from inside a script, and print its run id. - reads the source from stdin. The child runs independently (fire-and-forget); it’s a separate run, not a fan-out activity.

Terminal window
# a router script that decides what to run, then submits it
child="$(cryo spawn .cryo/deploy.yaml --tag from-dispatch)"
echo "spawned $child"
cryo sleep 30s
echo "still watching $child"

This is the primitive that makes a script an orchestrator: inspect inputs, then spawn the workflows you choose.

Each spawn is checkpointed by its position in the script, so the sleep above is safe: the replay pass prints the same id it printed the first time and no second child is submitted.

spawned dev:default:run-14f9593fa81a407d
cryo: script suspended for 30000ms; agent re-dispatch on wakeup
- replaying from checkpoint (completed steps are cached, not re-run)
spawned dev:default:run-14f9593fa81a407d
still watching dev:default:run-14f9593fa81a407d

That is worth checking rather than assuming, because a spawn that re-ran would deploy twice and the second run id would be the only evidence. Position is the key here, not a name - spawn takes none, so moving a spawn across a suspend point changes which memo it lands on. Like the other durable primitives it only works inside a polyglot script run, not a YAML shell step.

cryo call node <kind> / cryo call graph <name> / --definition <file|->

Section titled “cryo call node <kind> / cryo call graph <name> / --definition <file|->”

Submit a child run and wait for it. The script parks (suspend-and-replay, so the agent slot is freed), and when the child terminates its output is printed on stdout - capture it with $(...):

Terminal window
status="$(cryo call node http --config '{"url":"https://api.example/ping"}')"
report="$(cryo call graph nightly-report --input '{"day":"mon"}')"
# a graph the script just computed
./plan.py > plan.json
cryo call graph --definition plan.json

--definition takes an inline cryosleep-graph/v1 document (- reads it from stdin), so a script can run work it decided at run time without publishing a graph first. cryo spawn, by contrast, returns a run id straight away and never yields the child’s result.

Publish a custom event to the project’s event bus and print its event id. If a project handler matches the type, the emit triggers a handler run - and a handler’s own custom emits chain on to the next handler, so scripts can hand work down a routing chain. Durable and replay-safe - each emit is checkpointed by its position, so a replay returns the same event id instead of emitting a duplicate.

Some types are reserved and rejected: push, manual, schedule.tick, spawn, and the platform-emitted run.* / cryo.* namespaces.

Terminal window
cryo emit deploy.finished --payload '{"env":"prod","sha":"'"$SHA"'"}'

cryo state get|set|delete|list [--entity <name>]

Section titled “cryo state get|set|delete|list [--entity <name>]”

Read and write durable state cells - named, mutable state that survives replay. No --entity = run scope (private to this run); --entity <name> = the project’s cross-run entity scope. get prints the value (empty if unset); set takes JSON (or a bare string) and reads stdin on -; list prints key<TAB>value lines.

Unlike the durable primitives above, state is a plain read/write, not a suspend-and-replay boundary - it runs on every replay pass, so prefer last-write-wins or idempotent writes.

Terminal window
cursor="$(cryo state get cursor --entity crawler)"
process-from "${cursor:-0}"
cryo state set cursor "$new_offset" --entity crawler

cryo annotate works from a script too - it attaches a markdown note to the run without creating a durable boundary. See the annotations guide.

Every run carries an event describing what started it, written to a file named by $CRYO_INPUT_FILE - a JSON envelope with a type and type-specific fields, complete whatever its size. A push is {"type":"push","forge":…,"ref":…,"sha":…,"repo":…,"sender":…}; scheduler, manual, and custom triggers add their own type on the same shape. The same facts are also present as CI_TRIGGER_* vars (see env below).

Because any workflow can read the event and start more workflows with cryo spawn, routing is just a workflow that spawns. A push runs one entrypoint pipeline; a job in it reads the event and picks what to run. No separate “dispatcher” concept - it’s the same cryo spawn primitive.

This router job reads that file with jq, picks workflows by branch, and spawns them:

#!/usr/bin/env bash
set -euo pipefail
branch="$(jq -r '.ref | sub("refs/heads/"; "")' "$CRYO_INPUT_FILE")"
echo "routing branch=$branch"
case "$branch" in
main)
cryo spawn .cryo/ci.yaml --tag routed
cryo spawn .cryo/deploy.yaml --tag routed
;;
release/*)
cryo spawn .cryo/ci.yaml --tag routed
cryo spawn .cryo/release.yaml --tag routed
;;
*)
cryo spawn .cryo/ci.yaml --tag routed
;;
esac

cryo spawn is memoized (see above), so if the router suspends and replays it won’t submit duplicate children. The spawned runs are independent - the router doesn’t wait on them. To collect results instead, use cryo activity-submit / cryo activity-collect.

cryo spawn <file> reads a local file, so a router that spawns repo-tracked pipelines needs those files on the agent: check out the repo first (the job has $CI_TRIGGER_SHA), the same clone a normal CI job does. Every push is durable regardless - the entrypoint run is created when the push arrives and waits for an agent if none is free, so nothing is dropped between delivery and execution.

The script stages structured output with cryo output set. The keys are merged onto the child workflow’s final output alongside exit_code and duration_ms. They’re committed only if the script exits successfully.

Terminal window
cryo step "metrics" -- bash -c 'echo "checking metrics"'
cryo output set verdict=ok score=0.97

A YAML pipeline that spawns this as a workflow: job can pick those keys via its outputs: mapping:

canary:
outputs:
verdict: verdict
score: score
workflow:
type: cryosleep/script/v1
script: { ... }

Each script body sees:

  • $CRYO_AGENT_SOCKET - path to the IPC socket the cryo CLI uses to talk to the server through this agent.
  • $CRYO_AGENT_PID - agent process pid.
  • $CRYO_AGENT_ID - the id of the agent running this body, the same one cryo agents ls lists. Scoped to this execution on purpose: a run that suspends is re-dispatched to whichever agent is free, so a script that slept twice may have run on three agents and no single value describes the run. Read it where you need it and record it in a step if it has to survive. Shell jobs in a YAML pipeline get it too.
  • $CRYO_WORKFLOW_ID - the parent workflow’s id.
  • $CRYO_ACTIVITY_ID - this script_run activity’s id.
  • $PATH from the agent process (so the cryo CLI is reachable).

On top of those, a script run gets whatever env its workflow supplies, plus the context of whatever started it - a webhook-dispatched script (above) gets the push, a manual one gets CI_TRIGGER_EVENT=manual:

  • CI_TRIGGER_* - one per scalar field on the run’s event: EVENT (the type), EVENT_ID (the bus id, on a delivery), SHA, SHORT_SHA, REF, REPO, SENDER, FORGE, SCHEDULE, PARENT. Which ones appear depends on the event; a field that isn’t a scalar (paths, payload) has no flat form and stays in the file.
  • $CRYO_INPUT_FILE - a file holding what started the run, as one JSON object with a type: push (webhook, plus forge/ref/sha/repo/sender/paths), manual (a cryo submit), schedule.tick (plus the schedule id), or spawn (plus the parent run id). Every run with an input carries one, and it is never truncated - a forwarded forge body arrives whole.

The cryo-internal vars (CRYO_AGENT_SOCKET and the others above) are applied last and always win, so a workflow’s env can’t shadow them.

The ambient agent environment does not pass through. Scripts run with a sealed env: besides the vars above, only a fixed allowlist of standard vars is forwarded from the agent process (HOME, USER, LOGNAME, SHELL, TERM, LANG/LANGUAGE/LC_*, TZ, TMPDIR, and the SSL_CERT_FILE/SSL_CERT_DIR/NIX_SSL_CERT_FILE cert paths). Any other env you need, set it inside the script or supply it via the workflow’s env.

The interpreter defaults to bash -eu. cryo submit picks it up from the file extension - .pypython3, .tsbun run, .js/.mjsnode, .gogo run - and --interpreter overrides (space-split, so --interpreter "bun run" works):

Terminal window
cryo submit deploy.py --follow # python3, auto-detected
cryo submit deploy.ts --interpreter "deno run" # explicit override

The runtime has to be installed where the script runs. The interpreter is invoked on the machine running the agent, so a .py workflow needs python3 on that machine’s PATH, .ts needs bun, .go needs the Go toolchain, and so on. Running locally with cryo run / cryo dev, that machine is yours, so cryo run probe.py fails if you don’t have python3. On a deployed agent the requirement is the agent host’s PATH (see deploying agents). Only bash and raw cryo-verb scripts have no extra runtime beyond the cryo binary itself.

A runnable local example - install the CLI plus python3, then:

probe.py
from cryosleep import script
# `step` runs a command as a durable checkpoint; on replay it
# returns the recorded stdout instead of re-running.
reading = script.step(
"probe", ["python3", "-c", "import random; print(random.randint(0, 99))"]
)
print(f"probe read {reading.stdout.strip()}")
# Seconds or a timedelta, not the CLI's "5s" humantime string.
script.sleep(5)
# Same reading: the second pass replayed the memo rather than
# sampling again.
print(f"still {reading.stdout.strip()} after the sleep")
Terminal window
cryo run ./probe.py

from cryosleep import script is the Python SDK - a script that imports it needs the SDK importable by the python3 that runs it (see workflows in your language). A script that only shells out through the cryo CLI (cryo step …, cryo sleep …) has no import at all and runs anywhere the cryo binary and the interpreter both exist.

Local cryo dev / cryo run execute the file in place from the directory you submit it in, so bun, go run, and python3 resolve the SDK from your node_modules / go.mod / virtualenv with no extra setup - cryo submit release.go just works from your module. Editing that file while a run is parked won’t change what the parked run does: it recorded its own copy at submit time and replays that, so the edit lands on your next cryo submit. A deployed agent instead runs the script from a scrubbed scratch dir, so there a Go or TypeScript workflow that imports the SDK needs its module context: run it from a checked-out repo (a steps: job doing go run . / bun run .). See getting the SDK onto the agent.

  • The agent dying mid-script: the activity is re-dispatched; some other agent claims it; the script restarts from the top, with every cryo step / cryo sleep / cryo wait-signal / cryo activity-collect returning its cached result until reaching the point of the original death.
  • The server dying mid-script: the script keeps running on the agent. When the server restarts, the agent’s pending cryo step / completion calls land normally.
  • A cryo sleep / wait-signal / activity-collect straddling either restart: the suspended activity stays parked; on server start the eligible agent resumes it at wake / signal / completion.

cryo rerun <run-id> --from <site> starts a fresh run at one of your cryo step sites, reusing what the original recorded before it. The build was fine, the deploy flaked, don’t rebuild:

Terminal window
cryo rerun dev:default:run-3EMw --from deploy

The new run’s build returns the bytes the original captured. deploy and everything after it actually run.

It promises less than the same flag on a pipeline or a graph. There, the upstream nodes do not execute. Here, the calls before your point don’t re-perform, but the script around them runs again from line one, the same way it does after a cryo wait-signal. So:

Terminal window
curl -X POST https://example.com/announce # fires again
cryo step deploy -- ./deploy.sh # returns its recorded result

A script that already survives a wait is safe here by construction, for the same reason it survives the wait. If yours has never suspended, this is the first time that discipline is tested: wrap side effects in cryo step and they happen once.

A loop reaches one site many times, so a bare name means the first time - which drops every iteration of it - and --from check#2 starts at the third, keeping the two before it.

If the prefix no longer reproduces (the world changed and your script takes a different branch), the run fails loudly on the determinism guard rather than doing something subtly wrong.

Replay paths return cached cryo step / cryo sleep / cryo activity-collect output to your script’s $(...) capture, but don’t re-emit those bytes to the live log sink: logs record each execution attempt once, which keeps the live tail readable on long-running workflows. The web UI shows only the latest pass’s tail, trimming at the start of each script re-run. Per-step terminal panels in the event stream and the “Activities (fan-out)” panel keep prior-pass output durably available.

Plain shell between cryo calls (e.g. a bare printf) re-runs on every pass and will appear once per pass in the live tail. Wrap it in cryo step if you only want to see it once.

To see kill-9 durability for yourself, run cryo init and follow the crash test in getting started.