Event-triggered workflows
Start a workflow when an external event fires — an AdonisJS emitter event (`@OnEvent`) or a `@adonis-agora/diagnostics` channel (`@OnDiagnostic`) — with the event payload as the run input.
A scheduled workflow starts on time; an event-triggered workflow starts when something happens — a payment succeeded, a webhook arrived, a document changed. Instead of you wiring "event → engine.start(...)" by hand, the workflow class declares its triggers and the durable provider bridges the event source into the engine for you.
This page is about the sources. The engine side (publishEvent, onEvent, ctx.waitForEvent, coalescing and buffering) is covered in the Events guide. Two sources are supported:
- the AdonisJS emitter (
@OnEvent) — exact event names, - the
@adonis-agora/diagnosticsbus (@OnDiagnostic) —agora:<lib>:<event>channels, exact or regex, plus "every event of a lib".
Authoring — decorators and static on
import { BaseWorkflow, OnEvent, OnDiagnostic, type WorkflowCtx } from '@adonis-agora/durable'
@OnDiagnostic({ lib: 'payments', event: 'payment.succeeded' })
@OnEvent({ event: 'agora:payments:payment.succeeded' })
export class ProcessPaymentWorkflow extends BaseWorkflow {
static workflow = { name: 'process-payment' }
async run(ctx: WorkflowCtx, input: { externalReference: string }) {
// input is the event payload
}
}Both forms are supported, exactly like schedules:
@OnEvent({ event })/@OnDiagnostic({ lib, event? })— named-parameter decorators, and- a colocated
static onliteral, one trigger or an array (the decorators only stampstatic on):
static on = [
{ source: 'emitter', event: 'agora:payments:payment.succeeded' },
{ source: 'diagnostics', lib: 'payments', event: 'payment.succeeded' },
{ source: 'diagnostics', lib: 'payments', event: /^payment\./ }, // regex over events
{ source: 'diagnostics', lib: 'payments' }, // every event of the lib
]Repeated decorators and an existing static on accumulate (decorators prepend, top-first).
The first example declares both sources for the same fact. Each exact trigger maps to a canonical engine event name: the literal event name for @OnEvent, and agora:<lib>:<event> for @OnDiagnostic. Here both resolve to agora:payments:payment.succeeded, so the workflow is subscribed once, but each source that fires publishes separately. If the library emits the fact on the emitter and on the diagnostics bus, you get two runs. Declare the one source you actually consume.
Source capabilities
The Adonis emitter has no wildcard, so @OnEvent matches exact names only. Regex / "every event" is available on the diagnostics bus, whose channel registry the bridge subscribes to (current and future channels).
How it fires
- Exact triggers route through
engine.publishEvent(canonical, payload), which starts a fresh run of every workflow registered with that canonicalonEventname, with the payload as input. The same publish also resumes any run parked onctx.waitForEvent(canonical). The bridge passes noopts.id, so every emitted event starts its subscribers once. To dedupe an upstream's redeliveries, callpublishEventyourself with the upstream's id (see Idempotent publishing). - Regex / any triggers start the workflow directly with
engine.start(workflow, payload, <fresh uuid>), because the concrete channel name isn't known at registration time. They bypasspublishEvent: each message starts exactly one run with a new id (no dedupe), and they never resume actx.waitForEventnor buffer a copy.
For both kinds, the run input still goes through the workflow's inputSchema, so a payload it rejects starts nothing.
For an emitter trigger, the emitted data is the run input. For a diagnostics trigger, the channel envelope's payload becomes the run input. The payload type is whatever the emitting library published (e.g. @adonis-agora/payments' payment.succeeded payload with externalReference).
Wiring
No config needed. The provider reads the triggers from discovered workflow classes (static on) and bridges the app's emitter (when resolvable) + the diagnostics channels at boot. If the emitter is unavailable, emitter triggers are inert; diagnostics triggers work regardless (they use node:diagnostics_channel directly).
A few consequences of how the bridge is wired:
- It is in-process. Both the emitter and
node:diagnostics_channelare local to one Node process, so an event starts runs only in the process that emitted it. A fact emitted on a web replica is published by that replica; workers don't see it. To fan a fact out across processes, callengine.publishEventfrom a shared transport consumer instead. - It needs an engine. The bridge is attached by the provider's
booton the roles that own one (standaloneandcontrol-plane). A store-lesstenantpod attaches no bridge, and neither does a warmed-up command (node ace codegenand similar) that never starts the app. - Regex / "every event" needs the diagnostics registry. Exact diagnostics triggers subscribe to their channel directly. Regex and any-event triggers discover the current and future
agora:<lib>:*channels through the@adonis-agora/diagnosticsregistry, so they stay inert until that package is loaded.
Triggering from your own app events
@OnEvent works with any event on the app emitter, not only library events. Declare the event in EventsList so emitter.emit type-checks:
import { BaseWorkflow, OnEvent, type WorkflowCtx } from '@adonis-agora/durable'
declare module '@adonisjs/core/types' {
interface EventsList {
'user:registered': { email: string }
}
}
@OnEvent({ event: 'user:registered' })
export default class SendWelcomeWorkflow extends BaseWorkflow {
static workflow = { name: 'send-welcome' }
async run(ctx: WorkflowCtx, input: { email: string }) {
await ctx.step('mail.send-welcome', input)
}
}import emitter from '@adonisjs/core/services/emitter'
await emitter.emit('user:registered', { email: user.email }) // starts one send-welcome runEvent payloads → workflow input
The webhook-style flow that motivated this: a payment provider publishes payment.succeeded on agora:payments:payment.succeeded (via its diagnostics bridge); the workflow below runs once per event, with the payment's externalReference — no controller, no manual engine.start:
@OnDiagnostic({ lib: 'payments', event: 'payment.succeeded' })
export class ProcessPaymentWorkflow extends BaseWorkflow {
static workflow = { name: 'process-payment' }
async run(ctx: WorkflowCtx, input: { externalReference?: string }) {
if (!input.externalReference) return
// grant the purchased credits / activate the subscription — durable owns retry + exactly-once
}
}Persisted schedules
engine.schedules — Temporal-style schedules created, updated, paused, resumed, triggered and deleted at runtime, persisted in the store (durable_schedules), fired by the durable:work worker tick when persistedSchedules is on, with cron + timezone or fixed intervals, jitter, overlap and catch-up policies. Safe with any number of workers.
Events
Name-based pub/sub between your app and its workflows. engine.publishEvent(name, payload) resumes every run parked on a matching ctx.waitForEvent and starts every onEvent subscriber, with idempotent publishing, debounce/batch coalescing for bursty sources, and buffered delivery when nobody is listening yet.