Skip to content

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.

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 and depends_on, in a file next to your code.
  • cryosleep/script/v1 - runs a polyglot script (bash, python, whatever) under an agent’s PTY, with cryo CLI 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 full cryo toolkit (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.

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 tagged shell.
  • script_run - runs a polyglot script. Claimed by agents tagged polyglot.

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.

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 step mean 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.

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.

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>, or entity= 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:

Terminal window
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 digest

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

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.

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.

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.

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.