Named schedules trigger Flows on a calendar cron **or** a fixed duration. Declare with
`clock` / helpers, set the zone once on the app, then bind with `on(clockDecl, flow)`.

For developers running daily digests, health pings, and per-tenant billing loops on okengine.

<Callout title="The one rule">
  Calendar work: prefer `oke({ clock: { timezone } })` (or `defineConfig`) and omit
  `timezone` on each schedule. Intervals: durations are `ms|s|m|h|d` only — no weeks.
  Both shapes share leader locks, catch-up `"one"`, and the Store.
</Callout>

<ClockSchedules />

## Smallest Example

<Steps>

<Step>
### Declare a schedule and bind a Flow

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const dailyReportClock = clock.daily("reports.daily", { at: "06:00" });
```

```typescript title="src/flows/reports/daily.ts"
import { on, flow } from "okengine";
import { dailyReportClock } from "@/clocks/reports";

export const runDailyReport = on(
  dailyReportClock,
  flow("reports.runDaily", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(buildDailyReport, { at: fx.clock.now() });
    },
  }),
);
```

Set the zone once so each schedule stays short:

```typescript title="src/app.ts"
import { oke } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});
```

Or `defineConfig({ clock: { timezone: "Asia/Riyadh" } })`. `oke({ clock })` wins over
config; a per-clock `timezone` wins over both.

</Step>

<Step>
### What fires

Every morning at 06:00 in the declared zone, the leader instance runs `reports.runDaily`.
`do` receives no payload — use `fx.clock.now()` when you need the fire instant.

With `oke dev`, the scheduler reconciles `reports.daily` into the Store and leader-elects
before each tick. Under `drivers.clock.test = "frozen"`, advance time in tests instead of
waiting for dawn.

</Step>

</Steps>

<Callout title="Jobs are Flows">
  `on(clockDecl, flow)` is the same species as HTTP handlers and Signal consumers. There is no
  separate job runner — see [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs).
</Callout>

## Progressive Patterns

From helpers and intervals to per-tenant rows and Console-tunable schedules:

<Tabs items={["Helpers", "Interval", "Per-tenant", "Complex fields", "Overridable"]}>

<Tab value="Helpers">

Named helpers compile to the same `ClockDecl` (a five-field `cron` or `every` string):

```typescript title="src/clocks/reports.ts"
import { clock } from "okengine";

export const daily = clock.daily("reports.daily", { at: "06:00" });
export const rollup = clock.hourly("metrics.rollup", { minute: 0 });
export const digest = clock.weekly("notes.digest", {
  on: ["mon", "fri"],
  at: "09:00",
});
export const close = clock.monthly("billing.close", { on: [1, 15], at: "00:00" });
export const ping = clock.every("health.ping", "30s");
```

Prefer the named helpers above for fixed schedules. The bare callable remains fully
supported for the same `ClockDecl` shape when you need a raw string or a schedule
chosen programmatically: `clock("x", { cron: "0 6 * * *" })`, Bun nicknames
(`@hourly`), and `clock("x", { every: "30s" })`.

**Consequence:** Manifest / Store still see `cron: "0 6 * * *"` — helpers are declare-time sugar.

</Tab>

<Tab value="Interval">

Fixed duration loops — `"200ms"` · `"30s"` · `"5m"` · `"1h"` · `"7d"`:

```typescript title="src/clocks/health.ts"
import { clock } from "okengine";

export const pingClock = clock.every("health.pingExternal", "30s");
```

```typescript title="src/flows/health/ping.ts"
import { on, flow } from "okengine";
import { pingClock } from "@/clocks/health";

export const pingExternal = on(
  pingClock,
  flow("health.pingExternal", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(pingUpstream);
    },
  }),
);
```

Combine with injectable offsets inside `do`:

```typescript
await fx.call(dropExpired, { before: fx.clock.ago("1h") });
```

Unknown duration strings parse as `0` ms and never become due. A `"d"` is exactly
86_400_000 ms — not a calendar day across DST. No week unit; no jitter on `clock()`
(retry jitter lives on Flow `retry`).

</Tab>

<Tab value="Per-tenant">

`clock.perTenant` expands one Store row per tenant (`{name}#{tenantId}`). The bare
template name is never ticked:

```typescript title="src/clocks/invoices.ts"
import { clock } from "okengine";

export const invoicesClock = clock.perTenant("invoices", { every: "1h" });
```

```typescript title="src/flows/billing/invoices.ts"
import { on, flow } from "okengine";
import { invoicesClock } from "@/clocks/invoices";

export const runInvoices = on(
  invoicesClock,
  flow("billing.invoices", {
    plane: "operator",
    do: async (_, fx) => {
      await fx.call(closeOpenInvoices);
    },
  }),
);
```

Equivalent bare form (same decl; prefer `clock.perTenant` above):
`clock("invoices", { every: "1h", perTenant: true })`.

**Consequence:** ten tenants → ten leader-elected rows. Catch-up still fires **once per
row** after downtime — not a burst of missed hours per tenant.

</Tab>

<Tab value="Complex fields">

`clock.cron` accepts a string **or** a field bag (lists, ranges, steps, weekday names):

```typescript title="src/clocks/ops.ts"
import { clock } from "okengine";

export const sweep = clock.cron("ops.sweep", {
  minute: "*/15",
  hour: [9, 12, 17],
  dayOfWeek: "1-5",
});

export const digest = clock.cron("ops.digest", {
  at: "08:00",
  dayOfWeek: ["mon", "wed", "fri"],
});
```

| Field                                      | Examples                                            |
| ------------------------------------------ | --------------------------------------------------- |
| `minute` / `hour` / `dayOfMonth` / `month` | `0` · `[9, 12, 17]` · `"1-5"` · step strings        |
| `dayOfWeek`                                | `1` · `"mon"` · `["mon", "fri"]` · `"1-5"`          |
| `at`                                       | `"06:00"` — sets minute + hour when those are unset |

Only standard five-field cron (what Bun parses). No “last day of month” / “nth weekday”.

You may set both `cron` and `every` on one declaration; extract records the cron as the
Manifest trigger when both are present. Prefer one primary shape unless you want both.

</Tab>

<Tab value="Overridable">

`overridable: true` lets Console edit the effective cron/every in the Store:

```typescript title="src/clocks/digest.ts"
import { clock } from "okengine";

export const digestClock = clock.daily("notes.digest", {
  at: "08:00",
  overridable: true,
});
```

Without it, a Console edit fails with `ScheduleNotOverridableError`
(`clock "{name}" is not overridable`).

</Tab>

</Tabs>

## Helper Reference

| Helper / form     | Signature                                 | Default / notes                                                    | Compiles to           |
| ----------------- | ----------------------------------------- | ------------------------------------------------------------------ | --------------------- |
| `clock`           | `clock(name, { cron? \| every?, … })`     | Need `cron` and/or `every`                                         | same                  |
| `clock.daily`     | `clock.daily(name, { at?, … })`           | `at` default `"00:00"`; invalid `at` → `cron at: expected "HH:MM"` | five-field cron       |
| `clock.hourly`    | `clock.hourly(name, { minute?, … })`      | `minute` default `0`                                               | five-field cron       |
| `clock.weekly`    | `clock.weekly(name, { on, at?, … })`      | `on` required (`sun`…`sat` / `0–6`); `at` default `"00:00"`        | five-field cron       |
| `clock.monthly`   | `clock.monthly(name, { on, at?, … })`     | `on` required (day of month); no “last day” token                  | five-field cron       |
| `clock.cron`      | `clock.cron(name, expr \| fields, opts?)` | Empty field bag throws; invalid string → `Bun.cron.parse`          | five-field / nickname |
| `clock.every`     | `clock.every(name, duration, opts?)`      | Same duration grammar as `fx.clock.ago` / sleep                    | `every` string        |
| `clock.perTenant` | `clock.perTenant(name, opts)`             | Expands `{name}#{tenantId}` rows                                   | same + `perTenant`    |

At least one of `cron` or `every` is required on the callable form. Empty options throw
`clock("name"): require cron or every`. Invalid cron throws at declare (`invalid cron …`).

An empty `clock.cron` field bag throws
`cron fields: require at least one of at, minute, hour, dayOfMonth, month, dayOfWeek`.

## Timezone Resolution

Cron math needs an IANA zone. Resolve it once; override only when a schedule must differ.

| Source                                  | Wins when           | Example                                                    |
| --------------------------------------- | ------------------- | ---------------------------------------------------------- |
| Per-clock `timezone`                    | Always              | `clock.daily("x", { at: "06:00", timezone: "UTC" })`       |
| `oke({ clock: { timezone } })`          | No per-clock zone   | `oke({ name: "app", clock: { timezone: "Asia/Riyadh" } })` |
| `defineConfig({ clock: { timezone } })` | No `oke({ clock })` | config default                                             |
| Built-in default                        | Nothing else set    | `"UTC"`                                                    |

```typescript title="src/app.ts"
import { oke } from "okengine";
import { clock } from "okengine";

export const app = oke({
  name: "notes",
  clock: { timezone: "Asia/Riyadh" },
});

// Inherits Asia/Riyadh
export const digest = clock.daily("notes.digest", { at: "08:00" });

// Explicit UTC wins over the app default
export const utcRollup = clock.hourly("metrics.utc", {
  minute: 0,
  timezone: "UTC",
});
```

`{ tz: "…" }` is not an option — it is ignored. Intervals (`every`) are duration-based and
do not consult the zone for tick spacing; the zone still lands on the Store row.

## Binding & Input

Bind with `on(clockDecl, flow("name", { do }))`. One Flow can write `clock.every`
inside `on()` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
`do` has no payload; read time through `fx.clock`:

```typescript title="src/clocks/metrics.ts"
import { clock } from "okengine";

export const cleanupClock = clock.every("metrics.cleanup", "1h");
```

```typescript title="src/flows/metrics/cleanup.ts"
import { on, flow } from "okengine";
import { lt } from "drizzle-orm";
import { cleanupClock } from "@/clocks/metrics";
import { db, metricLogs } from "@/schema";

export const cleanup = on(
  cleanupClock,
  flow("metrics.cleanup", {
    plane: "operator",
    do: async (_, fx) => {
      await fx
        .store(db)
        .delete(metricLogs)
        .where(lt(metricLogs.timestamp, fx.clock.ago("7d")));
      return { at: new Date(fx.clock.now()).toISOString() };
    },
  }),
);
```

| Concern | Rule                                                               |
| ------- | ------------------------------------------------------------------ |
| Trigger | `on(clockDecl, flow)` — same Flow species as HTTP / Signal         |
| Input   | none — use `_`                                                     |
| Time    | `fx.clock.now` / `ago` / `fromNow` / `duration` only               |
| Plane   | Prefer `plane: "operator"` for background work                     |
| Import  | The `clock(…)` module must load at boot so reconcile sees the decl |

## Options Reference

Shared options on `clock(name, options)` and helpers:

| Option / helper                         | Type        | Default               | Meaning                                    |
| --------------------------------------- | ----------- | --------------------- | ------------------------------------------ |
| `cron`                                  | `string`    | —                     | Five-field cron or Bun nickname            |
| `every`                                 | `string`    | —                     | Interval (`"30s"`, `"1h"`, `"7d"`, …)      |
| `timezone`                              | IANA string | app default / `"UTC"` | Zone for cron math                         |
| `overridable`                           | `boolean`   | `false`               | Console may override schedule in the Store |
| `perTenant`                             | `boolean`   | `false`               | Expand `{name}#{tenantId}` rows            |
| `description`                           | `string`    | the name              | Console / docs blurb                       |
| `oke({ clock: { timezone } })`          | app option  | —                     | Default zone when `timezone` omitted       |
| `defineConfig({ clock: { timezone } })` | config      | —                     | Same; overridden by `oke({ clock })`       |

### Duration units (`every`)

| Unit | Example   | Milliseconds |
| ---- | --------- | ------------ |
| `ms` | `"200ms"` | 200          |
| `s`  | `"30s"`   | 30_000       |
| `m`  | `"5m"`    | 300_000      |
| `h`  | `"1h"`    | 3_600_000    |
| `d`  | `"7d"`    | 604_800_000  |

## Per-tenant Schedules

`clock.perTenant` expands one Store row per tenant (`{name}#{tenantId}`). The bare
template name never ticks — `runNow("invoices")` returns `false`.

Tenant ids come from reconcile (`tenantIds`). New tenants get rows; deleted tenants mark
those rows `orphaned`.

| Shape                    | Example         | Fires?               |
| ------------------------ | --------------- | -------------------- |
| Template (declared name) | `invoices`      | No — never ticked    |
| Expanded row             | `invoices#acme` | Yes — leader-elected |

**Consequence:** ten tenants → ten leases. Catch-up is `"one"` **per row**, so downtime
does not replay a burst of missed hours for each tenant.

## Leader Lock

Multi-instance deploys need a shared clock driver — otherwise every pod fires the same
tick. Dev/prod default is **postgres** (test is **frozen**). A short lease (default
**30s**) means only one instance runs each fire.

**Consequence:** three pods calling `runNow` still execute the Flow once. After the lease
expires, another instance may take the next tick.

| Driver     | Cross-instance lock                       | Best for                          |
| ---------- | ----------------------------------------- | --------------------------------- |
| `postgres` | Yes — shared `oke_crons`                  | Dev/prod multi-instance (default) |
| `file`     | Yes — on one host (`.oke/crons.json`)     | Single-host multi-process         |
| `memory`   | No — single process only                  | Local single process              |
| `frozen`   | Test harness — you drive `tick` / advance | Tests                             |

## Catch-up Policy

<ClockCatchUp />

Catch-up is `"one"`: after 5 hours down on an hourly clock, the next tick fires **once**.
Missed slots are visible as `missedRuns` — they are not replayed as a burst.

**Consequence:** a digest that missed the night still runs once at boot, not 24 times.

Console health on each row exposes `driftMs`, `overdue`, `missedRuns`, and `catchUp: "one"`.
Flow `retry` is separate — it retries a failed fire, not missed calendar slots.

## Store Lifecycle & Console

Named clocks reconcile into the Store at boot. The scheduler reads **effective** state from
the Store, never the source declaration directly.

```text
Declared  (Manifest / code)     ← truth for names and defaults
Override  (Store, overridable)  ← operational drift
Effective = declared + override ← what actually runs
```

| Status     | Meaning                       | Fires?                       |
| ---------- | ----------------------------- | ---------------------------- |
| `active`   | Reconciled and enabled        | Yes (when due + lease)       |
| `paused`   | Operator paused via Console   | No — until `active` again    |
| `orphaned` | Decl removed (or tenant gone) | No — row kept, never deleted |

Reconcile upserts still-declared clocks as `active` (preserving lease / `lastRunAt` /
overrides when `overridable`). Removed declarations become `orphaned`.

| Console action  | Requirement         | Error when missing                                     |
| --------------- | ------------------- | ------------------------------------------------------ |
| Run now         | Active row + lease  | `ClockResourceNotFoundError` — `cron "{id}" not found` |
| Pause           | Existing row        | same                                                   |
| Edit cron/every | `overridable: true` | `ScheduleNotOverridableError`                          |

**Consequence:** without `overridable: true`, change the schedule in source and redeploy —
Console edits are refused. Redeploying a still-overridable clock **preserves** overrides.

DST gap / fall-back overlap attaches a Store warning (`gap` / `overlap`); UTC never
warns; the scheduler still ticks. Detection covers simple `M H * * *` / `M H * * DOW`
forms; on overlap days crontab fires **once** (first occurrence).

| Prefer                                          | When                                 |
| ----------------------------------------------- | ------------------------------------ |
| `timezone: "UTC"`                               | No DST warnings                      |
| Non-ambiguous local hour (`08:00`, not `02:00`) | Digests that stay silent on DST days |

## Troubleshooting

<Accordions>

<Accordion title='TypeError: clock("name"): require cron or every'>
  Pass `{cron}` and/or `{every}`, or use a helper (`clock.daily`, `clock.every`, …). Declaration
  throws before `on()`.
</Accordion>

<Accordion title="invalid cron at declare">
  Cause: the expression failed `Bun.cron.parse`. Fix the five-field string / nickname, or fix
  structured fields (`at` must be `"HH:MM"`). Error shape: `clock("…"): invalid cron "…" (…)`.
</Accordion>

<Accordion title='cron at: expected "HH:MM"'>
  `at` on helpers / field bags must be `"HH:MM"` or `"H:MM"` with hour `0–23` and minute `0–59`.
  `"6am"` and `"06:00:00"` fail.
</Accordion>

<Accordion title="Interval never fires">
  Unknown duration strings parse as `0` and never become due. Use `ms|s|m|h|d` only. Confirm the
  Flow is bound with `on(clockDecl, flow)` and the clock module is imported at boot.
</Accordion>

<Accordion title="tz is not a clock option">
  Use `timezone: "Asia/Riyadh"` on the declaration, or set the app default with
  `oke({ clock: { timezone: "Asia/Riyadh" } })` /
  `defineConfig({ clock: { timezone: "Asia/Riyadh" } })`. `{ tz: "…" }` is ignored.
</Accordion>

<Accordion title="Cron / interval fired many times after downtime">
  Catch-up is `"one"` per Store row. A burst usually means several clocks or `clock.perTenant` rows
  (one per tenant), not a replay of missed slots.
</Accordion>

<Accordion title="Two pods ran the same job">
  Need shared `drivers.clock` postgres (or `file` on one machine). `memory` does not elect a leader
  across processes.
</Accordion>

<Accordion title="Bare template name never ticks">
  Expected for `perTenant: true` — only `{name}#{tenantId}` rows fire. Ensure tenant ids are
  available at reconcile.
</Accordion>

<Accordion title="ScheduleNotOverridableError from Console">
  Cause: `clock "{name}" is not overridable`. Add `overridable: true` and redeploy, or change the
  schedule in source.
</Accordion>

<Accordion title="ClockResourceNotFoundError">
  Cause: `cron "{id}" not found` (or `run "{id}" not found`). The Console action targeted a name
  that is not in the reconciled Store — check spelling against your `clock()` declarations.
</Accordion>

<Accordion title="OKE1072 — Clock flow unnamed">
  Cause: `A clock flow on "{trigger}" has no name.`
  Fix: pass an explicit name — `on(clockDecl, flow("reports.runDaily", { do }))`.
  Inline `clock.every` still needs `flow("…")` — [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export).
</Accordion>

<Accordion title="DST gap / overlap warning on the row">
  Informational. Pick a UTC cron, a non-ambiguous local hour, or accept the warning. The job still
  schedules.
</Accordion>

<Accordion title="Paused schedule never fires">
  Console pause sets `status: "paused"`. The scheduler skips it. A later reconcile of a
  still-declared clock restores `active` (and keeps overrides when `overridable`).
</Accordion>

</Accordions>

## Learn more

- [Clock overview](/docs/elements/clock) — `fx.clock` helpers and drivers
- [Clock · Inline or named export](/docs/elements/clock#inline-or-named-export) — one Flow vs shared schedule
- [Durable Sleep](/docs/elements/clock/sleep) — `fx.clock.sleep(label, duration)`
- [Consumers · Clock Jobs](/docs/elements/flow/consumers#clock-jobs) — bind with `on(clockDecl, flow)`
- [fx](/docs/reference/fx) — `fx.clock.now` / `ago` / `fromNow` / `duration`
- [Errors](/docs/reference/errors) — `ScheduleNotOverridableError` · `ClockResourceNotFoundError` · OKE1072

## Next

<Cards>
  <Card
    title="Durable Sleep"
    description="Journaled pauses with fx.clock.sleep(label, duration)."
    href="/docs/elements/clock/sleep"
  />
  <Card
    title="Clock Overview"
    description="Return to the Clock element overview."
    href="/docs/elements/clock"
  />
  <Card
    title="Consumers"
    description="Signal workers, named Clock jobs, and SQL CDC."
    href="/docs/elements/flow/consumers"
  />
</Cards>
