Guide

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:

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;  /** 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:

SuffixUnitExample
msmilliseconds"100ms"
sseconds"30s"
mminutes"5m"
hhours"1h"
ddays"1d"
wweeks"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

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: interval, input, enabled, 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.