Skip to content

Events and handlers

Every project has an event bus. Runs, webhooks and the platform put events on it, and anything that should react to them listens there: a run waiting halfway through for a go-ahead, or a handler that starts a fresh run for each matching event. Use events to connect workflows to each other and to the outside world without hard-wiring one to the next.

A handler is a workflow registered to run when an event of a given type arrives. Here one starts a run for every large order:

Terminal window
cat > ship.sh <<'EOF'
cat "$SPOOL_INPUT_FILE"
EOF
spool handler set big-orders ./ship.sh --on order.placed --when 'event.payload.qty > 10'
spool event emit order.placed --payload '{"sku":"TENT-2P","qty":12}'

The emit starts one run of ship.sh, which prints the event it was given:

{"event":"dev:default:evt-f1c75f283d22ffab","payload":{"qty":12,"sku":"TENT-2P"},"type":"order.placed"}

An order of 2 starts nothing, because the --when filter turns it down before any run exists.

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

  • type: a string such as order.placed or run.failed.
  • key: its subject, the thing the event is about (a repo, a deploy). Optional; an event without one is a broadcast. See Subjects.
  • payload: any JSON.

When an event reaches a listener it arrives as an envelope: a JSON object with the type, the bus id as event, and the payload, as in the output above. A webhook delivery from a forge adds a few shaped fields at the top level (see Forge deliveries), and the envelope a wait receives adds untrusted. The subject isn’t in the envelope; a filter reads it as event.key.

The bus is append-only, so it reads like a log. spool event list shows recent events and spool event get <id> prints one in full. In the console, More > Events lists them with the runs each one started.

Terminal window
spool event list
# dev:default:evt-edebbab186b70d36 order.placed acme/shop 6s ago
spool event get dev:default:evt-edebbab186b70d36

Any workflow can emit an event: spool event emit from a shell step or a script, emit() from the language SDKs, or an emit step in a graph. spool event emit also works from a terminal, which is how you release a waiting run or trip a cancel_on: by hand.

Terminal window
spool event emit deploy-done --payload '{"env":"prod","sha":"abc123"}'
spool event emit deploy-done --key acme/web --payload '{"env":"prod"}'
spool event emit deploy-done --payload @result.json # or - for stdin
spool event emit cache-warm # payload defaults to JSON null

Inside a run, each emit is checkpointed by its position. When the code that emitted runs again (a script resuming after a wait, a step re-dispatched after its agent dropped it, a retry: attempt), the emit returns the first event instead of publishing a second one. The first attempt’s event stands, payload and all.

A YAML step re-runs from the top, so keep its emits at a fixed place: an emit inside a loop whose length changes lands at a new position and publishes again. Outside a run, every spool event emit publishes.

A type is 1-64 characters of [A-Za-z0-9._-]. Some types belong to the platform and to verified webhook deliveries, and an emit can’t use them; see Reserved event types.

An event is broadcast: every listener watching its type gets the whole envelope, and none of them takes it away from the others. There are four ways to listen:

Waits with Reads the event as Filters with
Pipeline a wait_for_event: job ${{ steps.<job>.output.payload.… }} when: on the wait
Graph an event step nodes.<id>.output.payload.… when: in the step’s config
Script spool wait event <type> the envelope on stdout the script, afterwards
Handler spool handler set --on <type> input.payload.…, or $SPOOL_INPUT_FILE --when '<CEL>'

The first three hold a run that already exists. A handler starts a new run per event.

Here are a pipeline and a graph that both wait for a large order:

# waiter.yaml: a pipeline job waits for the event
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 }}"
# waiter-graph.yaml: a graph's event step waits for it
version: hyperspool-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 }

Submit both, register the big-orders handler from the top of this page, then emit spool event emit order.placed --payload '{"sku":"TENT-2P","qty":12}'. All three react to that one event, and both waits print ship TENT-2P. Emit a small order first and none of them react: the two waits decline it and keep waiting, and the handler never starts.

Two waits and a handler on one event type: a small order is declined by
both waits and starts no handler run; a large one releases both waits and
starts one run of the handler

A script step waits with spool wait event, which suspends the run until the event arrives and prints its envelope:

Terminal window
echo "waiting for an order"
order=$(spool wait event order.placed --timeout 5m)
echo "got: $order"
waiting for an order
got: {"event":"dev:default:evt-54f23adfdda984d2","payload":{"qty":2,"sku":"MUG"},"type":"order.placed","untrusted":false}

spool wait event has no filter, so it takes the first event of the type, small orders included, and the script decides afterwards. With --timeout it exits 124 when nothing arrives in time. It works inside a job only.

--on and a wait’s event: match the type. A predicate filters on content. It is CEL with one name in scope, event, which holds the envelope plus the subject:

  • event.type
  • event.key, the subject
  • event.payload
  • for a forge delivery, the shaped fields listed in Webhooks

A pipeline’s top-level if: reads the same scope.

Where the predicate runs decides what it costs:

  • A handler’s --when runs before anything starts. An event it rejects creates no run.
  • A wait’s when: runs against a run that is already waiting. An event it declines leaves the wait in place, and every other listener still receives it. Declined events don’t extend a timeout:, which counts from when the wait began.
  • A script decides after spool wait event returns, so the event is spent either way. It can wait again for the next one.

Put the test in a handler when most events aren’t for you and each one that is should start fresh work. Put it on the wait when a run is halfway through and waiting for its turn. Once a run has the event, an if: on a later step can branch on what it contained.

A predicate that can’t be evaluated counts as false: the handler is skipped, or the wait declines the event. Reading a field the event doesn’t carry is such an error, so guard optional fields with has():

Terminal window
--when 'has(event.payload.qty) && event.payload.qty > 10'

Without the guard, every event without qty is declined, and a wait can sit until its timeout:. A predicate that doesn’t compile is refused when you save it or run spool workflow check.

A wait’s predicate can’t read the run’s own state (steps.*, vars). To tie a wait to something the run computed, put that value in the event’s subject.

When a handler you expected to run didn’t, the webhook delivery log shows what each handler did with each delivery, including a --when that errored and why.

A webhook marks each delivery trusted or untrusted by its trust rule, which looks at who caused the delivery and whether the code came from a fork. A fork’s pull request and a comment from someone outside the repo are untrusted. Trust lists the rule for each kind of delivery.

A waiting run already holds its secrets, so by default an untrusted event neither ends a wait nor fires a cancel_on:. Otherwise anyone who can comment could resume a deploy parked on a /deploy comment, or cancel your builds. The wait isn’t spent: it goes on until a trusted event arrives.

When any event of the type will do, opt in on the wait:

# tear down a preview environment when its pull request closes, forks included
jobs:
closed:
wait_for_event:
event: github.pull_request.closed
accept_untrusted: true
teardown:
depends_on: [closed]
steps:
- run: ./preview down "${{ steps.closed.output.payload.body.number }}"

The same opt-in exists everywhere a run waits:

  • cancel_on: takes cancel_on: { events: [...], accept_untrusted: true }.
  • A graph’s event step takes accept_untrusted in its config.
  • A script passes spool wait event <type> --accept-untrusted.

The envelope a wait receives says "untrusted": true or false, which a later step or the wait’s own when: can read. A run that is itself untrusted takes untrusted events on every wait without opting in.

A spool event emit from an untrusted run ends no wait and fires no cancel_on:, opted in or not. Its payload is whatever that run wrote, and one fork’s run could otherwise resume or cancel another’s.

Handlers still start on untrusted events, without project secrets; see Credentials.

An event’s key is its subject. A forge webhook sets it to the repo (acme/web), so ten repos on one webhook send events of the same type, told apart by subject. spool event emit --key sets it by hand, and lifecycle events carry the run’s subject.

When an event starts a run, the run takes the event’s key as its subject (a subject:<key> tag). A cancel_on: or wait_for_event: in that run then only matches events with the same key, or with no key at all. You write nothing about the correlation.

For example, a run started by a push to acme/web with cancel_on: { events: [deploy.freeze] } is cancelled by either of these:

Terminal window
spool event emit deploy.freeze --key acme/web
spool event emit deploy.freeze

A freeze for acme/api leaves it alone.

The subject is the repo and nothing finer, so five open pull requests in one repo share it. To replace the previous run for the same branch or pull request, use a concurrency group computed from the run’s input:

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

Pipelines and graphs both take this block; see Run admission. Keep cancel_on: for events that apply to the whole subject, like a freeze. A handler’s --when can read the subject as event.key, which is how one handler picks one repo’s events off a shared webhook.

A handler runs a workflow when an event’s type matches one of its patterns. Each handler has a name (lowercase letters, digits and -), and one event can start several handlers.

Terminal window
spool handler set router ./router.sh --on run.failed,deploy-done # a script
spool handler set release ./release.yaml --on github.release.published
spool handler set report -w weekly --on 'run.*' # a stored graph
spool handler set approvals --node slack.post_message \
--config '{…}' --on run.awaiting # one node
spool handler list
spool handler get router
spool handler delete router

--tag adds tags to every run the handler starts. spool handler init writes a starter router script to edit.

Pick the body by what the reaction is:

The reaction is Use Why
one call --node <kind> --config … no document to keep
a decision, then dispatch a script it can read, choose and spool spawn
several steps you want to see -w <name> each step shows and retries on its own
a build across agents a pipeline file jobs, depends_on, matrix:, capabilities per job

A pipeline handler stores a snapshot of the file as it was when you registered it; nothing re-reads it. To run the pipeline in a repo at the pushed commit, use spool repo connect, or a graph workflow step with from_repo:. A graph handler runs the graph’s published version, or the one -w <name>@<version> pins. A graph can also register itself with trigger steps when you publish it.

A script handler runs as bash -eu whatever the file’s extension, with no checkout and no working directory of its own. It needs an agent with the polyglot capability (add more with --requires <cap>), which the managed environments provide. Those have node and python3 but not jq.

--on takes a comma-separated list of patterns, and a graph’s bus trigger step takes the same patterns in event::

  • an exact type: run.failed, github.push, deploy-done
  • a prefix wildcard: run.* matches run and any run.<…>, however many segments follow
  • *: every event

Quote patterns with * so your shell doesn’t expand them. Every handler whose patterns match starts its own run, and a handler with no --when runs on every match.

Besides your own events and lifecycle events, the bus records pushes, schedule ticks (schedule.tick) and manual submits (manual), so a handler can subscribe to those too. You can subscribe to a type that has never fired; the handler runs the first time it appears. The event catalog lists the types a project has, with sample payloads.

A script handler gets the envelope in a file named by $SPOOL_INPUT_FILE. For a spool event emit event it holds:

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

The envelope’s top-level scalars also arrive as environment variables: $CI_TRIGGER_EVENT (the type), $CI_TRIGGER_EVENT_ID, and for a forge delivery $CI_TRIGGER_SHA, $CI_TRIGGER_REF, $CI_TRIGGER_REPO, $CI_TRIGGER_SENDER and so on. Nested values stay in the file.

#!/usr/bin/env bash
set -eu
field() { node -p "JSON.parse(require('fs').readFileSync(process.env.SPOOL_INPUT_FILE)).$1"; }
case "$CI_TRIGGER_EVENT" in
run.failed) spool step notify -- echo "failed: $(field payload.error)" ;;
deploy-done) spool step announce -- echo "deployed $(field payload.sha)" ;;
*) echo "ignoring $CI_TRIGGER_EVENT" ;;
esac

Pipeline, graph and node handlers get the same envelope as their run input: ${{ input.type }} and ${{ input.payload.sha }} in a pipeline, input.payload.error in a graph expression or a --node handler’s $template.

One event makes one run per matching handler. The run id comes from the event id and the handler name, so a repeated delivery of the same event resolves to the same run.

A script handler can pull project credentials into its environment with --credential ENV_VAR=credential_name (repeatable), masked in logs. Pipeline and graph handlers name their credentials in the definition instead.

A run started by an untrusted event never sees a secret, and a handler that asks for one fails rather than running without it. To serve fork pull requests too, add --optional-when-untrusted: on an untrusted event the script runs with those variables unset, so check for them before the privileged part. Or register a second handler that asks for no credential.

A handler’s own spool event emit of a custom event is delivered like any other, so a run.failed handler can emit deploy.blocked to start a rollback. Its lifecycle events are not delivered (see Runs that don’t announce), which stops a run.* handler from triggering on itself.

Two limits turn a runaway chain into an error the emitting run sees:

  • A chain of handler-emitted events may be at most 5 generations deep.
  • A project may have at most 16 handler runs in flight. Beyond that, delivery is refused until some finish.

A top-level run publishes an event when it starts, when it ends, and around what it waits for: run.started, run.succeeded, run.failed, run.cancelled, run.awaiting, run.wait_expired and run.wait_withdrawn. The event catalog says what each one means and what it adds to the payload. These types are the platform’s, so a run.failed on the bus always means a run failed.

run.* matches all seven, including run.started and the three about waits. Subscribe to run.succeeded,run.failed,run.cancelled when you mean “finished”.

Every lifecycle payload carries run_id, status and workflow (the workflow type, such as hyperspool/pipeline/v1), plus:

  • tags, and tag, a map of the k:v tags, so a handler can read payload.tag.sha. repo: and sha: are the platform’s: a run can’t set them at submit, and spool spawn keeps them only when they name the repo and commit that were delivered.
  • trigger: what started the run when an event did (type, sha, ref, repo, sender, forge). A manual submit has {"type": "manual"} or no trigger at all, so test the field you read: has(event.payload.trigger.repo). A run started by spool spawn or a call keeps only the fields of its own input: that match what the run at the head of that chain was delivered.
  • run_url: a link to the run in the console.

A run held in a concurrency queue announces run.started when it leaves the queue. A lifecycle event carries the run’s subject, so a wait on an upstream completion matches that run’s events and not every run in the project.

Some runs’ lifecycle events never reach a handler or a wait, so a run.failed notifier doesn’t hear about them:

  • A run that a handler or a graph trigger step started. Its lifecycle events are recorded (you see them in spool event list) but not delivered, so a run.* handler can’t re-trigger on its own ending. Such a run reports its own failure with a step guarded by if: failure().
  • Child runs, which are steps of their parent.
  • Dry runs (Try it), which publish nothing.

A run that a handler starts with spool spawn is a new top-level run and does announce. That is how builds from spool repo connect report their status.

run.awaiting fires when a top-level run reaches an approval, a wait_for_event: job, a graph event step or a graph wait on a signal. On top of the common fields it carries:

  • substate: what the run is waiting in, such as awaiting_signal.
  • waits_on: who is expected to act. person for an approval with a prompt, event for a wait an event will release, signal for one somebody sends by hand. Check it before paging anyone.
  • prompt and fields: the approval’s question and what it asks for.
  • signal: the name to send, so a reply can be scripted.
  • deadline_ms: when a bounded wait gives up.
  • workflow_name: what the author called the workflow, when it has a name.

One wait is one event, however often the run is replayed. A timer doesn’t fire it (a sleeping run wakes by itself), and neither does spool wait signal inside a script step.

A --node handler turns it into a message with no code:

Terminal window
spool handler set approvals --node slack.post_message --on run.awaiting \
--when 'event.payload.waits_on == "person"' \
--config '{"credential": "slack", "with": {"channel": "#deploys"},
"input": {"text": {"$template":
"{{ input.payload.workflow_name }} is waiting: {{ input.payload.prompt }} {{ input.payload.run_url }}"}}}'

That message only says the run waits. To answer from the message, with Approve and Reject buttons that act as the person who clicks, use a chat target instead: see Approvals in Slack and Mattermost.

status is the run’s status when the event was published. An approval answered just after the run stopped can publish run.awaiting with the run already finished, so a handler that pages someone should check it.

run.wait_expired fires when a bounded wait’s timeout: passes. The wait resolves with timed_out: true and the run carries on, so an approval nobody answered goes ahead unless a later if: says otherwise. The event carries what run.awaiting did except substate and deadline_ms; prompt is still there to say what went unanswered.

run.wait_withdrawn fires once per wait when the run is cancelled or ends while stopped on it, so a notification sent on run.awaiting has gone stale. It carries what run.wait_expired does, so a handler can find the message it sent by run_id and signal and take it back.

A job failing beside the approval doesn’t withdraw it, since the run still waits for the answer. An approval that was answered or ran out of time is never withdrawn, and neither is an event wait, which asked nobody.

Delivery is at least once. Recording an event and starting its handler runs are idempotent on the event id, but a workflow that fails after emitting and is retried can record a second event for the same thing. Handlers should tolerate a duplicate.

There is no ordering guarantee: two events close together may run their handlers concurrently and finish in either order.

Something calling the submit API directly can pass the sender’s delivery id as an idempotency key. The key derives the run id, so a second submit with the same key answers with the first run’s id and creates nothing:

Terminal window
spool submit deploy.yaml --idempotency-key "$DELIVERY_ID"

For duplicates a run discovers in its own work, use the dedup ledger instead.

A webhook is an inbound HTTP endpoint that turns a delivery from outside, such as a forge push or a payment callback, into an event on this bus.

An emit can’t use these types, so a handler can trust what they mean:

Types Why
push, manual, schedule.tick, spawn envelopes the engine makes
run.*, spool.*, feedback.*, invite.* the platform emits them
github.*, gitea.*, forgejo.* verified forge deliveries
graph.* a graph’s webhook trigger delivers here
<name>.* for each signed webhook in the project verified deliveries to that webhook

A wildcard here covers the bare root too, so run and github are refused as well.

A signed webhook delivers as <name>.<event>, so its name is checked against the same list: run, invite, schedule and other names that lead into a platform type are refused when the webhook is created, and a delivery that would arrive as a platform type is answered 400.

An emit is an HTTP body capped at 2 MiB; a larger one is refused. Past that point nothing is cut: spool wait event prints the whole envelope and $SPOOL_INPUT_FILE is never truncated. Lifecycle events cap their error and reason text at 4000 characters, keeping the tail.

Past a few hundred KB, store the bytes as an artifact and emit a reference. Webhook deliveries have their own limits; see What a delivery looks like.