triggers array. A trigger is either an event trigger
(fires on a matching event) or a cron trigger (fires on a schedule). A workflow can mix several.
triggers, the workflow is started by an event whose name equals the workflow name - the
default, so nothing changes for workflows that don’t opt in.
Event triggers
An event trigger fires when an incoming event’s name matchesevent, optionally gated by an if
filter. One event fans out to every matching workflow, and a workflow with several matching triggers
runs once.
An event reaches Duraton two ways - from inside another workflow with the SDK, or from outside over
the REST API:
- TypeScript
- REST API
Wildcards
An event name can end in a single trailing* to match a prefix:
* is allowed only as the final character. A pattern with a * anywhere else is rejected at
registration; there is no mid-string match and no multi-segment **.
Filters
if is a CEL expression evaluated against the event. It sees one variable, event,
with event.name and event.data:
true, the workflow doesn’t start for that event. It
runs once at ingest and never again on replay, so it must not depend on anything but the event.
Cron triggers
A cron trigger fires the workflow on a schedule - no event needed.Descriptor macros
Shorthand for the common 5-field expressions below - same admission rules, same skip-and-forward behavior, and theTZ=/CRON_TZ= prefix still applies.
Missed ticks are not backfilled: if a tick cannot fire, the schedule advances to the next one
(skip-and-forward), and overlapping schedules fire a given tick only once. Cron runs go through the same
retries and flow control as event-triggered runs - pair a frequent cron with
singleton to stop a slow
job overlapping itself.
Detecting a dead schedule
Skip-and-forward is silent by design: a tick that finds nothing to do just advances to the next one, with no run and no error. That means a schedule whose sweep has stopped entirely - the engine was down, or a bug broke the sweep loop - looks the same as a healthy schedule that simply had nothing to do.GET /workflows exposes two fields per schedule so the two are distinguishable from the
outside, with no change to skip-and-forward itself:
isStale is derived at read time from lastFiredAt and the cron expression - Duraton keeps no separate
missed-tick counter, so there is nothing else to poll or reconcile. Alert on isStale: true the same way
you’d alert on a stale heartbeat elsewhere in your stack.
Fire once on start
By default a cron waits for its next scheduled tick. SetrunOnStart to also fire the workflow once
immediately when it is registered (on each runner startup or deploy), for a catch-up run before the
regular schedule takes over:
singleton or an idempotency key if a redeploy must not repeat work.
runOnStart is available in the TypeScript SDK.
Each cron run carries triggerKind: "cron" - see trigger kinds for the full set. In
the console, a scheduled workflow shows a clock badge and its next run time, plus a stale badge when
isStale is true.
Trigger a run manually
A cron trigger needs no event to fire - which means, until now, a cron-only workflow had no event to send it either:POST /events matches by event name, and a cron trigger declares no event pattern to match
against. POST /workflows/{app}/{name}/trigger closes that gap: it starts one run of one workflow by
identity, independent of its declared triggers. It works the same way for every workflow, whether it’s
event-triggered, cron-triggered, both, or neither - this is the cron-only case that had no workaround
before.
- TypeScript
- REST API
It never touches the schedule
A manual trigger is a fully independent, one-off run. It never reads or writes a cron trigger’snextFireAt cursor, so the next scheduled tick fires at exactly the time it always would have, whether or
not you also triggered the workflow manually in between. This is the same guarantee a kubectl create job --from=cronjob/... run gives a Kubernetes CronJob, or a manual run of a GitHub Actions workflow that also
has an on: schedule trigger: firing one now never reschedules the recurring one.
eventName is a label, not an event
The optional eventName field only sets ctx.event.name on the run it starts - it does not fire an
event. No workflow’s event trigger matches it, nothing fans out, and no
step.waitForEvent anywhere wakes up.
That’s the key difference from the console’s Events view and its Trigger event dialog (backed by
POST /events): sending a real event fans out to every workflow whose trigger matches
that event name and can resume parked waiters. Triggering a workflow manually starts exactly one run of
exactly one workflow - nothing else. Both stay useful for different jobs: reach for /events to
exercise your event-driven fan-out, and the trigger endpoint to just run one workflow, right now,
regardless of what’s declared to start it.
Set eventName when you want the manually-started run to look like it came from a specific event - the
same if-branch behavior a real event-triggered run would take, if your handler inspects
ctx.event.name. Leave it out and ctx.event.name defaults to the workflow’s own name.
Flow control still applies
Flow control - debounce, batch, rate-limit, singleton, idempotency - gates a manual trigger exactly the way it gates an event- or cron-triggered run. A manual trigger is not a bypass. That means a request can legitimately produce no run: a singleton workflow already in flight skips it, a debounced workflow coalesces it into the pending window, and so on. The response reports which gate fired (skipped / dropped / debounced / batched / deduped) instead of a runId - see the full response
shape. Render that as an explicit outcome (“already running,
skipped”) rather than a failure; a missing runId is not an error.
Trigger kinds
Every run carries atriggerKind, recording what started it:
Filter the runs list to one kind with
?runType=<kind> - GET /runs?runType=manual, ?runType=cron, and
so on. See listing & filtering.
Limits
step.emit publishes through the same matching path as an external event, so an emitted event can fan
out to wildcard-matching workflows - including, by accident, back to the workflow that emitted it. The
depth cap stops such a cycle from looping forever, but it still burns 64 runs getting there. Do not
build one.
See the scheduling example running end to end in Examples.