Guide

Schedules

Schedules let you fire signals or broadcasts at runtime-defined intervals or calendar-aware cron expressions without redeploying. They're stored in a schedule adapter and reconciled on every poll tick. Operators can add, edit, pause and remove them through the dashboard or the v1 API.

File-defined .every() schedules in signal and broadcast definitions are unchanged. Runtime schedules live alongside them in the same store; the two systems are additive, not exclusive.


The three kinds

A schedule's kind says what it fires:

KindTargetTriggers
signalSignal nameThe signal runner picks it up and dispatches a normal run.
broadcast-staticFile-defined broadcast nameThe broadcast runner triggers the registered DAG.
broadcast-dynamicDynamic broadcast nameThe broadcast runner snapshots the current spec and triggers it, same as a manual trigger.

Each schedule carries an optional input that's passed to the target on every fire. For signals it's the signal's input; for broadcasts it's the trigger input handed to the entry node.


The Schedule record

interface Schedule {  id: string;  kind: "signal" | "broadcast-static" | "broadcast-dynamic";  /** Signal or broadcast name. */  target: string;  /** Exactly one timing mode is set. */  interval?: string;  cron?: string;             // five fields: minute hour day month weekday  timezone?: string;         // IANA name, defaults to UTC  overlapPolicy?: "skip" | "allow";  misfirePolicy?: "skip" | "fire-once" | "catch-up";  misfireGraceMs?: number;  /** JSON-serialisable payload sent to the target on each fire. */  input?: unknown;  enabled: boolean;  nextRunAt: Date;  lastRunAt?: Date;  lastRunStatus?: string;  lastRunId?: string;  createdAt: Date;  updatedAt: Date;  createdBy?: string;}

Interval grammar

Intervals are the same human-readable strings used by signal .every(): a positive integer plus a unit suffix. Both the client-side preview helper and the server-side parseInterval accept all of these:

SuffixUnitExample
msmilliseconds"100ms"
sseconds"30s"
mminutes"5m"
hhours"1h"
ddays"1d"
wweeks"1w"

Intervals are absolute durations from the previous planned fire, so a late poll does not cause cumulative drift. For calendar time, set a five-field cron expression and an IANA timezoneinstead of interval. For example, 0 17 * * *with Africa/Nairobi becomes eligible every day at 5 PM in Nairobi, including timezone offset changes.

Overlap and misfires

overlapPolicy: "skip" (the default) suppresses a fire when the target already has pending or running work;"allow" permits overlap. misfirePolicy controls overdue occurrences after downtime: fire-once (default),skip beyond misfireGraceMs, orcatch-up one occurrence per reconciliation pass.


HTTP API

Schedules live under /api/v1/schedules. Auth is the same API key flow used elsewhere on the v1 surface.

Create

curl -X POST http://localhost:4400/api/v1/schedules \  -H "Authorization: Bearer $STATION_KEY" \  -H "Content-Type: application/json" \  -d '{    "kind": "signal",    "target": "syncInventory",    "cron": "0 17 * * *",    "timezone": "Africa/Nairobi",    "overlapPolicy": "skip",    "misfirePolicy": "fire-once",    "input": { "warehouseId": "WH-1" },    "enabled": true  }'

List, edit, delete

EndpointDescription
GET /api/v1/schedulesList schedules. Query params: ?kind=..., ?enabled=true|false, ?due=true.
GET /api/v1/schedules/:idSingle schedule by ID.
PATCH /api/v1/schedules/:idPartial update. Fields you can change include interval, cron, timezone, overlap/misfire policy,input, enabled, and nextRunAt. Identity fields (kind, target, createdAt) are immutable.
DELETE /api/v1/schedules/:idRemove a schedule. Hard delete; runs already triggered are unaffected.

Preview the next fires

curl -X POST http://localhost:4400/api/v1/schedules/sched_abc/preview \  -H "Authorization: Bearer $STATION_KEY" \  -H "Content-Type: application/json" \  -d '{ "n": 5 }' // → { "fires": ["2026-05-02T15:00:00.000Z", "2026-05-02T15:15:00.000Z", ...] }

Useful for the dashboard's "next runs" list and for testing interval changes without waiting for the reconciler to fire.


Adapter requirements

Schedule storage is a separate ScheduleAdapter, not the broadcast or signal queue adapter. It ships per backend in a sub-path of the corresponding adapter package:

For tests, ScheduleMemoryAdapter ships in station-schedules itself.

The interface

interface ScheduleAdapter {  add(schedule: Schedule): Promise<void>;  get(id: string): Promise<Schedule | null>;  list(filter?: { kind?: ScheduleKind; enabled?: boolean; due?: boolean }): Promise<Schedule[]>;  update(id: string, patch: SchedulePatch): Promise<void>;  delete(id: string): Promise<boolean>;   /**   * Atomically advance nextRunAt only if the stored value still matches   * expectedNextRunAt. Required for multi-instance correctness.   */  claimDue?(id: string, expectedNextRunAt: Date, newNextRunAt: Date): Promise<boolean>;   generateId(): string;  ping(): Promise<boolean>;  close?(): Promise<void>;}
claimDue is technically optional, but adapters that don't implement it fall back to a non-atomic advance and emit a warning. That's fine for single-process development; in any multi-runner deployment you can end up firing the same schedule twice. All four built-in backends implement it.

How each backend makes the claim atomic


The reconciler

The signal runner and broadcast runner each own a ScheduleReconciler tied to the kinds they handle. On every tick it:

  1. Lists schedules with enabled = true and nextRunAt <= now.
  2. Calls claimDue(id, currentNextRunAt, newNextRunAt). If another runner already claimed, it bails — at-most-once.
  3. Optionally checks for an in-flight run for the same target and records lastRunStatus = "skipped:overlap" rather than firing.
  4. Triggers the target.
  5. Records lastRunAt, lastRunId, and lastRunStatus ("triggered" or "errored").

If the trigger throws, the schedule's nextRunAt still advances (the claim already moved it forward) and the error is recorded on the row. A schedule can never busy-loop on a recurring failure.


File schedules vs runtime schedules

A signal's .every("5m") and a broadcast's .every("1h") are still the right tool when the cadence is part of your code: it lives in version control, it's reviewed in PRs, and it deploys with the application. They're managed by the runner's own scheduling fields on the run record; they don't use the schedule adapter at all.

Reach for a runtime schedule when the cadence belongs to operations: you want it pause-able, edit-able and visible in the dashboard without a redeploy. The two are designed to coexist on the same target — you can have a file-defined hourly broadcast and a runtime schedule that also fires it ad-hoc; both go through the runner's overlap protection so you won't end up with two concurrent runs.