5.4 Custom events
Included from Basic tiertriggers
After this you can: emit events in your own vocabulary from a step or a script, read what the stored event carries, and trigger on it.
Before you start — tick each one:
- Lesson 5.2 — Triggers (
opschain learn 5.2) - Lesson 2.1 — Your first action (
opschain learn 2.1)
In the GUI: custom events appear in the Activity tab and the Audit history search under their own type, filterable like any other.
Lifecycle events say a change finished; they cannot say the database cluster is provisioned or the smoke test found three regressions. Custom events let your actions speak in your domain's vocabulary — and lesson 5.2's triggers fire on them exactly as on built-in events. By the end you will have emitted one from a step, found it in the feed with its data, and dry-run a trigger against it.
1. Types are namespaced — and some prefixes are the server's
A custom event type is <namespace>:<name> — database:provisioned,
smoketest:regressions_found. Pick a namespace per domain and keep it;
triggers match on the full type.
Five prefixes are reserved for the server's own vocabulary: api:,
system:, info:, error:, warn:. Emitting one is refused — a named
error at action load for a declarative emit: step, a step failure at
runtime for step emit-event — so your events can never impersonate
platform lifecycle.
2. The emit: step kind
A YAML action emits declaratively with an emit: step, whose with: map
is the event's data — templated with the same ${{ … }} expressions as
run: commands:
# .opschain/actions/database/provision.yaml — defines database:provision
action: provision
events:
emits:
- database:provisioned
steps:
- name: apply
run: terraform apply -auto-approve
- emit: database:provisioned
with:
cluster: "${{ properties.cluster }}"
tf_exit: "${{ steps.apply.outputs.exit_status }}"
The emit step is an ordinary step in the tree: it runs after apply, and
its event is recorded only when it executes successfully — a failed run
never announces success. The events.emits header is advisory metadata
(GUI pickers, trigger lint see it); it never gates what a step may emit.
Note the with: key does double duty in the schema: on a uses: step it
binds a composed action's inputs (lesson 2.2); on an emit: step it is
the event's templated data.
3. opschain step emit-event — from any script
Inside any running step — hostexec or containerexec — the step subcommand emits imperatively:
opschain step emit-event database:provisioned \
--data cluster=prod-a \
--data-file details=@tf-summary.json
--data key=valueadds a string entry (repeatable).--data-file key=@pathreads the entry's value from a file — embedded as parsed JSON when the content is valid JSON, as a string otherwise. This is how a structured report (a terraform summary, a scan result) becomes queryableevent.datafor trigger conditions.- The event is recorded when the step succeeds, like the declarative kind. Running the subcommand outside a step is a usage error.
4. What the stored event carries
However it was emitted, the event is sourced from the emitting step,
lands on the change's node path, and inherits the change's correlation
chain — so chains show places your domain announcements right in the
cascade that produced them, and anything a trigger creates from them goes
one depth level deeper on the same chain. Run the action and read the
row back:
opschain runs create change acme/environments/dev/assets/db --action database:provision --follow
opschain events --filter type=database:* --tail 1
WHEN ID TYPE NODE USER CORRELATION DETAIL
2026-10-12T09:40:12.551204Z 01a14701-…-example database:provisioned /projects/acme/environments/dev/assets/db alice 01a14700-…-example -
opschain events show 01a14701-…-example
id: 01a14701-…-example
type: database:provisioned
node path: /projects/acme/environments/dev/assets/db
user: alice
system: false
correlation id: 01a14700-…-example
chain depth: 1
source: step 01a14702-…-example
created at: 2026-10-12T09:40:12.551204Z
payload:
{
"cluster": "prod-a",
"tf_exit": "0"
}
The event's data passes the run's secret masker before storage: a
value the run knows to be secret is stored as ******, never in the
clear. Masking is a backstop, not a channel — don't put secrets in event
data; hand over a vault reference or an id instead.
5. Trigger on it
A custom type goes in a trigger's events list like any lifecycle name,
and conditions query the emitted data:
# deploy_app_on_db_provisioned.yaml
code: deploy_app_on_db_provisioned
subject: change
events: [database:provisioned]
scope_path: /projects/acme/environments/dev
conditions:
- event.data.cluster eq prod-a
action_type: change
action_params:
parent_path: /projects/acme/environments/dev/assets/web
action: web:deploy
opschain triggers create acme -f deploy_app_on_db_provisioned.yaml
created trigger acme/deploy_app_on_db_provisioned (01a14710-…-example)
Prove the wiring before relying on it: dry-run the trigger against the stored event's ID from section 4 —
opschain triggers test acme/deploy_app_on_db_provisioned --event 01a14701-…-example
trigger acme/deploy_app_on_db_provisioned against event 01a14701-…-example:
enabled: yes
subject/event match: yes
scope match: yes
condition event.data.cluster eq prod-a ✓ actual: prod-a
action params: {
"action": "web:deploy",
"parent_path": "/projects/acme/environments/dev/assets/web"
}
verdict: would fire
— then watch real firings with
opschain triggers firings acme/deploy_app_on_db_provisioned --since 1h.
Check yourself
- Emit
database:provisionedonce more with a differentclustervalue and dry-run the trigger against that event: which line of the report changes, and what does the verdict become? Which five prefixes could you never have used for the type?
What you learned
- Custom event types are namespaced
ns:name; theapi: system: info: error: warn:prefixes are the server's and are refused at load or at runtime. - The
emit:step kind announces declaratively, withwith:as${{ … }}-templated event data;events.emitsis advisory metadata. opschain step emit-event --data k=v --data-file k=@pathemits from any step script; either way the event records on step success, sourced from the step, on the change's chain, masked by the run's secret masker, andevents showprints its payload.- Triggers match custom types in
events:and query the payload withevent.data.<key>conditions — dry-run withtriggers testfirst.
Next: lesson 5.5 — Notifications: watches, channels, the bell (opschain learn 5.5), or back to your track (opschain learn).