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:
| 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; /** 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:
| 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 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
| 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 include interval, cron, timezone, overlap/misfire policy,input, enabled, and 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.