Event triggers
defineEventTrigger — bind a domain event name to a workflow. ctx.events.emit(name, data) fans the event out to every matching trigger with filter, payload-mapping, and idempotency.
A workflow is usually kicked off directly (ctx.workflows.start(...)). An event trigger decouples that: a *.trigger.tsx file binds a domain event name to a workflow name, and any handler that calls ctx.events.emit('event.name', data) fans the event out to every trigger listening for it. The emitter never names the workflow — "something happened" is separated from "run this workflow", so you add reactions without touching the code that emits.
// triggers/orderPaid.trigger.tsx
import { defineEventTrigger } from '@voltro/runtime'
export default defineEventTrigger({
event: 'order.paid', // the event name to listen for
workflow: 'fulfilment.run', // the workflow to start
// Optional: skip when the predicate returns false.
filter: (e) => e.data.total > 0,
// Optional: map the event envelope → the workflow's payload (default: e.data).
payload: (e) => ({ orderId: e.data.orderId, tenantId: e.data.tenantId }),
// Optional: dedup key — two emits with the same key start the workflow once.
idempotencyKey: (e) => `fulfil:${e.data.orderId}`,
})Discovery walks every *.trigger.tsx; the default export must be a defineEventTrigger(...) descriptor (one per file). The workflow runtime is active whenever the app has any workflow OR any trigger.
Emitting an event
From any handler, emit through ctx.events:
const execute = async (input: { orderId: string }, ctx: AppContext) => {
// ... mark the order paid ...
const result = await ctx.events.emit('order.paid', {
orderId: input.orderId,
tenantId: ctx.request.subject.tenantId,
total: order.total,
})
// result.eventId — the persisted event id
// result.triggered — one entry per matched trigger:
// { triggerId, workflowName, status: 'started' | 'skipped' | 'failed', run?, reason? }
}emit(name, data, options?) accepts { id?, source? } (override the event id / source label). It returns { eventId, triggered } as soon as the workflows are kicked off — the workflows themselves run durably in the background, exactly like ctx.workflows.start.
The event envelope every filter / payload / idempotencyKey receives is:
{ id, name, data, occurredAt, source, subject?, traceId? }subject + traceId are inherited from the emitting request, so a triggered workflow continues the same trace and carries the same actor.
Matching + delivery
- Exact + wildcard. A trigger matches its exact
eventname; a trigger registered for'*'matches every event (and runs in addition to exact matches). - Idempotency. Before starting, the runtime checks for an existing delivery with the same
idempotencyKey(default<eventId>:<triggerId>) — a duplicate is recorded asstatus: 'skipped', reason: 'duplicate'and the workflow is not started twice. - Every emit is audited. The event itself lands in
_voltro_workflow_events; one row per trigger lands in_voltro_workflow_event_deliverieswithstatus(starting → started, orskipped/failed), the startedexecutionId, and any error message. Both tables are reactive — the dashboard's Workflows view surfaces deliveries live.
When to use it
Reach for an event trigger when ONE thing happening should fan out to several independent reactions, or when you want the emitter to stay ignorant of the consumers:
order.paid→ start fulfilment AND a receipt-email workflow AND an analytics rollup — three triggers, oneemit.- A mutation emits
user.signedUp; an onboarding workflow trigger starts the drip sequence. The signup mutation never imports the onboarding workflow.
If the handler already knows exactly which workflow to run and there's only one, call ctx.workflows.start(...) directly — the trigger indirection only pays off when you're decoupling emit from react, or fanning one event out to many.