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:
cat > ship.sh <<'EOF'cat "$SPOOL_INPUT_FILE"EOFspool 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.
What an event is
Section titled “What an event is”An event is a project-scoped record with three parts:
type: a string such asorder.placedorrun.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.
spool event list# dev:default:evt-edebbab186b70d36 order.placed acme/shop 6s agospool event get dev:default:evt-edebbab186b70d36Emitting
Section titled “Emitting”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.
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 stdinspool event emit cache-warm # payload defaults to JSON nullInside 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.
Waiting for an event
Section titled “Waiting for an event”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 eventname: yaml-waiterjobs: 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 itversion: hyperspool-graph/v1nodes: - 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.

A script step waits with spool wait event, which suspends the run until the event arrives
and prints its envelope:
echo "waiting for an order"order=$(spool wait event order.placed --timeout 5m)echo "got: $order"waiting for an ordergot: {"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.
Filtering with when
Section titled “Filtering with when”--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.typeevent.key, the subjectevent.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
--whenruns 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 atimeout:, which counts from when the wait began. - A script decides after
spool wait eventreturns, 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():
--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.
Untrusted events
Section titled “Untrusted events”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 includedjobs: 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:takescancel_on: { events: [...], accept_untrusted: true }.- A graph’s
eventstep takesaccept_untrustedin 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.
Subjects
Section titled “Subjects”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:
spool event emit deploy.freeze --key acme/webspool event emit deploy.freezeA 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-runningPipelines 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.
Handlers
Section titled “Handlers”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.
spool handler set router ./router.sh --on run.failed,deploy-done # a scriptspool handler set release ./release.yaml --on github.release.publishedspool handler set report -w weekly --on 'run.*' # a stored graphspool handler set approvals --node slack.post_message \ --config '{…}' --on run.awaiting # one nodespool handler listspool handler get routerspool 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.
Matching
Section titled “Matching”--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.*matchesrunand anyrun.<…>, 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.
What a handler receives
Section titled “What a handler receives”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 bashset -eufield() { 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" ;;esacPipeline, 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.
Credentials
Section titled “Credentials”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.
Chaining and limits
Section titled “Chaining and limits”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.
Lifecycle events
Section titled “Lifecycle events”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, andtag, a map of thek:vtags, so a handler can readpayload.tag.sha.repo:andsha:are the platform’s: a run can’t set them at submit, andspool spawnkeeps 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 notriggerat all, so test the field you read:has(event.payload.trigger.repo). A run started byspool spawnor a call keeps only the fields of its owninput: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.
Runs that don’t announce
Section titled “Runs that don’t announce”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 arun.*handler can’t re-trigger on its own ending. Such a run reports its own failure with a step guarded byif: 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.
Telling someone a run is waiting for them
Section titled “Telling someone a run is waiting for them”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 asawaiting_signal.waits_on: who is expected to act.personfor an approval with a prompt,eventfor a wait an event will release,signalfor one somebody sends by hand. Check it before paging anyone.promptandfields: 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:
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 guarantees
Section titled “Delivery guarantees”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:
spool submit deploy.yaml --idempotency-key "$DELIVERY_ID"For duplicates a run discovers in its own work, use the dedup ledger instead.
Webhooks
Section titled “Webhooks”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.
Reference
Section titled “Reference”Reserved event types
Section titled “Reserved event types”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.
Size limits
Section titled “Size limits”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.