Schedules
Schedules let you fire signals or broadcasts at runtime-defined intervals without redeploying. They're stored in the same adapter as your runs 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:
| Kind | Target | Triggers |
|---|---|---|
signal | Signal name | The signal runner picks it up and dispatches a normal run. |
broadcast-static | File-defined broadcast name | The broadcast runner triggers the registered DAG. |
broadcast-dynamic | Dynamic broadcast name | The 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; /** Interval string, e.g. "5m", "1h", "100ms". */ interval: string; /** 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:
| Suffix | Unit | Example |
|---|---|---|
ms | milliseconds | "100ms" |
s | seconds | "30s" |
m | minutes | "5m" |
h | hours | "1h" |
d | days | "1d" |
w | weeks | "1w" |
Intervals are absolute durations from the previous fire — there is no cron expression support. If you need calendar-aware scheduling ("the first business day of the month"), implement it as a signal that runs frequently and short-circuits when the calendar isn't right.
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", "interval": "15m", "input": { "warehouseId": "WH-1" }, "enabled": true }'List, edit, delete
| Endpoint | Description |
|---|---|
GET /api/v1/schedules | List schedules. Query params: ?kind=..., ?enabled=true|false, ?due=true. |
GET /api/v1/schedules/:id | Single schedule by ID. |
PATCH /api/v1/schedules/:id | Partial update. Fields you can change: interval, input, enabled, nextRunAt. Identity fields (kind, target, createdAt) are immutable. |
DELETE /api/v1/schedules/:id | Remove 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:
station-adapter-sqlite/schedulesstation-adapter-postgres/schedulesstation-adapter-mysql/schedulesstation-adapter-redis/schedules
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
- SQLite —
UPDATE ... WHERE next_run_at = ?; the single-writer DB serialises. - Postgres —
UPDATE ... RETURNING idin a single statement, atomic across connections. - MySQL —
UPDATE ... WHERE ...withaffectedRows > 0deciding the winner. - Redis — Lua
EVALscript that comparesZSCOREagainst the expected value before updating.
The reconciler
The signal runner and broadcast runner each own a ScheduleReconciler tied to the kinds they handle. On every tick it:
- Lists schedules with
enabled = trueandnextRunAt <= now. - Calls
claimDue(id, currentNextRunAt, newNextRunAt). If another runner already claimed, it bails — at-most-once. - Optionally checks for an in-flight run for the same target and records
lastRunStatus = "skipped:overlap"rather than firing. - Triggers the target.
- Records
lastRunAt,lastRunId, andlastRunStatus("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.