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
},
});from duraton import define_workflow
from duraton.context import StepContext
async def fulfillment_handler(ctx: StepContext) -> object:
# ctx.event.name tells you which event (or cron) started this run
...
fulfillment = define_workflow(
"fulfillment",
fulfillment_handler,
triggers=[
{"event": "ticket.created"},
{"event": "ticket.reprocessed", "if": "event.data.total > 100"},
{"cron": "TZ=UTC 0 9 * * *"},
],
)var fulfillment = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "fulfillment",
Triggers: []duraton.Trigger{
{Event: "ticket.created"},
{Event: "ticket.reprocessed", If: "event.data.total > 100"},
{Cron: "TZ=UTC 0 9 * * *"},
},
Handler: func(c *duraton.Context) (any, error) {
// the event name tells you which event (or cron) started this run
return nil, nil
},
})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 },
});await ctx.step.emit(
"reprocess",
name="ticket.created",
app="support-app",
data={"ticketId": "T-421", "total": 250},
)err = duraton.Emit(c, "reprocess", duraton.EmitOptions{
Name: "ticket.created",
App: "support-app",
Data: map[string]any{"ticketId": "T-421", "total": 250},
})curl -X POST $DURATON_URL/events \
-H "Authorization: Bearer $DURATON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"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, ...triggers=[{"event": "ticket.*"}] # ticket.created, ticket.shipped, ticket.refunded, ...Triggers: []duraton.Trigger{{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"' }]triggers=[{"event": "ticket.created", "if": 'event.data.total > 100 && event.data.tier == "pro"'}]Triggers: []duraton.Trigger{{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 UTCtriggers=[{"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 UTCTriggers: []duraton.Trigger{{Cron: "TZ=Europe/Paris 0 9 * * *"}} // 09:00 Paris time, every day
Triggers: []duraton.Trigger{{Cron: "CRON_TZ=UTC 0 9 * * 1-5"}} // 09:00 UTC, weekdays
Triggers: []duraton.Trigger{{Cron: "@every 30m"}} // every 30 minutes
Triggers: []duraton.Trigger{{Cron: "TZ=UTC @daily"}} // descriptor macro, midnight UTC| Part | Form | Description |
|---|---|---|
| Schedule | 5-field cron (0 9 * * *), @every <duration>, or a descriptor macro | When the workflow fires. |
| Timezone prefix | TZ=Area/City or CRON_TZ=Area/City | Optional, 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.name | the cron spec | A 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.
| Descriptor | Equivalent | Fires |
|---|---|---|
@yearly / @annually | 0 0 1 1 * | Once a year, midnight Jan 1 |
@monthly | 0 0 1 * * | Once a month, midnight on the 1st |
@weekly | 0 0 * * 0 | Once a week, midnight Sunday |
@daily / @midnight | 0 0 * * * | Once a day, midnight |
@hourly | 0 * * * * | 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:
| Field | Type | Meaning |
|---|---|---|
lastFiredAt | RFC3339, optional | When the sweep last claimed a tick for this schedule. Absent if it never has. |
isStale | boolean | true 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 deployThe 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() },
});from duraton.client import AsyncDuratonClient, TriggerWorkflowInput
async with AsyncDuratonClient() as dx:
# Fire the cron-only rollup right now, off its schedule.
res = await dx.workflows.trigger("support-app", "metrics.rollup")
res.run_id # None if a flow-control gate skipped it - see below
# Or override the input a run starts with.
await dx.workflows.trigger(
"support-app",
"metrics.rollup",
TriggerWorkflowInput(input={"scheduledFor": "2026-08-07T09:00:00Z"}),
)import (
"context"
"encoding/json"
"duraton.dev/sdk-go/client"
)
dx := client.New(client.Options{})
// Fire the cron-only rollup right now, off its schedule.
res, err := dx.TriggerWorkflow(context.Background(), "support-app", "metrics.rollup", client.TriggerWorkflowInput{})
res.RunID // empty if a flow-control gate skipped it - see below
// Or override the input a run starts with.
_, err = dx.TriggerWorkflow(context.Background(), "support-app", "metrics.rollup", client.TriggerWorkflowInput{
Input: json.RawMessage(`{"scheduledFor":"2026-08-07T09:00:00Z"}`),
})# Fire the cron-only rollup right now, off its schedule.
curl -X POST "$DURATON_URL/workflows/support-app/metrics.rollup/trigger" \
-H "Authorization: Bearer $DURATON_API_KEY"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:
| Kind | Set on | Notes |
|---|---|---|
event | A run started by a matching event trigger. | Carries eventId, linking back to the event that fanned it out - see event->run lineage. |
cron | A run started by a cron trigger's schedule. | ctx.event.name is the cron expression; ctx.event.data is { cron, scheduledFor }. |
eval | A run started by fanning a dataset through a workflow as an eval run-set. | Carries the eval run-set's lineage. |
manual | A 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
| Limit | Value | Description |
|---|---|---|
| Wildcard position | final character only | ticket.* matches; ticket.*.eu and ** are rejected at registration. |
| Filter scope | the event variable | if sees only event.name and event.data. It cannot read a run, a step, or the clock. |
waitForEvent matching | exact 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 depth | 64 | An 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.