> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```ts theme={null}
const fulfillment = workflow<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](/reference/api/events):

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    await ctx.step.emit("reprocess", {
      name: "ticket.created",
      app: "support-app",
      data: { ticketId: "T-421", total: 250 },
    });
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    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}}'
    ```
  </Tab>
</Tabs>

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:

```ts theme={null}
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](https://cel.dev) expression evaluated against the event. It sees one variable, `event`,
with `event.name` and `event.data`:

```ts theme={null}
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.

```ts theme={null}
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
```

| 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`](/reference/api/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. |

```json theme={null}
{
  "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:

```ts theme={null}
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](#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.

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    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() },
    });
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    # 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"
    ```
  </Tab>
</Tabs>

### 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](#event-triggers) matches it, nothing fans out, and no
[`step.waitForEvent`](/core/steps#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`](/reference/api/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](/core/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](/reference/api/workflows#trigger-a-run-manually). 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](#event-triggers).                                                                  | Carries `eventId`, linking back to the event that fanned it out - see [event->run lineage](/reference/api/runs). |
| `cron`   | A run started by a [cron trigger](#cron-triggers)'s schedule.                                                                  | `ctx.event.name` is the cron expression; `ctx.event.data` is `{ cron, scheduledFor }`.                           |
| `manual` | A run started by [triggering a workflow manually](#trigger-a-run-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=cron`, and
so on. See [listing & filtering](/reference/api/runs#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`](/core/steps#step-waitforevent) rendezvouses on an exact event name, with an optional [`if`](/core/steps#correlated-waits) 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.

<Note>
  See the **scheduling** example running end to end in [Examples](/start/recipes#run-on-a-schedule).
</Note>
