Skip to main content
A workflow declares what starts it with a 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.
If you omit 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 matches event, 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:
The console’s Events view can post the same request from its Trigger event dialog.

Wildcards

An event name can end in a single trailing * to match a prefix:
The * 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:
The filter is an admission gate: if it isn’t 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 the TZ=/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. Set runOnStart 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:
The immediate run is claimed exactly once even when a fleet registers concurrently. Because it fires on every registration, pair it with 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.

It never touches the schedule

A manual trigger is a fully independent, one-off run. It never reads or writes a cron trigger’s nextFireAt 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 a triggerKind, 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.