Concepts
The mental model in nine terms. If you have a CI background most of this should feel familiar; if you’ve used a durable-execution engine before, the durability semantics will too.
Workflow
Section titled “Workflow”A workflow is a single durable execution: a deterministic function running over an event log. Cryosleep has three built-in workflow types out of the box:
cryosleep/pipeline/v1- interprets a YAML pipeline: jobs, steps anddepends_on, in a file next to your code.cryosleep/script/v1- runs a polyglot script (bash, python, whatever) under an agent’s PTY, withcryoCLI primitives for durable boundaries. Reach for it when the control flow is easier written as code than declared.cryosleep/graph/v1- runs a graph document: nodes and edges, authored on the canvas, written by hand, or computed by an earlier node and handed over inline.
These are three ways to write down work, not three tiers. Which one you spend your time in follows from what you’re building, and a project usually holds all three: CI in the repo, durable logic in a language you already use, and graphs for the wiring in between. They compose - a workflow of any kind can spawn any other as a child (see Composition) - and all three can emit events and be triggered by them.
A job is the unit of dispatch and the unit of durable boundary inside a YAML pipeline. Each job runs as exactly one activity on exactly one agent. If the agent dies mid-job, the activity fails and the job re-runs from the start on whatever agent claims it next.
Jobs come in six kinds (mutually exclusive in YAML):
steps:- a shell job. One bash script with multiple named steps, so the UI can split the log into a segment per step.approval:- a human gate. Parks on a signal in the workflow process; no agent slot held while waiting.wait:- a timer or external signal. Same - workflow-process-only.wait_for_event:- park until a matching event lands on the project bus; workflow-process-only, no agent slot held.workflow:- spawns a child workflow as the body of this job. The composition seam.script:- a durable polyglot script run inline as one activity of this run, with the fullcryotoolkit (step,sleep,wait-signal,activity-*) and suspend-and-replay.
A step is a labelled segment of bash inside a shell job. There is
no per-step durable boundary - the whole job is one activity. Step
markers exist so logs can be split visually; cross-step state goes
through bash variables and cryo output set, not through
durable storage.
Activity
Section titled “Activity”An activity is one unit of work an agent claims, runs, and reports
back on. From the runtime’s perspective it’s a (name, input) → output callable whose scheduling and completion are recorded in the
workflow’s log.
Two built-in activities:
shell_run- runs a YAML shell job’s bash script. Claimed by agents taggedshell.script_run- runs a polyglot script. Claimed by agents taggedpolyglot.
Polyglot scripts can fan out: cryo activity-submit enqueues a
child activity, cryo activity-collect suspends the parent
until the child finishes (the parent’s claim slot is freed
during the wait, so a single agent can drive both sides). These
fan-out children are tracked separately from workflow: child
runs, and the web UI surfaces them in a dedicated panel.
Atomic and durable activities
Section titled “Atomic and durable activities”Both of those are activities, so “an activity can’t do durable things” is not the rule. What separates them is whether the activity can suspend and be replayed:
- A shell step runs once, atomically. There is no way to park it half-done and resume, so a durable call inside one would have nothing to resume into - it would look like a checkpoint and behave like a plain command.
- A polyglot script can suspend. It parks server-side, frees the
agent, and re-runs from the top on wake, with completed durable calls
replaying their recorded answers. That is what makes
cryo stepmean something there.
So the durable primitives - cryo step, sleep, wait-signal,
wait-event, activity-*, spawn, call, lock - are refused in a
shell step, with an error naming the job kinds that do offer them
(wait:, approval:, script:, workflow:).
The calls that never suspend are a different category, and they work in
both: cryo annotate, cryo state (every operation), cryo output set,
cryo emit, cryo artifact, cryo status. Each asks the control plane
something and returns; nothing parks. That is why a shell step can
record durable state with cryo state set while it cannot cryo sleep -
writing a cell is a round trip, sleeping is a suspension.
An agent is a long-running process that dials out to the server over HTTP, claims activities matching its capabilities, runs them, and reports outcomes. Outbound-only: agents don’t expose any inbound HTTP. That’s the key property that makes cryosleep self-host-friendly behind firewalls and on-prem.
Agents declare capabilities at startup (e.g. shell, polyglot,
or your own custom tags); the server matches activities to agents
by capability. An agent claims one activity at a time; concurrency
comes from running more agents.
Agents are interchangeable. A script that suspends on cryo sleep or
cryo wait-signal is dispatched again on wake, and any agent with the
right capabilities can take it - not necessarily the one that ran it
before, and not necessarily on the same machine or in the same week.
What a run carries forward is its recorded values. Completed cryo step
calls replay their stdout out of the workflow log, so a step that drew a
random number, read a clock, or called an API hands the next pass the
same answer wherever it resumes. Anything outside a step stays behind:
local files, unsaved variables, the working directory. See
the authoring model.
Signal
Section titled “Signal”A signal is an external event delivered to a workflow by name and
payload. Workflows wait for signals via cryosleep::wait_signal(name)
(Rust SDK) or cryo wait-signal <name> (polyglot). YAML approval
jobs are signals under the hood.
Polyglot cryo wait-signal uses the same suspend-and-replay
primitive as cryo sleep and cryo activity-collect: the
script suspends, the activity parks server-side until the
signal arrives, and a future claim re-runs the script from the
top with the suspending call now returning the signal payload.
The agent’s claim slot is freed during the wait.
Common uses: human approval gates, external system callbacks, workflow-to-workflow coordination.
Durable state cells
Section titled “Durable state cells”A state cell is named, mutable state that survives replay - a
(scope, key) -> value document you read and write directly, distinct
from the event log (which records how a run got here). Reach for a cell
when you have state that shouldn’t be reconstructed by replaying history:
a value passed between steps, an agent’s accumulated memory, a poll
cursor.
There are two scopes:
- Run scope (the default) - state private to one run, dropped when the run is collected. A durable scratchpad for the run.
- Entity scope (
--entity <name>, orentity=in an SDK) - state that outlives any single run, addressed by the name you pass. The name is unique within your project, so two runs in the same project with the same entity name reach the same cell. This is where cross-run memory lives.
Entity scope is what makes a poll cursor work. Run this on a schedule and each tick picks up where the last one left off, because the cell outlives the run that wrote it:
since="$(cryo state get last-seen --entity digest)"latest="$(cryo step "poll" -- ./fetch-since.sh "${since:-0}")"cryo step "notify" -- ./post-digest.sh "$latest"cryo state set last-seen "$latest" --entity digestThree consecutive runs against the same project print 0, 10, 20:
each one reads what its predecessor wrote. The ${since:-0} covers the
first tick, when nothing has written the cell yet - cryo state get
exits 0 and prints nothing for an unset cell, so $since is an empty
string rather than an error, and the default keeps an empty argument out
of fetch-since.sh.
A step memo could not do this job. Memos are keyed inside one run, and
this value has to cross from one run to the next.
A cell also decides things two runs can race for. get then set has a
gap in the middle - both runs read the same absent cell and both think
they’re first - so there are three operations that don’t: add claims a
key and tells you whether you won, incr counts without losing
concurrent updates, and cas writes only if the cell is still at the
version you read. Durable state works all three
through, including the dedup ledger that makes a workflow safe to
trigger twice with the same payload.
Values are small JSON documents; a write bumps a per-cell version, and
cells are encrypted at rest with the project’s key, the same as secrets
and step memos. Reach a cell from any surface - cryo state get/set/add/cas/incr/delete/list, the SDK helpers
(state_get / stateGet / StateGet), or a canvas state node -
and they all address the same cells: a set from a shell step and a
get from a graph node in the same run see each other.
get, set and delete are plain operations, not suspend-and-replay
boundaries, so they run again on each replay pass - prefer idempotent
writes. The three race-settling ops don’t: add, cas and incr
record their answers, so a replay reads back what the first pass got
rather than claiming, swapping, or counting a second time.
Schedule
Section titled “Schedule”A schedule fires a fresh workflow at a configured cadence -
humantime intervals (1m, 5h, 1d) or cron expressions. Each
tick spawns an entirely independent workflow run. See
Schedules.
A run is one execution of a workflow - has a unique id
(<org>:<project>:run-<random>), a tagged event log, a status
(running / completed / failed / cancelled), and zero or
more child runs. Cryosleep’s runs are durable: kill the server
mid-run and the next start picks up where it left off.
A definition can also declare outputs: at the top level - named values
projected out of the finished run and handed to whoever called it, which
is how a child run returns something to its parent. Only a successful run
produces them; a failure carries no output at all. See
Composition.
Three primitives, four shapes
Section titled “Three primitives, four shapes”The same substrate covers cron-shaped recurring work, sequential durable timelines (onboarding nudges, multi-step soak tests), external-event-driven flows (webhooks, approvals), and CI pipelines:
| Shape | Primitive | Example |
|---|---|---|
| Cron | cryo schedule |
health checks, nightly reports |
| Sequential timeline | cryo step + cryo sleep |
“send email, wait 7 days, follow up” |
| External-event-driven | cryo wait-signal / approvals |
human approvals, webhook callbacks |
| CI pipeline | YAML jobs | build → test → deploy |
Composition between them is the workflow: job kind. See
Composition.
A project also has an event bus. A workflow emits events with
cryo emit, and a project can register many named handlers that each
match the event types they care about (push, run.failed, run.*).
Every root run also emits a lifecycle event (run.succeeded /
run.failed / run.cancelled) when it finishes, so a handler can react
to completions. See events and handlers.
Pipelines and graphs
Section titled “Pipelines and graphs”A pipeline and a graph are both documents you can write in YAML, and both run on the same engine. They are different document types:
| Pipeline | Graph | |
|---|---|---|
| Top level | jobs: |
version: cryosleep-graph/v1, nodes:, edges: |
| Where it lives | a file, usually in your repo next to the code | stored in the project, versioned as draft and published |
| How it starts | submitted per run: cryo submit <file>, a push, a schedule, a handler |
by name: cryo call graph <name>, a schedule, a handler, or trigger nodes it owns, registered when you publish |
| Reference | YAML pipeline reference | Authoring on the canvas |
Underneath they meet: a pipeline compiles to the same node graph the canvas
draws and runs through the same interpreter, which is why a run of either
renders the same way and why you can paste a pipeline into the read-only
preview at /pipelines and see it as a graph.
One job becomes one node, whatever its kind. steps: becomes a shell
node and script: a code node; approval:, wait: and workflow: keep
their names; wait_for_event: becomes an event node. depends_on becomes
edges, and vars: carries over unchanged. A job with matrix: is still one
node, a fan_out wrapping the body it expands.
Why there is no conversion between them
Section titled “Why there is no conversion between them”The overlap is large. Both carry vars:; a shell step can read and write
durable state with cryo state and publish events with cryo emit, a
script: job has the whole durable toolkit, and a workflow: job’s node:
form calls any connector action. So most of what a graph does is reachable
from a pipeline. What differs is the shape each format is built around, and
that is what makes a mechanical conversion lossy.
A pipeline carries an envelope with no place in a graph document:
tags:, concurrency:, throttle:, cancel_on:, and a trigger if:.
The control plane reads these when a run is admitted, before any node
exists. Tags, concurrency, throttle and cancel_on: apply however the
run started; the if: is a
predicate on a delivered event, so it gates handler-started runs and a false
one creates no run at all. A pipeline also has steps:, several named
commands in one job, which become boundaries in that job’s log rather than
nodes of their own.
A graph is a stored, versioned document rather than a file submitted per
run, so it has a draft and a published version, and it can own its own
inbound side through trigger nodes. Its structure is nodes and edges you
place directly, so switch, state, emit, fan_out and connector
actions are steps you drop in, where a pipeline reaches the same ends
through a job kind, an if:, matrix:, or a cryo call from a step. Its
edges can carry routing too: an edge out of a switch may name the branch
it belongs to (when: fast), and a target whose branch didn’t win is
skipped, so the choice lives in the edges instead of a guard on every
target. See branch routing.
So pick by what you are doing rather than by file format: work that belongs with your code and runs on a push is a pipeline; work that reacts to events, branches, or is edited on the canvas is a graph. Composition lets one call the other, which is usually better than porting either.