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: spool 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 spool 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
spool emit deploy-done --payload '{"env":"prod","sha":"abc123"}'
spool emit deploy-done --key acme/web --payload '{"env":"prod"}'
spool 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. spool 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 spool.* 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
spool events ls
# deploy-done dev:default:evt-... by dev:default:run-... {"env":"prod"}
spool 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="$(spool wait-event order.placed)"
[ "$(echo "$ev" | jq -r .payload.qty)" -gt 10 ] || exit 0
spool step "ship" -- ship-it "$(echo "$ev" | jq -r .payload.sku)"
// waiter-graph.json - a graph node parks on 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"}]
}
Terminal window
# a handler starts a fresh run per event, and filters before it does
spool 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
spool 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 spool wait-event <type> the envelope on stdout whatever the language has (jq, a case)
Handler spool handler set --on <type> $SPOOL_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 - spool 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 spool wait-event prints the entire envelope, and $SPOOL_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

And two while it is still alive:

  • run.awaiting - the run has stopped on something outside itself, usually a person
  • run.wait_expired - a bounded wait gave up and the run carried on without an answer

All of them 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 server knows its own public address (HYPERSPOOL_PUBLIC_URL, or a local spool dev). 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 every lifecycle event. 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.* is wider than the terminal events. It includes run.started, run.awaiting and run.wait_expired, so a handler written when the set was three terminal events now fires at the start, at every park, at a wait that gives up, and 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.

run.awaiting fires when a run parks on an approval gate or an event wait, which is the moment somebody needs to hear about it. On top of the fields above it carries what a message needs:

  • waits_on - who is expected to act. person for an approval gate with a prompt, event for a wait the event bridge will deliver, signal for one somebody sends by hand. Branch on this before you page anyone: an event wait is a machine waiting on a machine.
  • workflow_name - what the author called this workflow. Use this rather than workflow, which is the type (hyperspool/pipeline/v1).
  • prompt - the gate’s own question, for an approval. A bare signal wait has none.
  • fields - what the approver is being asked to fill in.
  • signal - the name to send, so a reply can be scripted.
  • deadline_ms - when it gives up, when it is bounded.
  • run_url - where to go and answer it: the run page carries the approval form.

One wait is one event, however many times the run is polled, replayed or resumed after a restart. A run that stops twice sends two, because a second gate is a second thing to answer.

Only root runs announce. A child workflow that parks is a step of its parent, and the parent’s own gates are what somebody has to answer.

A timer does not fire this: a sleeping run resumes itself, so nobody needs telling. Neither does spool wait-signal inside a script, which suspends the step rather than the workflow, so the run parks without the workflow reaching a wait of its own. Use an approval node, or a pipeline approval: / wait_for_event: job, for a wait somebody is told about.

A handler turns the event into a message:

Terminal window
spool handler set approvals ./notify.sh --on run.awaiting
#!/usr/bin/env bash
set -euo pipefail
# Only page somebody when somebody is the blocker.
[ "$(jq -r '.payload.waits_on' "$SPOOL_INPUT_FILE")" = person ] || exit 0
name="$(jq -r '.payload.workflow_name // .payload.run_id' "$SPOOL_INPUT_FILE")"
ask="$(jq -r '.payload.prompt' "$SPOOL_INPUT_FILE")"
url="$(jq -r '.payload.run_url // ""' "$SPOOL_INPUT_FILE")"
spool step notify -- ./post-to-chat.sh "$name: $ask $url"

On a canvas, the same shape is a trigger node on run.awaiting into a matrix.send_message or slack.post_message node, with the text templated from nodes.trigger.output.payload.

A wait with a timeout: gives up when the deadline passes. It resolves as a success carrying timed_out: true, and the run carries on, so an approval nobody answered ships the release unless a downstream if: says otherwise.

run.wait_expired is that moment. It carries what run.awaiting carried minus the two fields that would be wrong: no substate, because the wait has resolved and stopped holding the run, and no deadline_ms, because the event’s own timestamp is when it passed. prompt is still there, so a message can say what went unanswered. Only root runs announce, the same rule run.awaiting follows.

Terminal window
spool handler set unanswered ./chased.sh --on run.wait_expired
#!/usr/bin/env bash
set -euo pipefail
name="$(jq -r '.payload.workflow_name // .payload.run_id' "$SPOOL_INPUT_FILE")"
ask="$(jq -r '.payload.prompt // .payload.signal' "$SPOOL_INPUT_FILE")"
url="$(jq -r '.payload.run_url // ""' "$SPOOL_INPUT_FILE")"
spool step notify -- ./post-to-chat.sh "$name: nobody answered \"$ask\", it went ahead $url"

Both halves fire for one gate: the run announces that it stopped, and later that it gave up. A gate answered in time only ever produces the first.

status on both is the run’s status when the event was published, not when the wait happened. A gate answered a moment after the run parked can publish its run.awaiting with the run already finished, so a handler that pages someone should check it.

A registered webhook forwards every delivery as a bus event typed <hook>.<forge event> - so a hook you created as gh turns a push into gh.push, a comment into gh.issue_comment, and a PR into gh.pull_request.opened (GitHub’s action becomes a third segment). A handler reacts to those the same way it reacts to a spool emit. Deliveries are signature-verified and deduped before forwarding; a forge redelivery resolves to the same event, not a duplicate.

The hook’s name is the first segment, so the type depends on what you called it. There is no bare push event type: it is reserved for the platform and nothing emits it, so a handler subscribed to the bare word matches nothing at all.

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”

spool 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 spool handler whose --on matches that event type runs.

Terminal window
spool 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: spool event-hook rotate <name> mints a new one and the old one stops working. The sender presents the secret in the X-Spool-Hook-Secret header:

Terminal window
curl -X POST "$INGEST_URL" \
-H "X-Spool-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.

spool 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 spool 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 spool 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 build that repo is the hook-you-create kind: one endpoint for the repo, a handler on <hook>.push, and the pipeline fetched at the pushed commit. That is what CI from a repo walks through and what this project’s own repos run. A trigger node is the answer when one graph is the whole reaction to one sender and its endpoint should live and die with it.

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 spool 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); spool 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: spool 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 spool 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.

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

--tag applies tags to every run the handler spawns. Stuck at a blank file? spool handler init prints starter router scripts you edit and register.

Four, because a handler runs the same thing a schedule fires. They are not four ways to do one job; each is the smallest thing that does a different one.

The reaction is Use Why
one call --node <kind> --config … no document to keep anywhere
a decision, then dispatch a script it is the only kind that can read, choose, and spawn
several steps you want to see --graph <name> the run graph is the diagram, and each node retries on its own
a build across agents --as yaml jobs, depends_on, matrix:, capabilities per job

Two things to know before choosing.

A --as yaml handler stores a snapshot of the file as it was when you registered it. Nothing re-reads it. That makes it right for a pipeline the server owns, and wrong for “run the pipeline in my repo” - for that the definition has to be fetched at the commit, which a script does (below) or a graph’s workflow node does with from_repo:.

A script handler is the flexible one and pays for it: always bash -eu whatever the file’s extension or shebang, no checkout, no working directory of its own, and the capability is polyglot. It is the right answer when the reaction is a judgement - which commit, which pipeline, what to say on the commit status - and the wrong one when the reaction is just work, which the other three describe better.

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
spool handler set ci ./router.sh --on gh.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.awaiting/run.wait_expired/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 the fields the source shaped onto it. For a push those are event.ref, event.sha, event.repo, event.sender, event.paths and event.paths_known; a pull request carries event.sha (its head commit), event.head_ref and event.base_ref instead of ref and paths. 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
spool handler set ci ./router.sh --on gh.push \
--when '!event.paths_known || 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.

Every handler whose patterns match runs, each in its own run. There is no fallback executor behind them and no “primary” handler: a webhook does transport (signature, dedup, the repo the delivery names) and the handlers do the work.

Which handler a push reaches is decided by the hook’s name. The event type is <hook>.<forge event>, so a hook named world emits world.push and one named cloud9k emits cloud9k.push, and a handler --on world.push cannot see the other repo’s pushes. One hook per repo is the simplest arrangement for that reason: the namespace does the routing and no predicate is involved.

One hook can serve several repos, because the repo is read from each delivery rather than configured. Then every repo’s push arrives as the same type and reaching different work means filtering on event.key, which is the repo. Worth it when re-pointing many repos is slow; a hook each is easier to read.

Within one repo, several handlers share the type and separate by --when. A monorepo is the case: two dispatchers on cloud9k.push, one skipping paths the other owns, so a push that touches one subproject does not rebuild the other, and a mistake in either cannot break the other’s release.

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 spool events ls alongside custom and lifecycle events.

A script handler runs with the event in a file, named by $SPOOL_INPUT_FILE; a shell step reads the same file. For a spool 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 "$SPOOL_INPUT_FILE")"
case "$event" in
run.failed) spool step notify -- alert "$(jq -r .payload.error "$SPOOL_INPUT_FILE")" ;;
deploy-done) spool step publish -- announce "$(jq -r .payload.sha "$SPOOL_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 spool 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
spool 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 spool 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.