Skip to content

Events and handlers

A project has an event bus. A workflow emits an event; handlers react to it. A handler is a workflow you register to run when an event of a given type fires - so routing logic lives in a workflow (a script, a pipeline, or a canvas graph) instead of a fixed subscription table. A project can have many handlers, each matching the event types it cares about.

An event is a project-scoped record with three parts:

  • type - a discriminator string (e.g. deploy-done, run.failed).
  • key - its subject: the thing the event is about (a repo, a PR, a deploy). Optional; a subjectless event is a broadcast. See Subjects.
  • payload - arbitrary JSON.

Recording an event is append-only, so the bus reads like a log. Both the platform and your workflows emit onto the same bus.

Any workflow can emit, in any language: cryo emit from a shell step or a polyglot script, emit() from the language SDKs, or an emit node on a canvas graph.

Each emit is checkpointed by its position, so re-running the code that emitted it - a script replaying after a suspend, a step re-dispatched after its agent dropped it, a retry: attempt - resolves to the same event rather than publishing a second one. Note what that means for a retry: the event the first attempt recorded is the one that stands, payload and all, so a later attempt emitting the same position with a different payload does not replace it.

Position is what makes this work, so an emit needs to sit at the same point in the code each time. In a polyglot script the surrounding cryo step memos hold the control flow steady. A YAML shell step has no such memos and re-runs from the top against the live world, so keep its emits at a fixed place in the script rather than inside a loop whose length can change between attempts.

Terminal window
cryo emit deploy-done --payload '{"env":"prod","sha":"abc123"}'
cryo emit deploy-done --key acme/web --payload '{"env":"prod"}'
cryo emit cache-warm # payload defaults to JSON null

--key sets the event’s subject (see Subjects). Omit it and the event is a broadcast - it reaches every reaction watching that type. cryo emit also works from a terminal, where it posts to the server - the way to unblock a wait_for_event: job or trip a cancel_on: by hand.

--payload must be valid JSON. The type is validated: 1-64 characters of [A-Za-z0-9._-]. Some types are reserved for the platform and rejected: push, manual, schedule.tick, spawn name built-in trigger envelopes, and the run.* and cryo.* namespaces are platform-emitted (see lifecycle events). Reserving them means a handler that trusts run.failed to mean a real run failed can’t be fooled by a forged emit.

To see recent events in a project, and one event’s full payload:

Terminal window
cryo events ls
# deploy-done dev:default:evt-... by dev:default:run-... {"env":"prod"}
cryo events show dev:default:evt-1a2b3c4d5e6f7a8b

An event is broadcast. Every reaction watching its type is notified by the same event, and they do not compete for it - one emit wakes all of them, each with the whole envelope. The four ways to listen can be waiting on one type at the same moment, in one project, and all four fire:

# waiter.yaml - a pipeline parks a job on it
name: yaml-waiter
jobs:
hold:
wait_for_event:
event: order.placed
when: ${{ event.payload.qty > 10 }}
ship:
depends_on: [hold]
steps:
- name: ship
run: echo "ship ${{ steps.hold.output.payload.sku }}"
Terminal window
# waiter.sh - a polyglot script suspends on it, and has no predicate:
# `wait-event` returns the first order of any size, and the script
# decides afterwards, by which point that wait is spent.
set -e
ev="$(cryo wait-event order.placed)"
[ "$(echo "$ev" | jq -r .payload.qty)" -gt 10 ] || exit 0
cryo step "ship" -- ship-it "$(echo "$ev" | jq -r .payload.sku)"
// waiter-graph.json - a graph node parks on it
{
"version": "cryosleep-graph/v1",
"nodes": [
{"id": "hold", "type": "event",
"config": {"event": "order.placed", "when": "event.payload.qty > 10"}},
{"id": "ship", "type": "code",
"config": {"script": "echo \"ship ${SKU}\"",
"env": {"SKU": {"$expr": "nodes.hold.output.payload.sku"}}}}
],
"edges": [{"from": "hold", "to": "ship"}]
}
Terminal window
# a handler starts a fresh run per event, and filters before it does
cryo handler set big-orders ./handler.sh \
--on order.placed --when 'event.payload.qty > 10'

Submit the first three, register the handler, then emit once:

Terminal window
cryo emit order.placed --payload '{"sku":"TENT-2P","qty":12}'

All four react to that single event: the parked pipeline job resumes, the script’s wait-event returns, the graph’s event node completes, and the handler starts a new run. Send a small order first and only the script reacts - the two predicates decline it and keep waiting, and the handler never starts.

Three listeners on one event: a small order is declined by the two
that carry a when: and taken by the script that does not, then a large
one releases the rest

Each context hands you the whole envelope; they differ in where you read it from and where a predicate can go.

Waits with Reads the event as Filters with
YAML pipeline a wait_for_event: job ${{ steps.<job>.output.payload.… }} when: on the wait
Graph an event node {"$expr": "nodes.<id>.output.payload.…"} when: on the node
Polyglot script cryo wait-event <type> the envelope on stdout whatever the language has (jq, a case)
Handler cryo handler set --on <type> $CRYO_INPUT_FILE, or input.payload.… --when '<CEL>'

Where the predicate runs is the thing to get right. A handler’s --when runs before anything starts, so a non-matching event creates no run and costs nothing. A wait’s when: runs against a run that is already parked: an event it declines leaves the wait in place, so the run keeps waiting for one it wants (below). A script has no predicate at the wait - cryo wait-event returns the next event of its type and the script decides afterwards. An event it doesn’t want is consumed either way, though it can wait again for the one after it.

Put the test in a handler when most events are not for you and each one that is should start fresh work. Put it on the wait when a run is already halfway through and waiting for its turn.

Once a run has the event, an if: on a downstream job or node is how you branch on what it contained - ${{ steps.<job>.output.payload.… }} in a pipeline, nodes.<id>.output.payload.… in a graph. That is a different question from when:, which decides whether the wait ends at all.

All of it. The payload reaches a listener whole - a script’s cryo wait-event prints the entire envelope, and $CRYO_INPUT_FILE is never truncated however large the payload.

The bound is on the way in: an emit is an HTTP request body, capped at 2 MiB. A larger one is refused at the door with 413, so no listener ever sees a half-event. Platform run.* events cap their error and reason text at 4000 characters (keeping the tail, where the real error is) so the rest of the lifecycle payload always fits.

Events are for notifying, not for moving data. Past a few hundred KB, put the bytes in an artifact or object store and emit the reference.

Every run publishes a lifecycle event when it starts and another when it finishes, whatever its kind (pipeline, script, or graph):

  • run.started - payload {run_id, status, workflow, …}
  • run.succeeded - adds output
  • run.failed - adds error
  • run.cancelled - adds reason

All four also carry:

  • tags - the run’s tags, and tag, a map of the k:v ones, so a handler routes on payload.tag.sha without a second lookup.
  • trigger - what started the run, when it was event-triggered: type, sha, ref, repo, sender, forge. A manual submit has {"type": "manual"}, so test the field you are about to read (has(event.payload.trigger.repo)) rather than trigger itself.
  • run_url - a link to the run, when the deployment sets CRYOSLEEP_PUBLIC_URL. Absent otherwise rather than guessed.

Subscribe a handler to run.failed to notify on failures, to run.succeeded to fan out on completion, or to run.* for all four. These are the platform’s events, so you can’t emit them yourself - a run.failed on the bus always means a run actually failed.

run.* includes run.started. A handler written when the set was three terminal events fires twice as often now, once at the start and once at the end. List the three explicitly (--on run.succeeded,run.failed,run.cancelled) if you want terminal-only.

run.started is announced when a run starts, so a run held in a concurrency queue announces when it is promoted, not when it was accepted.

A lifecycle event carries the finishing run’s subject (see Subjects), so a run that waits on a specific upstream completion matches that one, not any run that happened to finish.

A registered webhook forwards every non-push delivery as a bus event typed github.<event> - github.issue_comment, github.pull_request, and so on - so a handler can react to PR comments, review states, or issues the same way it reacts to a cryo emit. Deliveries are signature-verified and deduped before forwarding; a forge redelivery resolves to the same event, not a duplicate. Pushes arrive as the push event type.

Which events arrive is controlled on the forge side. An auto-registered webhook (--auto-register) subscribes to push plus the actionable set: issues, issue comments, pull requests, PR reviews and review comments, and releases. A manually-created hook sends whatever you tick in the forge’s settings - anything it sends gets forwarded.

Inbound webhooks: external deliveries on the bus

Section titled “Inbound webhooks: external deliveries on the bus”

cryo emit is one way onto the bus. The other is an event hook: a named inbound HTTP endpoint that turns a delivery from an outside system into a bus event, so a cryo handler whose --on matches that event type runs.

Terminal window
cryo event-hook set billing --event-type payment.settled

set prints the ingest URL, a shared secret shown once, and a curl that uses both. Only the secret’s digest is stored, so a lost one is replaced rather than recovered: cryo event-hook rotate <name> mints a new one and the old one stops working. The sender presents the secret in the X-Cryo-Hook-Secret header:

Terminal window
curl -X POST "$INGEST_URL" \
-H "X-Cryo-Hook-Secret: $SECRET" \
-H 'content-type: application/json' \
-d '{"sku": "TENT-2P", "qty": 12}'
# 202 {"event_id":"dev:default:evt-…","event_type":"…"}

A ?secret=<secret> query parameter works too, for a sender that can’t set headers - but it puts the secret in browser history, proxy logs and referrers, so prefer the header wherever you have the choice. Without either, ingest answers 401 and the body says which header it wanted.

The delivery arrives as {"body": …, "query": …} under payload, so a handler reads a posted field as input.payload.body.<field> and a query string one as input.payload.query.<field>. Query values are always strings; body values keep their JSON types. A POST with no body at all - curl -X POST "$INGEST_URL", the shape a doorbell or a button sends - arrives as an empty object, so .payload.body.temp finds nothing rather than failing on a string. A body that isn’t JSON (form-encoded, plain text) arrives verbatim as a string, so guard when you expect one of those: .payload.body.temp? // .payload.query.temp? // 80 in jq.

Three verification modes decide what counts as a valid delivery:

  • a shared token (--event-type <type>), presented on every delivery, which emits that one type;
  • a built-in manifest (--manifest github --key <signing-key>), which verifies the signature, shapes the payload, and derives the emitted type per delivery;
  • manual HMAC (--hmac-header/--hmac-key/--hmac-prefix) for a sender whose signing scheme you describe yourself.

cryo event-hook ls, show <name> and rm <name> manage them. The delivery becomes an ordinary bus event, so whatever subscribes to that type runs.

A webhook trigger node mints its own hook when the graph is published and subscribes the graph to it. Same machinery underneath: the endpoint is an event hook, it appears in cryo event-hook ls as graph.<graph>.<node>, and its deliveries land on the bus like any other. So both shapes can feed any number of subscribers - the choice is about who owns the endpoint, not what can read it.

The default is simple: if a graph is the thing you want run, give it a trigger node. The endpoint is then part of the graph - one verb to set up, and nothing left behind when the graph goes. Create a hook yourself when the bus is the consumer: several graphs, a cryo handler script, or subscribers you haven’t written yet.

Where that isn’t obvious, three things decide it:

  • How many URLs the sender has to know. A hook you create is one endpoint that fans out to every subscriber. Trigger nodes give one endpoint per graph, each registered with the sender separately. Ten graphs reacting to one repo is ten forge webhooks, or one.
  • Whether the endpoint should outlive the graph. A trigger’s endpoint is minted and reaped by publishing: drop the node and the URL and secret go with it, which is clean teardown. A hook you created stays until you remove it, which is what you want when re-pointing the sender is slow or out of your hands.
  • Whether the name should be neutral. A hook you name emits billing.*; a trigger’s emits graph.<graph>.<node>.*, keyed to that graph’s node. Subscribing something else to that is reaching into another graph’s front door.

Catching a repo push to run that repo’s pipeline is the owned kind - one graph, one sender, teardown with the graph. CI from a repo walks it through.

An event’s key is its subject - the thing it’s about. A forge webhook sets it to the repo (acme/web), so ten repos on one project emit push and pr.closed events that are the same type but distinguishable by subject. A cryo emit sets it with --key. Lifecycle events carry the finishing run’s subject.

The subject earns its keep through inheritance. When an event starts a run, the run inherits the event’s key as its subject. A run-scoped reaction the run registers - a cancel_on: or a wait_for_event: - then correlates to that subject automatically. So a CI run started by a push to acme/web has subject acme/web, and its cancel_on: [pr.closed] fires only when acme/web’s PR closes, not another repo’s. You write nothing about the correlation; the subject carries it. One graph, ten repos, no per-repo duplication.

Two rules keep this predictable:

  • The producer sets the subject, once. The webhook composes it from the delivery (the repo); cryo emit --key sets it explicitly. A consumer never digs it out of the payload - it matches a clean key.
  • An unkeyed event is a broadcast. An event with no subject reaches every reaction watching its type, whatever their subject. That is how a project-wide signal works: cryo emit deploy.freeze (no --key) cancels every run whose cancel_on watches it. A subject only ever narrows delivery, and only for events a producer chose to key.

A handler’s when: predicate can read the subject as event.key, so a handler can filter on it even though handler matching itself is by type.

How far the subject narrows, and where it stops

Section titled “How far the subject narrows, and where it stops”

A forge webhook sets the subject to the repo, and that is the whole of it: there is no per-PR or per-commit key. So the narrowing is repo-level and nothing finer. In a repo with five open PRs, all five CI runs carry subject acme/web, and one pr.closed fires the cancel_on: on all of them, whichever PR closed. A wait_for_event: is the same - a run waiting on deploy.approved is resumed by any approval in the repo.

Two things follow for how to write this today:

  • To supersede a run per branch or per PR, use a concurrency group rather than cancel_on:. group: takes ${{ }} holes, so it names the thing being serialized as precisely as you like, and policy: cancel-running replaces the previous run for that same key when a new one arrives:

    concurrency:
    group: 'ci-${{ has(input.ref) ? input.ref : "manual" }}'
    policy: cancel-running

    That is the per-PR “stop the old run” behaviour, and it correlates to the run itself, because the group is computed from the run’s own input.

  • To decide on the event’s contents, give the wait a when:. A wait_for_event: job and a graph event node both take a predicate over the envelope, so a wait accepts the events it is for and lets the rest go by (below). A handler’s --when does the same job one level earlier, before a run exists.

cancel_on: has no predicate: it is a whole-run reaction, so reserve it for events that are genuinely repo-wide - a deploy.freeze that should stop everything - rather than for “cancel the run for this PR”, which is a concurrency group.

A wait takes the first event of its type, and that is rarely what you mean when several are in flight. Give it a predicate and it accepts only the events it is for; the rest reach it, are declined, and it goes on waiting:

jobs:
hold:
wait_for_event:
event: order.placed
when: ${{ event.payload.qty > 10 }}
ship:
depends_on: [hold]
steps:
- name: ship
run: echo "shipping ${{ steps.hold.output.payload.sku }}"

A graph event node takes the same predicate, as bare CEL:

{"id": "hold", "type": "event",
"config": {"event": "order.placed", "when": "event.payload.qty > 10"}}

The scope is one name, event - the same envelope a handler’s --when and a pipeline’s top-level if: read. A ${{ … }} wrapper is accepted in YAML and stripped; the expression is evaluated in the control plane against an event the run has not seen, so it is never resolved like a ${{ }} hole in a step.

A few specifics:

  • Declining is private to that wait. Every other listener still receives the event, predicate or not. One run’s filter is not a project-wide one.
  • timeout: is unaffected. The deadline runs from the first park, so a stream of declined events does not extend it. A wait that declines until its deadline times out exactly as one that saw nothing.
  • A predicate that cannot be evaluated declines the event, and says so in the server log. Reading a field the event does not carry is an error, so guard anything optional: has(event.payload.qty) && event.payload.qty > 10. Without the guard, every event of that type is declined and the run waits until its timeout:. A predicate that will not compile is refused earlier, when cryo check runs.
  • The scope is event and nothing else. A predicate cannot read the run’s own state - no steps.*, no vars. It is evaluated in the control plane before this run is involved. To correlate a wait with something the run computed, put that value in the event’s subject and let the subject match.

Without a when:, a wait behaves as it always did: the first event of its type resumes it, whatever it contains. A downstream if: is not a substitute - by the time it runs, the wait has already been spent.

A handler runs a workflow when a bus event matches its type patterns. A project can have many, each with a name. The workflow is a polyglot script, a YAML pipeline, or a stored canvas graph.

Terminal window
cryo handler set ci ./router.sh --on push # script, on pushes
cryo handler set notify ./alert.sh --on run.failed # a failure notifier
cryo handler set release ./rel.yaml --as=yaml --on push
cryo handler set report --graph weekly --on run.* # a stored graph
cryo handler set status --node github.report_run_status \
--config '{"credential":"forge"}' --on run.* # one node kind
cryo handler ls # list them
cryo handler show ci # print one
cryo handler rm notify # remove one

--tag applies tags to every run the handler spawns; --requires adds agent capabilities on top of the base one (polyglot for a script handler). Stuck at a blank file? cryo handler init prints starter router scripts you edit and register.

A script handler can pull project credentials into its environment with --credential ENV_VAR=credential_name (repeatable): the named credential’s value is resolved from the project’s credential store and injected as that env var when the run is claimed, masked in logs. A yaml or graph handler names its own credentials instead (a pipeline job / graph node), so --credential is script-only.

An untrusted delivery never sees a secret, and a handler that asks for one fails rather than running without it. So a handler registered with --credential does not serve fork pull requests at all: to give outside contributors feedback, register a second handler that asks for no credential, or move the privileged work into a graph and gate it on run.untrusted.

Terminal window
cryo handler set ci ./router.sh --on push \
--credential GITHUB_TOKEN=github-status

--on is a list of event-type patterns:

  • an exact type - push, run.failed, deploy-done
  • a prefix wildcard - run.* matches run.started/run.succeeded/run.failed/run.cancelled
  • * - every event

An event delivers to every handler whose patterns match its type, and each match spawns one run. So run.succeeded reaches only handlers that asked for it; a busy project doesn’t spawn a run for handlers that don’t care.

--on matches the event type; --when filters on its content. It’s a CEL expression evaluated against the event before the handler fires - when it’s false the event is recorded but the handler doesn’t run. One name is in scope: event, the whole envelope - event.type, event.key (the subject), event.payload, and for a push the fields the source shaped onto it (event.ref, event.sha, event.repo, event.sender, event.paths, event.paths_known). It is the same scope a pipeline’s top-level if: sees.

A push carries the files it changed, so a handler can skip work when nothing relevant moved - a CI router that ignores docs-only pushes:

Terminal window
cryo handler set ci ./router.sh --on push \
--when 'event.paths.exists(p, !p.startsWith("docs/"))'

event.paths is the sorted set of files the push added, modified, or removed. event.paths_known is false when the forge didn’t send a file list; write the predicate to run in that case rather than skip - '!event.paths_known || <your test>' - so an unknown set never silently drops a build. A handler with no --when fires on every type match.

A handler matching push is authoritative for pushes: the push runs that handler instead of the webhook config’s inline pipeline - exactly one executor per push, never both. The webhook config keeps transport duty (signature, dedup, repo identity). Drop the handler and the inline pipeline resumes. A handler routing pushes reads the ref, commit and repo off the envelope it was handed - event.ref, event.sha, event.repo - the same value a graph node reads as input. Clone and dispatch from those.

Every trigger is recorded on the bus - pushes, schedule ticks, and manual submits all show up in cryo events ls alongside custom and lifecycle events.

A script handler runs with the event in a file, named by $CRYO_INPUT_FILE; a shell step reads the same file. For a cryo emit event the envelope is:

{
"type": "deploy-done",
"event": "dev:default:evt-1a2b3c4d5e6f7a8b",
"payload": { "env": "prod", "sha": "abc123" }
}

event is the bus id. The file holds the whole envelope, whatever its size - a forwarded forge push is tens of KB and arrives intact. It is written into the run’s scratch directory and can’t be shadowed by a workflow’s own env:.

The handler reads it, switches on .type, and acts:

#!/usr/bin/env bash
set -eu
event="$(jq -r .type "$CRYO_INPUT_FILE")"
case "$event" in
run.failed) cryo step notify -- alert "$(jq -r .payload.error "$CRYO_INPUT_FILE")" ;;
deploy-done) cryo step publish -- announce "$(jq -r .payload.sha "$CRYO_INPUT_FILE")" ;;
*) echo "ignoring $event" ;;
esac

For the handful of fields worth branching on without reaching for jq, the same envelope’s scalars arrive as environment variables: $CI_TRIGGER_EVENT (the type), $CI_TRIGGER_EVENT_ID (the bus id), and one per scalar field the envelope carries - $CI_TRIGGER_SHA, $CI_TRIGGER_REF, $CI_TRIGGER_REPO, $CI_TRIGGER_SENDER, $CI_TRIGGER_SHORT_SHA. Nested objects and lists have no flat form and stay in the file.

A pipeline handler doesn’t read the file at all - the same envelope is its input expression scope, so it writes ${{ input.type }} and ${{ input.payload.sha }} directly. That scope is uncapped too.

A graph handler receives the event as its run input instead of an env var - nodes read it through expressions (input.type, input.payload.error). A canvas graph declares which events start it with trigger nodes, which register the handler for you on publish.

One event produces one run per matching handler. The run id is derived from the event id and the handler name, so a replayed emit resolves to the same run instead of starting a second one.

An event-triggered workflow can drive the next one: a handler’s own cryo emit of a custom event delivers to matching handlers, one generation deeper. So a run.failed handler can emit deploy.blocked to trigger a rollback workflow.

What doesn’t chain is a handler run’s lifecycle re-emit. When a handler finishes it emits run.succeeded/run.failed like any run, but those are recorded and not re-delivered - otherwise a run.* handler would re-trigger on its own completion forever. Lifecycle events from ordinary (non-handler) runs deliver normally; that’s what makes the first notification fire.

Two ceilings keep an emit storm from turning into unbounded durable work. Hitting either fails the emit, so the emitting workflow sees the error and applies backpressure rather than events piling up silently.

  • Chain depth: a custom-emit chain may run at most 5 generations deep. Past that it’s treated as a routing loop and delivery is refused.
  • Concurrency: a project allows at most 16 handler runs in flight at once. Beyond that, delivery is refused until the backlog drains.

Delivery is at-least-once. Recording an event and submitting the run are idempotent on the event id, but a retried emit (from a workflow that failed after emitting) can record a second bus row for the same logical event. Handlers should tolerate a duplicate.

Something calling the submit API directly - your own webhook receiver, a script in someone else’s CI - has a cheaper answer than tolerating it. Pass the sender’s delivery id as an idempotency key:

Terminal window
cryo submit deploy.yaml --idempotency-key "$GITHUB_DELIVERY"

The key derives the run id, so a redelivery is answered with the run the first one made and no second run is created, admitted or counted. The second submit answers 200 with duplicate and hands back that run’s id, so a caller that polls has something to poll either way:

Two submits under one idempotency key: the second returns the first
run’s id, and runs ls shows one row

That is a different tool from the dedup ledger, which is for duplicates a run discovers about its own work; the key is for duplicates the caller already knows about.

There is no per-project ordering guarantee. Events are delivered as they arrive; two emits close in time may run their handlers concurrently and finish in either order.

You don’t have to know an event type by heart - the event catalog lists what’s available: the platform’s own types (run.*, push, schedule.tick), types a project graph declares by emitting them, and types already seen on the bus (with a sample payload). Subscribing to a type that has never fired is fine: a handler’s on patterns match by type, so the registration sits ready and fires the first time the type appears.

A handler is the one place events route to work, and it already runs your code (in any language) with the event in hand, where it can cryo spawn a pipeline or a script, call a connector, or do the work inline. There’s no separate listener to run - the platform invokes the handler once per matching event, and each run is a durable workflow, so a crash mid-route re-dispatches and replays rather than dropping the event.

That’s the durable answer to “react to a stream of events and start workflows”: register a handler, act from it. The agent socket is request/response and only exists inside a running activity; wait_event inside a workflow parks the run until one event arrives (a durable suspension, not a live feed). A long-lived external listener would be non-durable - while it’s down, events need buffering, acking, and replay, exactly the machinery a handler gives you because the platform owns the waiting.