Skip to content

Schedules

A schedule fires a fresh workflow at a configured cadence. Each tick spawns an entirely independent run - no replay accumulation across iterations.

The naïve “loop with cryo sleep” pattern doesn’t behave the way you’d expect:

Terminal window
while true; do
healthcheck
cryo sleep "1m"
done

After every wake, the script restarts from line 1 - which means healthcheck re-runs every replay. After N iterations the script has executed healthcheck N(N+1)/2 times. (The inner cryo sleep calls fast-forward via the durable log, but plain shell commands re-execute.) Wrapping the body in cryo step check fixes the duplication mechanically but loses the “just write a normal script” ergonomic.

The right answer is a separate primitive for cron-shape work. Each tick is a fresh workflow; the user’s script is one-shot, no loop, no cryo sleep, no cryo step ceremony, and no replay accumulation can appear.

Cron-shape work is its own primitive (each tick fresh); within-execution sleep stays a separate, replay-aware primitive.

Terminal window
# Humantime interval - fires every N units after the last fire.
cryo schedule create "1m" health-probe.sh
# prints: dev:default:sch-RVXkHnlMdBf83IyG
# Cron expression - fires on the wall-clock cadence in `--tz`.
cryo schedule create --cron "0 0 9 * * MON" --tz "America/New_York" \
weekly-report.sh
# YAML pipeline instead of a script.
cryo schedule create "1d" --as yaml nightly.yaml
# A stored graph, by reference.
cryo schedule create "1d" --graph deploy --input '{"ref":"main"}'

Cadence is either a humantime interval (1m, 5h, 1d) or a 6-field cron expression (sec min hour dom mon dow). Cron accepts an optional --tz (IANA timezone name), defaulting to UTC; humantime is timezone-agnostic. Each tick submits a cryosleep/script/v1 workflow with the script body, a cryosleep/pipeline/v1 run for a YAML schedule, or a cryosleep-graph/v1 run for a graph schedule.

A graph schedule names a stored graph rather than carrying a file. A bare name resolves the graph’s published version at every tick, so republishing changes what future ticks run; deploy@3 pins one stored version for good.

If that graph declares an input_schema:, your --input is checked against it here, when you create the schedule - a stored input fires forever, and a tick at 3am has nobody to tell.

Ticks are not re-checked. That is deliberate: they would be judged against whatever version is published at the time, so republishing the graph with a new required would silently stop every schedule of it rather than only the ones somebody was about to fix. Republishing a tightened schema means auditing the schedules yourself.

Passing a graph file where the pipeline or script argument goes is refused, with a pointer to --graph. There is no inline-graph schedule: a definition on disk has no stored version for a tick to resolve, so publish it on the canvas first. The same holds for cryo handler set.

Optional flags:

  • --name <s> - human label.
  • --tag <t> - tags applied to every spawned run (in addition to the auto-applied schedule:<id> tag).
  • --as <yaml|script> - treat the file as a YAML pipeline or a polyglot script. Inferred from the extension when omitted.
  • --requires <cap> - capability the agent claiming the run must advertise. Defaults to polyglot. Script schedules only.
  • --graph <name>[@<version>] - run a stored graph instead of a file.
  • --node <kind> [--config <json>] - fire one node kind per tick, with no graph to hold it: a builtin (http, shell, code) or a <connector>.<action>. --input is the run input each tick submits.
  • --input <json> - the run input for a graph schedule, which the graph’s node expressions read as input.
Terminal window
cryo schedule list # status table, with next/last as durations
cryo schedule list --json # the stored rows, with absolute timestamps
cryo schedule show <id> # cadence, policy, and the body it runs
cryo schedule show <id> --json # the stored row, verbatim
cryo schedule run <id> # fire it once, now
cryo schedule pause <id> # stop firing ticks
cryo schedule resume <id> # resume
cryo schedule rm <id> # delete (admin)

show prints commented metadata and then the body on its own, so it pipes to a file and back through edit unchanged:

Terminal window
cryo schedule show sch-abc > update.yaml # then edit the file
cryo schedule edit sch-abc update.yaml

The runs page can be filtered by the schedule:<id> tag to see “every run this schedule has spawned”.

cryo schedule run <id> fires the body once, immediately, and prints the run id. Both schedule pages in the web UI have the same thing as Run now, which opens the run it started.

Terminal window
cryo schedule run sch-abc # prints: dev:default:run-9TkQ…
cryo schedule run sch-abc | xargs cryo logs -f

This is for the times the cadence is beside the point: you just edited the body and want to watch it work, or the thing it reconciles drifted and 3am is too far away.

It is not a tick, and three things follow from that:

  • The clock does not move. A daily schedule you run by hand at 2:55 still fires at 3am. Nothing about next_fire_at_ms changes.
  • --overlap does not apply. It never decides what a manual firing does — you are asked instead, see below — and a manual run does not count as “the previous run” for the next tick. Press Run now at 2:55 and the 3am tick still happens; under cancel_other the tick will not kill the run you just started.
  • A paused schedule still runs. Pausing stops the ticks, not the body — testing a paused schedule is a large part of why you would reach for this.

If the body has a concurrency: group and it is busy, the run waits in that queue instead of being dropped, which is the difference from a tick: a tick skips a busy group on the grounds that another is coming. The exception is policy: skip, which turns runs away rather than queueing them and does that to a manual firing too — you get “no run started” and the reason.

There is one place --overlap and a manual run do meet. Under buffer_one, a held deadline will not fire on top of a manual run that is executing — it waits for that run to finish, the same as it would for a tick’s. Without that a buffer_one schedule could end up with two runs going, which is the one thing that policy exists to stop. A manual run still waiting for a concurrency: group does not hold the deadline, because whatever is making it wait will make the released tick wait too.

Press Run now while runs of that schedule are still going and nothing starts. You are shown what is in flight, and Run alongside starts another one anyway. The CLI is the same:

Terminal window
cryo schedule run sch-abc --alongside

Without it, cryo schedule run prints what is running and exits non-zero without starting anything.

There is no “cancel the running one and start mine”. Replacing a run is a property of the body, not of one press: say it with a concurrency: group and policy: cancel-running, which applies to ticks and manual runs alike and is enforced at admission rather than by a best-effort cancel from outside.

A run waiting for a busy concurrency: group counts as already running too, so pressing again tells you rather than quietly queueing a second one.

The run carries the usual schedule:<id> tag, so it shows up in the schedule’s history with everything else, plus trigger:manual to tell it apart from a run a deadline produced. If something reads a schedule’s runs to reason about its timing, filter that tag out.

Graph and node schedules run with the --input they were created with. There is no per-firing input override.

Three answers print no run id and exit non-zero:

  • Something is subscribed to schedule.tick. Handlers own the firing the same way they own a real tick, so the schedule’s own body does not run. Their runs carry this schedule’s tag; there is no single id.
  • The start funnel turned it away — a full concurrency queue, a throttle: over its rate. The reason is printed.
  • Runs of it are already going and you did not pass --alongside. What is running is listed.

cryo schedule edit <id> takes the same flags as create and changes only what you pass. The schedule keeps its id, so the schedule:<id> tag on every run it has already spawned still resolves - which is what delete-and-recreate costs you.

Terminal window
cryo schedule edit sch-abc --cron "0 0 3 * * *" --tz Europe/Stockholm
cryo schedule edit sch-abc nightly.yaml # new body, same schedule
cryo schedule edit sch-abc --overlap buffer_one --jitter 5m
cryo schedule edit sch-abc --input '{"ref":"release"}' # graph schedules
cryo schedule edit sch-abc --name '' # an empty value clears

A cadence or jitter change re-measures the next deadline from now. Everything else leaves the clock where it is, so a body edit takes effect on the next tick without moving it. The same edits are on the schedule’s page in the web UI.

  • Cadence: humantime intervals or 6-field cron expressions (sec min hour dom mon dow) with an optional IANA timezone.
  • Overlap: --overlap, defaulting to skip. See below.
  • Timezone: UTC for humantime and for cron without --tz; otherwise the cron’s explicit --tz.

When a tick arrives and the last one is still going

Section titled “When a tick arrives and the last one is still going”

--overlap says what happens. The deadline advances either way - the policy decides what the tick does, never whether the schedule keeps ticking. It governs ticks against ticks; a run someone started with schedule run is outside it on both sides.

--overlap The arriving tick
skip (default) is dropped. A slow run costs you the ticks it overran.
allow fires anyway. Ticks overlap.
buffer_one is remembered, and fires when the running one finishes.
cancel_other cancels what’s running, then fires.
Terminal window
cryo schedule create 5m --overlap buffer_one reconcile.sh

Reach for buffer_one when a tick must not be lost - an every-5-minutes reconcile that occasionally takes six does half as much work for as long as it’s slow, and the slow stretches are when it matters. It holds one missed deadline, not a queue: a schedule persistently slower than its cadence would otherwise build a backlog it can never drain, which is what skip exists to avoid.

Reach for cancel_other when a new tick makes the old one pointless - a deploy-latest, a cache warm. Cancelling a run takes its children with it, so a superseded tick leaves no subtree behind.

allow is for ticks that are genuinely independent - a prober, a heartbeat - where skipping loses a sample rather than saving you one.

A pipeline’s own concurrency: group is a separate, later gate. Overlap decides whether the tick starts at all; if it does, the group decides what happens next, and by default that’s a queue behind the holder. A tick still waiting in that queue has no run yet, so the next tick’s overlap check can’t see it - with allow and a concurrency: group you can build a backlog that neither gate trims.

Fifty schedules written 0 * * * * all fire at the top of the hour. --jitter offsets each deadline by a random amount within the window you give it:

Terminal window
cryo schedule create --cron '0 0 * * * *' --jitter 5m warm-cache.sh

This schedule lands somewhere in the five minutes after the hour, and the next one you create with the same flag lands somewhere else in that window. The offset is drawn once per schedule and then held: every tick of a given schedule sits at the same point in the window, so an hourly schedule stays exactly hourly, it just isn’t punctual. Two consequences worth knowing:

  • The offset is stable across restarts and across server versions. A schedule that settled on +3m12s keeps firing at +3m12s.
  • It shifts every deadline by the same amount, so a cadence with slots closer together than the jitter window keeps all of them - */5 with --jitter 5m still fires twelve times an hour.

Don’t set it on a schedule whose ticks have to land on the minute. --jitter is capped at 24h.

There is no catch-up. If the instance is down across several scheduled ticks - a cryo dev you closed for the weekend, a server that was offline for an hour - those missed ticks do not fire when it comes back. Only the next future tick fires, once. A cadence of 1m that was down for an hour does not spawn 60 runs on restart; it spawns one, at the next minute boundary.

If you need every interval accounted for, don’t lean on the schedule to reconstruct the gap. Have each run record its own coverage (a watermark, a “last processed” marker) and pick up from there.

The driver advances last_fired_at_ms / next_fire_at_ms before submitting the run. A crash between advance and submit miss-fires one tick (inherent to recurring work). A crash after submit but before advance causes one duplicate fire on restart; the duplicate run completes independently.

Sometimes you do want a single durable workflow that loops forever - e.g. “monitor a queue and process incoming items indefinitely, surviving restarts.” For that, structure the loop as a polyglot script:

Terminal window
while true; do
cryo step iter -- bash -c 'process-one-item'
cryo sleep "5s"
done

Each iteration records one journal entry; the run’s durable log grows linearly. The cryo sleep between iterations is suspend-and- replay: the script re-runs from the top on wake and prior cryo step iter calls return their cached output until the loop reaches the uncached iteration. For “every minute forever” use a schedule (each tick is a fresh log). For “loop until done” with bounded iterations, the in-script loop is correct.