Concepts

Triggers

Start a run from exactly the right thing: event triggers with CEL filters and wildcards, cron schedules, or a manual trigger with no event at all.

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.

const fulfillment = defineWorkflow<TicketData>({
  name: "fulfillment",
  triggers: [
    { event: "ticket.created" },
    { event: "ticket.reprocessed", if: "event.data.total > 100" },
    { cron: "TZ=UTC 0 9 * * *" },
  ],
  handler: async (ctx) => {
    // ctx.event.name tells you which event (or cron) started this run
  },
});

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:

await ctx.step.emit("reprocess", {
  name: "ticket.created",
  app: "support-app",
  data: { ticketId: "T-421", total: 250 },
});

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:

triggers: [{ event: "ticket.*" }]   // ticket.created, ticket.shipped, ticket.refunded, ...

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:

triggers: [{ event: "ticket.created", if: 'event.data.total > 100 && event.data.tier == "pro"' }]

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.

triggers: [{ cron: "TZ=Europe/Paris 0 9 * * *" }]  // 09:00 Paris time, every day
triggers: [{ cron: "CRON_TZ=UTC 0 9 * * 1-5" }]    // 09:00 UTC, weekdays
triggers: [{ cron: "@every 30m" }]                 // every 30 minutes
triggers: [{ cron: "TZ=UTC @daily" }]              // descriptor macro, midnight UTC
PartFormDescription
Schedule5-field cron (0 9 * * *), @every <duration>, or a descriptor macroWhen the workflow fires.
Timezone prefixTZ=Area/City or CRON_TZ=Area/CityOptional, and worth setting on every cron: an expression with no prefix is resolved in Duraton's own timezone, not yours. @every is a relative interval and ignores the zone.
Event data{ cron, scheduledFor }The cron spec and the RFC 3339 instant the tick fired for.
event.namethe cron specA cron run has no event name of its own, so ctx.event.name is the spec string.

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.

DescriptorEquivalentFires
@yearly / @annually0 0 1 1 *Once a year, midnight Jan 1
@monthly0 0 1 * *Once a month, midnight on the 1st
@weekly0 0 * * 0Once a week, midnight Sunday
@daily / @midnight0 0 * * *Once a day, midnight
@hourly0 * * * *Once an hour, on the hour

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:

FieldTypeMeaning
lastFiredAtRFC3339, optionalWhen the sweep last claimed a tick for this schedule. Absent if it never has.
isStalebooleantrue once the cadence implies at least two ticks have fully passed since lastFiredAt (or since the workflow's registeredAt, if it has never fired) with none claimed. One missed tick is tolerated as ordinary jitter; two is treated as the sweep itself having stopped.
{
  "cron": "0 9 * * *",
  "nextFireAt": "2026-08-08T09:00:00Z",
  "lastFiredAt": "2026-08-07T09:00:00Z",
  "isStale": false
}

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:

triggers: [{ cron: "TZ=UTC 0 * * * *", runOnStart: true }]  // hourly, plus once on each deploy

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.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });

// Fire the cron-only rollup right now, off its schedule.
const res = await duraton.workflows.trigger("support-app", "metrics.rollup");
res.runId; // absent if a flow-control gate skipped it - see below

// Or override the input a run starts with.
await duraton.workflows.trigger("support-app", "metrics.rollup", {
  input: { scheduledFor: new Date().toISOString() },
});

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:

KindSet onNotes
eventA run started by a matching event trigger.Carries eventId, linking back to the event that fanned it out - see event->run lineage.
cronA run started by a cron trigger's schedule.ctx.event.name is the cron expression; ctx.event.data is { cron, scheduledFor }.
evalA run started by fanning a dataset through a workflow as an eval run-set.Carries the eval run-set's lineage.
manualA run started by triggering a workflow manually - a person or a script, not a schedule or an event.ctx.event.name defaults to the workflow's name, or your eventName override.

Filter the runs list to one kind with ?runType=<kind> - GET /runs?runType=manual, ?runType=eval, and so on. See listing & filtering.

Limits

LimitValueDescription
Wildcard positionfinal character onlyticket.* matches; ticket.*.eu and ** are rejected at registration.
Filter scopethe event variableif sees only event.name and event.data. It cannot read a run, a step, or the clock.
waitForEvent matchingexact name (+ optional CEL if)Wildcards apply to triggers only. step.waitForEvent rendezvouses on an exact event name, with an optional if payload filter for correlated waits.
Causal depth64An emit-triggered run inherits the emitting run's depth + 1, and a run at depth 64 fails rather than emitting again.

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.

On this page