Guide

Getting started

This guide walks through Station from first install to a production-ready setup with persistence, recurring jobs, multi-step pipelines, and lifecycle observers.

Prerequisites


1. Install

pnpm add station-signal station-kit

station-signal is where you define jobs; station-kit is how you run them — it is Station's entry point, and it wires the runners, dashboard, and API for you.

station-signal re-exports z from Zod. There is no need to install Zod separately.


2. Define a signal

A signal is a named, type-safe background job definition. It declares an input schema, execution constraints, and a handler function using a builder pattern. Signals are defined in their own files so the runner can auto-discover them.

// signals/send-email.tsimport { signal, z } from "station-signal"; export const sendEmail = signal("sendEmail")  .input(z.object({    to: z.string(),    subject: z.string(),    body: z.string(),  }))  .timeout(30_000)  .retries(2)  .run(async (input) => {    console.log(`Sending email to ${input.to}`);    // Your email sending logic here  });

Builder methods

MethodDescription
.input(schema)Zod schema for the job payload. Every .trigger() call is validated against this schema. If validation fails, the run never starts.
.timeout(30_000)Maximum execution time in milliseconds. If the handler exceeds this duration, the run is killed and marked as timed out. Default: 300_000 (5 minutes).
.retries(2)Number of retry attempts after the initial failure. A value of 2 means 3 total attempts (1 initial + 2 retries). Default: 0 (no retry).
.run(handler)The handler function. Receives the validated input. Runs in an isolated child process spawned by the runner.

3. Run it

Station apps are configured in one file and started with one command. defineConfig points Station at your signal directory; the station CLI discovers what is there and runs it.

// station.config.tsimport { defineConfig } from "station-kit"; export default defineConfig({  signalsDir: "./signals",});
npx station

That one command starts the signal runner, serves the dashboard on http://localhost:4400, and exposes the authenticated v1 API — so you can watch runs, inspect logs, and trigger jobs without writing any of that yourself. Add broadcastsDir and beaconsDir later and the matching runners are wired the same way, including the shutdown ordering between them.

Config fieldDescription
signalsDirPath to a directory of signal files. Station auto-discovers every .ts or .js file that exports a signal and registers it at startup.
adapterWhere run state is persisted. Defaults to in-memory — see step 5.
portDashboard / API port. Defaults to 4400.
runner.pollIntervalMsHow often the runner checks for due entries. Defaults to one second.

By default, Station uses an in-memory adapter. All jobs are lost on restart. See step 5 below for production-grade persistence.

Embedding: constructing a runner yourself

SignalRunner is also exported directly, for the cases station-kit deliberately doesn't cover: embedding Station inside a server process you already own, a headless worker that must not bind a port, or tests. Reach for it only then — you take on wiring the storage, subscribers, and shutdown ordering yourself.

// runner.ts — the escape hatch, not the defaultimport path from "node:path";import { SignalRunner } from "station-signal"; const runner = new SignalRunner({  signalsDir: path.join(import.meta.dirname, "signals"),}); runner.start();

4. Trigger a signal

import { sendEmail } from "./signals/send-email.js"; const runId = await sendEmail.trigger({  to: "user@example.com",  subject: "Welcome",  body: "Thanks for signing up.",}); console.log(`Enqueued run: ${runId}`);
BehaviorDetail
Validation.trigger() validates the input against the Zod schema before enqueuing. Invalid input throws immediately.
Return valueReturns a run ID (string) immediately. The call does not wait for execution.
ExecutionThe runner picks up the job on its next poll tick and spawns a child process to run the handler.

The .js extension in the import path is required for ESM resolution, even when your source files are .ts.


5. Add persistence (SQLite)

The default in-memory adapter loses all jobs on process restart. For anything beyond local development, use the SQLite adapter.

pnpm add station-adapter-sqlite
pnpm 10+: better-sqlite3 requires native compilation. Add { "pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] } } to your package.json and re-run pnpm install. See Adapters for details.
// station.config.tsimport { defineConfig } from "station-kit";import { SqliteAdapter } from "station-adapter-sqlite"; export default defineConfig({  signalsDir: "./signals",  adapter: new SqliteAdapter({ dbPath: "./jobs.db" }),});
DetailDescription
EngineUses better-sqlite3 under the hood with WAL mode enabled for concurrent reads.
SetupTables and indexes are created automatically on first run. No migrations needed.
Database fileCreated at the path you provide. Use an absolute path to avoid ambiguity.

Shared adapter for separate processes

When triggers happen in a different process than the runner (common in web servers), both processes need access to the same adapter instance. Use the configure() function to set a global default.

// config.tsimport { configure } from "station-signal";import { SqliteAdapter } from "station-adapter-sqlite"; configure({  adapter: new SqliteAdapter({ dbPath: "./jobs.db" }),});

Import the config module before any signal imports in your trigger process:

// In your web server or trigger processimport "./config.js"; // Run configure() firstimport { sendEmail } from "./signals/send-email.js"; await sendEmail.trigger({  to: "user@example.com",  subject: "Order confirmation",  body: "Your order has been placed.",});

6. Recurring signals

Signals can run on a fixed interval. The runner handles scheduling, re-enqueuing, and retry logic automatically.

// signals/health-check.tsimport { signal } from "station-signal"; export const healthCheck = signal("healthCheck")  .every("5m")  .run(async () => {    const res = await fetch("https://api.example.com/health");    if (!res.ok) throw new Error(`Health check failed: ${res.status}`);  });
BehaviorDetail
Intervals.every() accepts interval strings: "30s", "5m", "1h", "1d".
SchedulingThe runner automatically schedules the first execution at startup and re-enqueues after each completion.
InputNo input schema needed for recurring signals. If your recurring signal requires input, chain .withInput(data) to provide a default payload.
FailuresIf a recurring signal fails, retry rules apply. After all attempts are exhausted, it re-enqueues for the next interval.

7. Multi-step signals

For pipelines where each stage transforms data for the next, use steps instead of a single handler.

// signals/process-order.tsimport { signal, z } from "station-signal"; export const processOrder = signal("processOrder")  .input(z.object({ orderId: z.string(), amount: z.number() }))  .step("validate", async (input) => {    if (input.amount <= 0) throw new Error("Invalid amount");    return { ...input, validated: true };  })  .step("charge", async (prev) => {    const chargeId = await payments.charge(prev.amount);    return { orderId: prev.orderId, chargeId };  })  .step("notify", async (prev) => {    await notify(`Order ${prev.orderId} charged: ${prev.chargeId}`);  })  .build();
BehaviorDetail
Data flowEach .step() receives the return value of the previous step as its input. The first step receives the validated signal input.
ExecutionSteps run sequentially within a single child process.
FailureIf any step throws, the entire run fails and retries from the beginning (if retries are configured).
FinalizationUse .build() instead of .run() when defining steps.

8. Subscribers

Subscribers observe the signal lifecycle. Use them for logging, metrics, alerting, or any side effect that should not live inside a handler.

Register them in station.config.ts. Yours run alongside the subscribers Station wires for the dashboard and event stream — additive, never instead of them.

// station.config.tsimport { defineConfig } from "station-kit";import { ConsoleSubscriber } from "station-signal"; export default defineConfig({  signalsDir: "./signals",  subscribers: {    signal: [      new ConsoleSubscriber(), // Built-in: logs all events to stdout      {        onRunStarted({ run }) {          metrics.increment("signal.started", { name: run.signalName });        },        onRunCompleted({ run }) {          metrics.increment("signal.completed", { name: run.signalName });        },        onRunFailed({ run, error }) {          alerting.send(`Signal ${run.signalName} failed: ${error}`);        },      },    ],    // broadcast: [...] and beacon: [...] take their own subscriber shapes  },});

Station's own subscribers always run first, and the runner catches and logs anything a subscriber throws — so a slow or broken subscriber of yours can neither stall the dashboard nor fail a run.

EventDescription
onRunDispatchedA run was picked up from the queue and dispatched for execution.
onRunStartedA child process began executing the handler.
onRunCompletedThe handler finished successfully.
onRunFailedThe handler threw an error (after all retries exhausted).
onRunRetryA failed run is being retried.
onRunTimeoutThe handler exceeded its timeout and was killed.

All subscriber methods are optional. Implement only the events you care about. ConsoleSubscriber is a built-in subscriber that logs every event to stdout.


Next steps

ResourceDescription
Signals APIFull builder reference, runner options, adapter interface.
BroadcastsChain signals into DAG workflows with fan-out and fan-in.
BeaconsLong-running supervised processes — servers, pollers, clients.
AdaptersSQLite adapter details and custom adapter interface.
StationReal-time monitoring dashboard for signals and broadcasts.
ExamplesComplete working examples covering common patterns.