# Station documentation > Type-safe background jobs, schedules, Station Networks, beacons, DAG workflows, browser-local execution, persistent sandbox workspaces and agent-controlled browser sessions for TypeScript. Generated from the canonical Station 3.0.0 documentation pages listed in [llms.txt](https://station.dterminal.net/llms.txt). --- # Getting started > Canonical HTML: [https://station.dterminal.net/docs/getting-started](https://station.dterminal.net/docs/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. This page covers the Node runtime. For local execution inside a web app, follow the [experimental browser guide](https://station.dterminal.net/docs/browser.md) instead: it uses BrowserStation and IndexedDB with explicit worker hosts. ## Prerequisites - Node.js 18 or later - A package manager (pnpm, npm, or yarn) - A TypeScript project configured for ES modules (`"type": "module"` in your package.json) * * * ## 1\. Install ``` pnpm add station-signal station-daemon pnpm add -D station-runtime-cli station-dashboard ``` `station-signal` is where you define jobs; `station-daemon` is how you run them — it is Station's entry point for the runners and API. The CLI and dashboard are separate clients. 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.ts import { 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 | Method | Description | | --- | --- | | `.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 `stationd` process discovers what is there and runs it. ``` // station.config.ts import { defineConfig } from "station-daemon"; export default defineConfig({ signalsDir: "./signals", }); ``` ``` pnpm exec stationd ``` That command starts the signal runner and the v1 API on `http://localhost:4400`. Add `broadcastsDir` and `beaconsDir` later and the matching runners are wired the same way, including the shutdown ordering between them. ``` # Run in another terminal; the dashboard has its own lifecycle STATION_DAEMON_URL=http://127.0.0.1:4400 PORT=4401 STATION_DASHBOARD_HOST=127.0.0.1 pnpm exec station-dashboard ``` Open `http://127.0.0.1:4401`. A remote daemon uses the same dashboard with a different `STATION_DAEMON_URL`. | Config field | Description | | --- | --- | | `signalsDir` | Path to a directory of signal files. Station auto-discovers every `.ts` or `.js` file that exports a signal and registers it at startup. | | `adapter` | Where run state is persisted. Defaults to in-memory — see **step 5**. | | `port` | Daemon API port. Defaults to `4400`. | | `runner.pollIntervalMs` | How 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-daemon 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 default import 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}`); ``` | Behavior | Detail | | --- | --- | | Validation | `.trigger()` validates the input against the Zod schema before enqueuing. Invalid input throws immediately. | | Return value | Returns a run ID (string) immediately. The call does not wait for execution. | | Execution | The 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](https://station.dterminal.net/docs/adapters.md) for details. ``` // station.config.ts import { defineConfig } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; export default defineConfig({ signalsDir: "./signals", adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), }); ``` | Detail | Description | | --- | --- | | Engine | Uses better-sqlite3 under the hood with WAL mode enabled for concurrent reads. | | Setup | Tables and indexes are created automatically on first run. No migrations needed. | | Database file | Created 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.ts import { 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 process import "./config.js"; // Run configure() first import { 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.ts import { 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}`); }); ``` | Behavior | Detail | | --- | --- | | Intervals | `.every()` accepts interval strings: `"30s"`, `"5m"`, `"1h"`, `"1d"`. | | Scheduling | The runner automatically schedules the first execution at startup and re-enqueues after each completion. | | Input | No input schema needed for recurring signals. If your recurring signal requires input, chain `.withInput(data)` to provide a default payload. | | Failures | If 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.ts import { 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(); ``` | Behavior | Detail | | --- | --- | | Data flow | Each `.step()` receives the return value of the previous step as its input. The first step receives the validated signal input. | | Execution | Steps run sequentially within a single child process. | | Failure | If any step throws, the entire run fails and retries from the beginning (if retries are configured). | | Finalization | Use `.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.ts import { defineConfig } from "station-daemon"; 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. | Event | Description | | --- | --- | | `onRunDispatched` | A run was picked up from the queue and dispatched for execution. | | `onRunStarted` | A child process began executing the handler. | | `onRunCompleted` | The handler finished successfully. | | `onRunFailed` | The handler threw an error (after all retries exhausted). | | `onRunRetry` | A failed run is being retried. | | `onRunTimeout` | The 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 | Resource | Description | | --- | --- | | [Signals API](https://station.dterminal.net/docs/signals.md) | Full builder reference, runner options, adapter interface. | | [Broadcasts](https://station.dterminal.net/docs/broadcasts.md) | Chain signals into DAG workflows with fan-out and fan-in. | | [Beacons](https://station.dterminal.net/docs/beacons.md) | Long-running supervised processes — servers, pollers, clients. | | [Adapters](https://station.dterminal.net/docs/adapters.md) | SQLite adapter details and custom adapter interface. | | [Station](https://station.dterminal.net/docs/station.md) | Real-time monitoring dashboard for signals and broadcasts. | | [Examples](https://station.dterminal.net/docs/examples.md) | Complete working examples covering common patterns. | --- # Docker Compose and persistent agents > Canonical HTML: [https://station.dterminal.net/docs/docker-compose](https://station.dterminal.net/docs/docker-compose) Station can run inside a container and manage separate sandbox containers through an operator-owned Docker engine. Compose starts the controller and dashboard; Station creates and supervises the workloads. A persistent agent such as Hermes runs inside its own sandbox with Bash, Git, Node, Python and a writable home volume. ![Compose starts separate dashboard and Station controller containers. Only the controller accesses the host Docker engine, which runs a sibling Hermes sandbox. Controller state and the agent home use separate persistent storage.](https://station.dterminal.net/diagrams/station-compose.svg) One engine, separate containers. The agent never receives the Docker socket. ## What runs where | Component | Responsibility | Access | | --- | --- | --- | | Compose | Starts and restarts the Station controller and dashboard. | Operator deployment configuration. | | Station controller | Owns sandbox lifecycle, terminals, files and service supervision. | Docker socket and private controller state. | | Dashboard | Authenticated web client and proxy to Station. | No Docker socket, credentials file or workload volume. | | Hermes sandbox | Runs the gateway, agent tools and interactive shells. | Its own persistent home; configured outbound network access. | This uses sibling containers on the same engine. It does not start a second Docker daemon inside Station. Compose does not adopt the sandbox as one of its services: Station owns its lifecycle, and its Docker labels and named volume remain after Compose is stopped. ## Run the example The [Hermes sandbox example](https://github.com/porkytheblack/station/tree/main/examples/20-hermes-sandbox) contains the application image, separate controller/dashboard build targets, Compose configuration, provisioning scripts and operational runbook. Use the checkout until the corresponding packages are published. The detailed runbook explains how to build the pinned Hermes image and initialize private settings with a reviewed seccomp profile. ``` # After building the Hermes image and initializing private settings: docker compose -f examples/20-hermes-sandbox/compose.yaml build docker compose -f examples/20-hermes-sandbox/compose.yaml up -d --wait # Provision through Station; pass file paths, never credential values. node examples/20-hermes-sandbox/provision.mjs \ /private/openrouter-key.txt /private/telegram-token.txt TELEGRAM_USER_ID docker compose -f examples/20-hermes-sandbox/compose.yaml ps ``` Open `http://127.0.0.1:5801`, sign in with the generated private credentials, and choose **Sandboxes → worker → workspace**. Commands, Terminal, Services and Files are separate pages. Start long-running agents through Services; ordinary Commands have a timeout. When moving an existing local setup to Compose, back up first and stop its local controller before mounting the same state. Two controllers must never own one workspace root. The example retains the existing volume, sandbox identity and credentials. Its container entrypoint uses an exclusive filesystem lock and a stable controller identity to handle stale PID records after a container restart. ## Networking and boundaries The dashboard has its own network namespace and reaches the authenticated daemon over verified HTTPS on the private Compose network. It mounts only the public trust certificate; the controller keeps the private key. This lets the controller restart without stranding the dashboard in an old network namespace. Only loopback host ports are published. The sandbox uses a separate Docker network namespace: processes inside it can communicate on their own `127.0.0.1`. Bridge networking permits outbound API calls, but it is not an egress allowlist or a public-tenant network policy. The Docker socket grants powerful host-level control. Mount it only into the trusted controller; never into an agent sandbox or dashboard. Public tenants need dedicated workers, tenant-scoped authorization, independently enforced network/storage limits and production-host validation. Read the [execution guide](https://station.dterminal.net/docs/execution.md) for those requirements. For one fixed trusted agent, another option is a Station host-process adapter running inside an outer container. It needs no Docker socket inside, but all workspaces in that service share that container's isolation boundary. This example deliberately uses the container adapter and separate workload containers. ## Persistence and recovery | Event | Expected behavior | | --- | --- | | Close the dashboard tab | Agent service and shell keep running. | | Hermes exits or crashes | Station applies the configured bounded service restart policy. | | Controller restarts | Station reconciles its owned container and restarts desired services. Existing interactive shells are interrupted; files persist. | | Docker is missing at startup | The container adapter refuses startup. It never falls back to host execution. | | Engine connection is lost | Container operations fail. A connection loss does not prove the workload stopped; reconcile before retrying uncertain mutations. | | Docker Desktop or host stops | Execution stops. Retained volumes survive unless deleted. Restore the engine and verify controller and service recovery. | | Command timeout / forced cancellation | Containment can stop the entire workspace, including sibling services. Restart the desired service explicitly. | Compose's restart policy restarts exited containers, not merely unhealthy ones. It does not make a sleeping laptop an always-on host. The example's API health check confirms controller responsiveness, not Telegram delivery or model-provider availability. Monitor those separately. The Hermes home, user installs and conversations live on its named volume. Controller metadata lives in the private state mount. Back up both consistently while Station is stopped, protect backups as credentials, and test restoration. `docker compose down` is not a sandbox deletion command. Avoid volume-prune commands on an execution host. The Docker and Podman adapters require an available compatible Linux engine with enforceable resource limits and seccomp. Backend selection is explicit; Podman is not an automatic failover target for existing Docker volumes. ## What the local test covers The example has exercised a real model-driven shell task, interactive dashboard access, custom installation, communication between sandbox-local processes, gateway crash recovery, persistent files and an offline backup. The deployment runbook includes controller crash recovery, exclusive ownership and missing-engine checks. The internal TLS certificate lasts one year; renew it and recreate both services before expiry as described in the example runbook. These checks do not establish multi-tenant isolation or high availability on an intended production Linux host. --- # Station in the browser > Canonical HTML: [https://station.dterminal.net/docs/browser](https://station.dterminal.net/docs/browser) `station-browser` executes signals, broadcast DAGs, and beacons in Web Workers and service workers. IndexedDB stores the queue, completed steps, workflow progress, and beacon desired state on the device. The browser runtime needs no Node runner, companion app, or Station server. It is experimental and is not a Station Network member. Local state can outlive a browser execution slice 1. Application**Trigger locally**The page registers bundled definitions and queues work on this device. 2. IndexedDB**Retain progress**Store runs, completed steps, workflow state and beacon intent. 3. Worker**Run while awake**Execute work when the browser allows it. A later wake can recover persisted state. Storage durability is not continuous execution. A service worker cannot promise one-second polling after the PWA closes. Persistence survives a page reload; continuous execution does not. Service workers run bounded work when the browser wakes them. Neither an installed PWA nor `event.waitUntil()` guarantees polling after closing the app or browser. For server-owned browsers, screenshots or native shell workspaces, use [Browser Use](https://station.dterminal.net/docs/browser-use.md) or [Sandboxes](https://station.dterminal.net/docs/sandboxes.md). Those are separate server primitives with their own lifecycles. ## Try the implementation The experimental package has been available since Station 2.3.0. This checkout targets 3.0.0. After that release is published, install it with ` pnpm add station-browser@^3.0.0`. To try the release checkout, start with the repository workspace: ``` # From the Station repository checkout containing station-browser pnpm install pnpm dev:browser ``` Open `http://127.0.0.1:4317`. The Node process serves static files only. Follow the [browser lab walkthrough](https://station.dterminal.net/docs/examples/browser.md) to exercise interruption, retries, DAGs, and beacon restarts. For another application in this monorepo, add `station-browser` as a `workspace:*` dependency. Bundle page and worker entries for the browser with esbuild or another browser-aware bundler. The browser condition selects Web Crypto; do not polyfill Node runners into the app. Use HTTPS or localhost, IndexedDB, Web Crypto, and module worker support. TypeScript worker entries need the WebWorker library. ## Choose an execution host | Host | Use it for | Execution contract | | --- | --- | --- | | Web Worker | Interactive local jobs and live beacon sessions | Drain jobs and tick beacons while the worker runs. Page closure or suspension can interrupt it. | | Service worker | Bounded work on message or supported background events | Call wake() through event.waitUntil(). Beacons suspend at the end of each slice. | ## 1\. Share a workload registry Import the same definitions and database name in the page and executor. Definitions are bundled code; there is no directory auto-discovery. Broadcast node signals are registered automatically. ``` // src/registry.ts import { beacon, broadcast, signal, z } from "station-browser"; export const report = signal("report") .input(z.object({ text: z.string() })) .timeout(5_000).retries(2) .step("normalize", async ({ text }) => text.trim()) .step("count", async (text) => ({ characters: text.length })) .build(); const summarize = signal("summarize") .input(z.object({ characters: z.number() })) .output(z.object({ message: z.string() })) .run(async ({ characters }) => ({ message: String(characters) + " characters" })); export const analysis = broadcast("analysis") .input(report).then(summarize) .onFailure("skip-downstream").build(); // onDemand creates an instance only when explicitly requested. export const status = beacon("status") .config(z.object({ url: z.string() })) .onDemand().restart("on-failure") .backoff("1s", { max: "10s" }).stopTimeout(1_000) .poll("1s", async (ctx) => { const response = await fetch(ctx.config.url, { signal: ctx.signal }); if (!response.ok) throw new Error("Status request failed"); ctx.heartbeat(); ctx.log("HTTP " + response.status); }); export const options = { database: "my-app-station-v1", definitionVersion: "1", signals: [report], broadcasts: [analysis], beacons: [status], concurrency: 4, }; ``` The poll callback completes before the one-second delay begins, so a slow request lengthens the interval. Suspension, restart backoff, and browser scheduling add further gaps. This is not an exact one-second clock. The example URL must point to an endpoint your app provides; cross-origin requests also need permission from the target server through CORS. ## 2\. Host it in a Web Worker Drain jobs independently of beacon ticks: waiting for a slow signal must not prevent lease renewal. The current beacon lease is 2.5 seconds; the demo ticks every 100 ms. Catch failures so the host can report them. ``` // src/worker.ts — compile with the WebWorker library import { BrowserStation, configure } from "station-browser"; import { options } from "./registry.js"; const station = new BrowserStation({ ...options, stationId: "web-worker" }); configure({ triggerAdapter: station }); const reportError = (error: unknown) => postMessage({ error: String(error) }); async function drain() { try { await station.drain({ maxJobs: 10, budgetMs: 10_000 }); } catch (error) { reportError(error); } setTimeout(drain, 300); } void drain(); setInterval(() => { void station.beacons.tick().catch(reportError); }, 100); ``` ``` // src/app.ts — compile with the DOM library; bundles served at origin root import { BrowserStation, configure } from "station-browser"; import { options, report, analysis } from "./registry.js"; const station = new BrowserStation(options); configure({ triggerAdapter: station }); const worker = new Worker("/worker.js", { type: "module" }); worker.onmessage = ({ data }) => { if (data.error) console.error(data.error); }; const runId = await report.trigger({ text: "Hello from this device" }); const workflowId = await analysis.trigger({ text: "A browser workflow" }); await station.beacons.start("status", { instanceId: "api-status", config: { url: "/api/status" }, }); // Read these again from your UI to observe progress; trigger() only enqueues. console.log(await station.store.get(runId)); console.log((await station.broadcasts.list()).find((run) => run.id === workflowId)); console.log(await station.beacons.list()); // User actions can call: // await station.store.cancel(runId); // await station.broadcasts.cancel(workflowId); // await station.beacons.stop("api-status"); ``` `configure()` applies to its JavaScript context. Configure every context that calls a definition's `.trigger()`, or use `station.trigger(report, input)` and `station.triggerBroadcast("analysis", input)` directly. `BrowserStation` has no `waitForRun()`; observe the persisted status instead. ## 3\. Alternatively, host it in a service worker Use this host in place of the dedicated worker above. A message grants an execution opportunity; it does not create a permanent background loop. ``` // src/sw.ts — compile with the WebWorker library import { BrowserStation, configure } from "station-browser"; import { options } from "./registry.js"; const sw = self as unknown as ServiceWorkerGlobalScope; const station = new BrowserStation({ ...options, stationId: "service-worker" }); configure({ triggerAdapter: station }); sw.addEventListener("message", (event) => { if (event.data?.type !== "station:wake") return; event.waitUntil(station.wake({ maxJobs: 10, budgetMs: 10_000, beaconSliceMs: 1_500, })); }); ``` ``` // In the page: replace new Worker(...) with this registration. // After enqueueing work or changing beacon desired state, call wake(). await navigator.serviceWorker.register("/sw.js", { type: "module" }); const registration = await navigator.serviceWorker.ready; function wake() { registration.active?.postMessage({ type: "station:wake" }); } wake(); // Supply later wake opportunities while the page is open, including for retries. setInterval(wake, 1_000); ``` `wake()` drains signals and broadcasts while supervising a bounded beacon slice. `drain()` alone never supervises beacons. At slice end, handlers are aborted and cleanup is requested; cleanup may add the configured stop timeout. Beacon desired state remains running, with status suspended, until a later wake resumes it. Explicit stop persists across reloads. Background Sync is optional and feature-detected in the lab; no recurring schedule, push wakeup, or app-shell cache is installed by `BrowserStation` itself. ## Browser API reference | API | Behavior | | --- | --- | | `new BrowserStation(options)` | Optional signals, broadcasts, beacons arrays; database defaults to station-browser, definitionVersion to 1, concurrency to 4, stationId to a generated ID. Concurrency overlaps async signals in one executor. | | `trigger(definitionOrName, input)` | Validate and enqueue a signal; returns its ID. | | `triggerBroadcast(name, input)` | Enqueue a registered broadcast; returns its ID. | | `drain({ maxJobs, budgetMs })` | Returns the number of claimed signal attempts. Defaults: 10 jobs, 20,000 ms. Budget limits new claims; already claimed work may use its full timeout. | | `wake({ maxJobs, budgetMs, beaconSliceMs })` | Drain jobs and run a beacon slice concurrently. Default slice: 1,500 ms. | | `store.get(id) / list() / cancel(id)` | Read or cancel signal runs. Input, output, and checkpoint outputs are serialized JSON strings; decode defined values with JSON.parse(). | | `broadcasts.list() / cancel(id)` | Read workflow and node states or cancel the workflow and fence child writes. | | `beacons.start(name, { instanceId, config })` | Persist desired-running state and return the instance ID. An executor must tick or wake to launch it. | | `beacons.list() / stop(instanceId)` | Inspect instances or persist desired-stopped state. Stop before changing active configuration. | | `beacons.tick() / runSlice(ms) / suspend()` | Host-level supervision. Suspend preserves desired-running state; invoke it in the executor that owns the handlers. Stop host timers before graceful shutdown and close storage after in-flight work settles. | ## Supported definitions and limits - Signals support input/output schemas, run handlers, saved steps, retries, and cooperative timeouts. Schedules, env injection, placement, per-signal concurrency policies, and onComplete hooks are rejected. - Broadcasts support fan-out, joins, named dependencies, synchronous maps/guards, and fail-fast, skip-downstream, or continue policies. Recurring and dynamic-definition workflows are unsupported. Parent timeouts include time spent suspended. - Beacons support run/poll, config, readiness, heartbeat/startup watchdogs, restart/backoff, and manual/auto/on-demand modes. Handlers must honor ctx.signal and release resources through onStop. Listening ports through ctx.expose(), env injection, and placement are unavailable. - Manual beacons with required config can wait for an explicit start() with valid values. Existing instances retain saved configuration when a new supervisor starts. Auto-start definitions need valid defaults if no instance has been started yet; use schema defaults or withConfig(). Invalid explicit starts reject without creating an instance. ## Recovery and production boundaries Signal leases last for the timeout plus one second. An interrupted attempt can be reclaimed when its lease expires if retry attempts remain; retries become eligible after 250 ms and need another drain. Completed steps are reused. Incomplete handlers and steps may execute again: external effects must be idempotent. Lease tokens fence stored writes, not requests already sent or uncooperative JavaScript. Keep the database name stable across page and worker entries. Change definitionVersion when handler or step semantics change; incompatible runs fail rather than reuse old checkpoints. Coordinate asset and worker updates, or isolate incompatible versions in separate database names. IndexedDB is origin-local storage that can be cleared or evicted, not a server backup. Store scans, retention, blocked upgrades, and cross-browser rollout behavior need further hardening. Never bundle server secrets or assume workers provide an untrusted-code sandbox or Node process isolation. See the [agent skill](https://station.dterminal.net/docs/agent-skill.md) for building guidance, [llms.txt](https://station.dterminal.net/llms.txt) for the documentation index, and [this guide as Markdown](https://station.dterminal.net/docs/browser.md) for agent context. --- # Sandboxes > Canonical HTML: [https://station.dterminal.net/docs/sandboxes](https://station.dterminal.net/docs/sandboxes) A sandbox gives an agent a working directory, files, real shell commands and optional interactive terminals. It can clone a repository, install a tool, edit code or keep an agent server running. `station-sandbox` manages that environment on a particular worker; it does not emulate Unix or automatically move a running process to another machine. Inside a container-backed sandbox 1. Operator supplies**Tools image**A Linux image with Node, Bash and setsid. Add Git, Python or your agent runtime here. 2. Station owns**Workspace container**Commands, shells and services share this workspace and its localhost network. 3. Docker retains**Home volume**Repository, user installs and application data under /home/node survive container replacement. **Trust boundary:** only the controller can access the container engine. Agent commands never receive the Docker socket. The controller stores workspace metadata separately. Preserve both controller state and the home volume; neither one is a running-process snapshot. ## Choose the boundary before running code | Adapter | What it provides | Use it for | | --- | --- | --- | | `HostSandboxAdapter` | Separate workspace and home directories; processes run as the worker user. | Trusted local or internal code. A directory is not a security boundary. | | `ContainerSandboxAdapter` | One nonroot Docker or Podman container per workspace, persistent home, read-only root, resource limits and enforced seccomp. | Work that needs a container boundary, with operator-managed network policy and disk quotas. | Install and configure tools on the host for the host adapter, or build them into the tools image for the container adapter. Container startup fails when its engine or required policy is unavailable; Station never silently switches to host execution. A container shares the host kernel. Public tenants also need the authorization, private-worker and egress controls in the [execution reference](https://station.dterminal.net/docs/execution.md). ## Create a workspace and run a command This operator-side example requires a prebuilt image and a reviewed seccomp policy. The image digest is a placeholder. Start with no external network access, then configure an enforced network policy if the workload needs downloads or model APIs. ``` import { ContainerSandboxAdapter } from "station-sandbox/container"; const sandboxes = new ContainerSandboxAdapter({ rootDir: "/data/sandbox-metadata", image: "registry.example/tools@sha256:YOUR_VERIFIED_DIGEST", seccompProfile: "/etc/station/seccomp.json", network: "none", cpus: 1, memoryMb: 1024, pidsLimit: 128, }); await sandboxes.ready(); const workspace = await sandboxes.create(); const run = await sandboxes.exec(workspace.id, { command: "node --version && pwd", timeoutMs: 10_000, }); // exec returns a run handle. Read command() until status is terminal. const current = await sandboxes.command(workspace.id, run.id); console.log(current.status, current.stdout); ``` Commands have bounded runtime and output. Inspect `status`, `exitCode` and `truncated`; receiving a run ID does not mean the command succeeded. Retain the workspace ID in trusted application state so later requests reach the same worker and workspace. ## Use the right surface for the work | Dashboard page | Use | Lifetime | | --- | --- | --- | | Commands | A build, test run, clone or installation. | One bounded execution with output and exit status. | | Terminal | An interactive Bash session, editor or agent CLI. | Reconnect while its worker and process live. Requires enabled PTY support. | | Services | An agent gateway, OpenCode-style server or application. | Foreground process with explicit, bounded restart policy. | | Files | Browse, read, edit, upload and remove workspace files. | Files persist independently of the dashboard tab. | Open **Sandboxes → worker → workspace** in the [dashboard](https://station.dterminal.net/docs/dashboard.md). Closing the browser tab does not stop a service. Force-closing a terminal, cancelling a command or hitting a command timeout can stop the entire container to contain descendants, interrupting sibling services. Restart affected services explicitly afterward. ``` const service = await sandboxes.startService(workspace.id, { name: "agent-gateway", command: "node server.js", // Foreground: do not append & or daemonize. restart: { policy: "on-failure", maxRestarts: 5, delayMs: 1000 }, }); // Inspect attempts and output through service()/the Services page. // Stop deliberately with stopService(workspace.id, service.id). ``` ## Install tools and work with Git Use the image for reproducible shared tools and a workspace-local install for project dependencies. In the container adapter, `HOME` is `/home/node`, the default working directory is `/home/node/workspace`, and npm's user prefix is `/home/node/.local`. That prefix is on the command path. Installs under this home persist; changes to temporary files do not. ``` # Inside a sandbox with Git and permitted outbound network access: git clone --depth 1 https://github.com/OWNER/REPOSITORY.git app cd app # Review its install scripts before running them. npm ci # Edit files in the dashboard or terminal. git diff git status --short # For a custom CLI, pin an approved package version: npm install --global YOUR_CLI@EXACT_VERSION ``` A GitHub App credential can authorize one repository without giving the agent your personal account. Mint a short-lived installation token outside the sandbox, grant only the required repository permissions, and deliver it through protected runtime configuration or a credential helper. Do not embed tokens in clone URLs or command text. A local commit needs no remote permission; pushing and opening a pull request require the corresponding grants. ## Networking inside the workspace Processes in one sandbox can talk over `127.0.0.1`: an agent can call its own test server or local API. Separate sandboxes have separate network namespaces. Station does not automatically create Compose-style service discovery, publish arbitrary ports, or proxy every sandbox service. Cross-workspace networking and external ingress require an operator-provided design. `network: "none"` still permits sandbox-local loopback. Bridge networking enables outbound access, but does not itself restrict access to private networks, metadata endpoints or other tenants. The `networkRestricted` setting describes an independently enforced policy; it does not install a firewall. ## What survives a restart? | State | Recovery | | --- | --- | | Repository, user installs, agent state | Retained when saved in the persistent home volume. Back it up with controller metadata. | | Shell and process memory | Interrupted. A new process starts from files, not from the previous instruction. | | Container service intent | Controller recovery reconciles owned containers and relaunches desired services. It does not resume the old process. | | Host adapter services | Interrupted on worker restart; inspect state and restart explicitly. | | Temporary files | Not durable. Keep application state out of temporary storage. | Do not point two live controllers at the same metadata root. Local ownership locks are not distributed failover. Disk quotas, backups, host availability and monitoring remain deployment responsibilities. [**Run a persistent agent →**Compose, a separate controller and dashboard, and a real Hermes sandbox.](https://station.dterminal.net/docs/docker-compose.md)[**Configure execution access →**Worker configuration, tenant routing, API operations and enforcement requirements.](https://station.dterminal.net/docs/execution.md) --- # Browser Use > Canonical HTML: [https://station.dterminal.net/docs/browser-use](https://station.dterminal.net/docs/browser-use) Browser Use gives an agent a browser it can observe and control: navigate, inspect elements, fill forms, manage tabs and capture screenshots. A worker owns each live session. The dashboard lets you inspect its activity, review recordings and temporarily take human control. This is `station-browser-use`. The separate [browser runtime](https://station.dterminal.net/docs/browser.md) runs Station jobs inside a Web Worker or service worker. A Browser Use session also does not require a [sandbox workspace](https://station.dterminal.net/docs/sandboxes.md). The agent observes, acts, then observes again 1. Agent application**Scoped tools**The host grants a worker, sessions and profiles. The model chooses an allowed action. 2. Station worker**Session manager**Routes the action to its owning browser and enforces capacity and control leases. 3. Browser adapter**Page + context**Runs the action and returns page data or screenshot pixels for the next decision. **Human takeover:** an exclusive, expiring control lease pauses competing automation. Releasing it returns control to the agent. Feed observations back to the agent after each meaningful action. Website content is untrusted input, not new instructions for the agent. ## Choose where the browser runs | Backend | Execution | Important boundary | | --- | --- | --- | | Playwright | Local Chromium with structured actions, profiles, files, diagnostics and traces. | Trusted host workload; browser processes do not isolate tenants from the worker OS. | | Container Playwright | One Docker or Podman container per session; retained profile storage when configured. | Operator-managed engine, image, seccomp, egress policy and storage quotas. | | Bun WebView | Owned Bun subprocess using the supported WebView runtime. | Smaller capability set. Check capabilities before using advanced browser commands. | | Browserbase / Steel | Provider-managed session controlled over Playwright CDP. | Provider credentials, profile grants, session limits and cleanup stay with the operator. | Adapters expose capabilities; an application must check them instead of assuming every backend supports every command. Hosted proxies and challenge detection can help diagnose access failures, but do not guarantee CAPTCHA-free access. Remote downloads and trace exports are currently disabled pending provider-specific artifact handling. See the [remote browser guide](https://github.com/porkytheblack/station/blob/main/packages/station-browser-use/REMOTE.md). ## Give an agent browser tools Create one toolset per authorized workflow. Bind connection details and grants in your application; the model must not choose credentials or tenant identity. ``` import { BrowserUseClient, createBrowserAgentTools } from "station-browser-use/agent"; const tools = createBrowserAgentTools({ client: new BrowserUseClient({ baseUrl: "https://station.example.com", stationId: "tenant-browser-worker", apiKey: process.env.STATION_EXECUTION_KEY!, }), maxSessions: 1, profileIds: ["research"], // Explicit grant; omit for ephemeral sessions. }); try { // Mount name, description and inputSchema in your agent framework. // Call tool.execute(input, { signal }) for each selected tool. // Send result.images through the model's native image channel. await runYourAgent(tools); // Your application's agent loop. } finally { await tools.close(); } ``` The tools cover open, sessions, navigate, observe, interact, screenshot, checkpoint, resume and close. Structured interactions include semantic locators, frames, page selection, pointer input and file transfer where supported. Screenshots are image results: stringifying base64 into text does not give the model vision. The [Foundry example](https://github.com/porkytheblack/station/tree/main/examples/19-foundry-browser) demonstrates that bridge. Prefer roles, labels and test IDs to fragile CSS paths. Inspect again after navigation or a page update, because old element positions can be stale. Confirm the result of a consequential action before proceeding. A lost response does not prove a click or upload failed; reconcile the session instead of blindly repeating it. ## Session, profile and checkpoint are different Persist the account; recreate the browser 1. Open**Granted profile**Load retained cookies and browser storage into one exclusively owned session. 2. Work**Live session**Tabs, JavaScript, requests and control leases belong to the running browser. 3. Close + reopen**New session**Reuse persisted profile data after release. Authentication may still need renewal. A checkpoint records sanitized page URLs and open options. Resuming creates a new session; it cannot restore live DOM, memory, forms or pending requests. Keep one profile per tenant and account. A browser closing is not an account logout. Profiles contain authentication material and must be protected like credentials. With Steel, wait for profile release and the provider's READY state before reuse. Browser session lifetime and account lifetime are separate. WhatsApp QR linking through Steel and the dashboard was exercised locally. That does not establish reliable authenticated profile reuse or verified TikTok/Instagram automation. Uploading a file is also separate from publishing it. Upload commands currently accept at most 16 files and 4 MiB of decoded data; large media workflows need additional artifact handling. ## Capture a frame every five seconds Configure recording storage and limits on the worker, then start recording explicitly. This example assumes an already configured adapter. ``` import { BrowserSessionManager } from "station-browser-use"; const browsers = new BrowserSessionManager(adapter, 4, { recordingRootDir: "/data/browser-recordings", stateRootDir: "/data/browser-state", intervalMs: 5_000, maxFrames: 120, maxTotalBytes: 64 * 1024 * 1024, }); const session = await browsers.open({ profileId: "research" }); const recording = browsers.startRecording(session.id); // ...your browser actions... await browsers.stopRecording(recording.id); await browsers.closeSession(session.id); // Review saved frames in the dashboard's Recordings page. ``` Recordings are timestamped PNG frames, not continuous video. Busy actions can skip captures, storage or frame limits stop recording, and capture ticks do not keep an idle session alive. At a five-second cadence, 120 frames represent roughly ten minutes if no capture is skipped. Configured disk storage preserves captured frames across worker restarts; it does not keep the browser running. ## Operate from the dashboard Open **Browser Use → worker → session**. Use its action controls to inspect or operate the selected page, **Live** for periodic screenshots and human takeover, and **Recordings** for playback. The page is an observation and control surface; closing the dashboard does not close the remote browser. | Keep | Where it lives | What it does not restore | | --- | --- | --- | | Profile | Configured local/container storage or a granted provider profile. | Guaranteed login validity, running scripts or all open tabs. | | Recording | Worker recording root, with retention and byte limits. | The session itself, or activity between captured frames. | | Checkpoint and audit | Separate configured state root, bounded by retention limits. | Exact application state or automatic replay of unfinished mutations. | | Download / trace artifact | Session-scoped artifact store where supported. | Durability after session closure. Export needed artifacts beforehand. | Keep profile, recording and state roots separate and assign them to one logical worker. Live sessions remain on their owner; shared queue storage does not migrate them. Worker restarts interrupt browsers. Explicitly reopen or resume after cleanup, and inspect unknown action outcomes before retrying. [**Configure the worker →**Adapter setup, tenant authorization, API reference and deployment boundaries.](https://station.dterminal.net/docs/execution.md)[**Route through Headquarters →**Understand session ownership and how it differs from queued jobs.](https://station.dterminal.net/docs/network.md) --- # Sandbox and Browser Use > Canonical HTML: [https://station.dterminal.net/docs/execution](https://station.dterminal.net/docs/execution) Station supplies two separate server execution primitives: native shell workspaces and live browser sessions. A Headquarters service can expose their authenticated API while private, specialized workers own the resources. Use the host backend for trusted work. Customer execution requires tenant-scoped authorization and isolated, network-restricted container backends. [**Understand sandboxes →**Files, shells, tool installation, long-running agents and persistence.](https://station.dterminal.net/docs/sandboxes.md)[**Understand Browser Use →**Agent tools, session ownership, profiles, recordings and human control.](https://station.dterminal.net/docs/browser-use.md) This page is the configuration and security reference. The illustrated guides above explain the lifecycle before you wire the primitives into a network. For a step-by-step sandbox walkthrough, read the [detailed Sandbox guide](https://github.com/porkytheblack/station/blob/main/packages/station-sandbox/GUIDE.md). It covers custom tool installation, Git credentials, dashboard pages, tenant API payloads and the different recovery behavior of host and container backends. For a containerized controller and persistent agent setup, see [Docker Compose and persistent agents](https://station.dterminal.net/docs/docker-compose.md). | Primitive | Runs where | Purpose | | --- | --- | --- | | station-sandbox | POSIX worker | Persistent files, native Bash and bounded commands through a SandboxAdapter. | | station-browser-use | Server browser worker | Navigation, interaction, evaluation and screenshots through Bun WebView or Playwright. | | station-browser | Web Worker/service worker | Browser-local Station signals, DAGs and beacons with IndexedDB. | Browser Use does not require a Sandbox workspace. The separate [browser runtime](https://station.dterminal.net/docs/browser.md) executes Station inside the browser; it does not run Bash or control server browser sessions. For this checkout, use workspace dependencies and the [execution-network example](https://github.com/porkytheblack/station/tree/main/examples/18-execution-network). These additions are prepared for Station 3.0.0 and still require release and target deployment validation. ## Hosted browsers and access reliability Browser Use supports Browserbase and Steel through provider-managed sessions and Playwright CDP. The existing agent tools, screenshots and timed recordings use the selected worker. Provider credentials and profile grants stay in worker configuration. Read the [remote browser guide](https://github.com/porkytheblack/station/blob/main/packages/station-browser-use/REMOTE.md)for proxy settings, tenant deployment requirements and session cleanup. Remote adapters enable action pacing and challenge checks; local and container Playwright can opt in. Diagnostics reports challenges, blocked pages and throttling. Pause the agent for human Live takeover when needed. These checks do not guarantee CAPTCHA-free access. Remote downloads and trace exports remain disabled pending provider-specific artifact handling; uncertain session creation requires reconciliation. ## Persistent accounts and media For WhatsApp, TikTok and Instagram workflows, retain a separate profile grant per tenant and account, then open short-lived sessions with that profile. A browser closing is not an account logout. Steel saves profile changes on release; the operator must wait until the profile is READY before reuse. Authentication can still require human renewal. WhatsApp QR linking was tested through Steel and Station dashboard Live view. Authenticated profile reuse and TikTok/Instagram workflows remain unverified. Browser uploads currently accept at most 16 files and 4 MiB of decoded data per command. Large-video staging and remote provider downloads still need artifact integrations; uploading a file and publishing it are separate actions. Read the [account and media workflow guide](https://github.com/porkytheblack/station/blob/main/packages/station-browser-use/REMOTE.md#account-workflows-whatsapp-tiktok-and-instagram) for lifecycle, transfer limits and acceptance checks. ## Native trusted workspaces ``` import { HostSandboxAdapter } from "station-sandbox"; const sandboxes = new HostSandboxAdapter({ rootDir: "/data/workspaces", maxEnvironments: 8, maxConcurrent: 3, maxOutputBytes: 256 * 1024, maxTimeoutMs: 300_000, maxHistoryPerSandbox: 100, env: { PATH: "/opt/tools/bin:/usr/local/bin:/usr/bin:/bin" }, }); const workspace = await sandboxes.create(); const started = await sandboxes.exec(workspace.id, { command: "node --version && git --version && printf hello > greeting.txt", timeoutMs: 30_000, }); // Poll this until finishedAt before treating the result as final. const result = await sandboxes.command(workspace.id, started.id); console.log(result.status, result.stdout, result.stderr); // Graceful shutdown interrupts commands and preserves saved files. await sandboxes.close(); ``` Install Bash, Node, Git and other native tools in the worker image or host. Commands use those real programs; Unix is not emulated. Each command starts a fresh noninteractive shell with a separate workspace home. Files persist; shell bindings do not. The file API supports bounded reads, writes, uploads and directory listings. An optional relative `cwd` must resolve inside the workspace. The host-process adapter advertises `isolated: false` and ` pty: true` when explicitly enabled on a Node controller. Commands can access everything permitted to the worker's OS user, including other workspaces. Directory validation and explicit environment variables organize trusted work; they are not a security boundary. Provision OS/container CPU, memory, disk and process limits separately. Defaults are 20 workspaces, four concurrent commands, 256 KiB of combined captured output, a 30-second timeout with a configurable five-minute maximum, and 100 retained completed commands per workspace. Output is byte-bounded and UTF-8 aware; older completed command IDs expire. Ordinary descendants are cleaned up on shell exit, cancellation, timeout and shutdown. Deliberately escaped process groups are outside this backend's guarantees. Container workspaces use a stronger cancellation boundary: cancelling or timing out a command, stopping a running service, or closing a live terminal stops the entire workspace container, including escaped process sessions. Other active commands, terminals and services become interrupted; their restart policies do not replay them. Files and installed tools remain on the persistent volume. The next file, command, terminal or service operation waits for old handles to finish and starts a fresh container; restart interrupted services explicitly. Other workspaces are unaffected. Failed containment leaves this workspace unavailable. Ordinary completed commands use controller-captured process identity for descendant cleanup; workload-written files never establish ownership. ## Install tools into a workspace ``` npm install --global --ignore-scripts --no-audit --no-fund /data/custom-tool.tgz # After installation succeeds, run its binary by name in a new command: custom-tool --version ``` Commands prepend `workspace/node_modules/.bin` and ` HOME/.local/bin` to the configured or host PATH. npm's global prefix defaults to `HOME/.local`, so custom tools stay in the workspace home. Project-local npm binaries take precedence over workspace global tools. npm and required native dependencies must already be installed on the worker; a dependency-free local tarball can be installed offline. Tools survive fresh shells and worker restarts when the workspace volume survives. Other workspaces do not gain them through PATH. This does not restrict filesystem access: trusted commands retain the OS user's permissions. An explicit `env.NPM_CONFIG_PREFIX` override changes installation location; include its bin directory in `env.PATH` when required. ## Terminals, services and files ``` const sandbox = new HostSandboxAdapter({ rootDir: "/data/workspaces", enablePty: true, }); // Install optional node-pty; controller must run Node. const ws = await sandbox.create(); await sandbox.writeFile(ws.id, "hello.txt", { base64: Buffer.from("hello").toString("base64"), }); const terminal = await sandbox.openTerminal(ws.id, { cols: 100, rows: 24 }); await sandbox.terminalInput(ws.id, terminal.id, "node --version "); const output = await sandbox.terminal(ws.id, terminal.id, 0); await sandbox.resizeTerminal(ws.id, terminal.id, 120, 30); const service = await sandbox.startService(ws.id, { name: "app", command: "node server.js", restart: { policy: "on-failure", maxRestarts: 5, delayMs: 1000 }, }); await sandbox.stopService(ws.id, service.id); ``` Terminal output uses byte offsets and a bounded retained buffer. Reconnect while the worker lives; restart interrupts the shell. Service restart policy is explicit, bounded and stored with service intent. File APIs reject traversal and symlinks; the trusted host backend still cannot confine commands to those paths. ## Isolated container workspaces ``` import { ContainerSandboxAdapter } from "station-sandbox/container"; const sandbox = new ContainerSandboxAdapter({ rootDir: "/data/container-state", image: "your-registry/station-tools@sha256:YOUR_VERIFIED_DIGEST", engine: "docker", // Podman is also supported. network: "none", memoryMb: 512, cpus: 1, pidsLimit: 128, enablePty: true, seccompProfile: "/etc/station/seccomp.json", // Reviewed deny-default policy. }); await sandbox.ready(); ``` Provision a Linux engine and pre-pull an operator-controlled image containing Node, Bash and setsid. Each workspace has a nonroot container and persistent named volume. The adapter drops capabilities, uses a read-only root, bounds CPU/memory/PIDs and exposes no engine socket or arbitrary host mounts to workload code. Engine access belongs exclusively to the controller. It never falls back to host execution. Both ContainerSandboxAdapter and ContainerBrowserAdapter require enforced seccomp. If the engine default is unconfined, supply an absolute `seccompProfile` pointing to an operator-reviewed deny-default JSON policy. Station validates and stages a private copy. Missing or unsafe policy fails admission; it does not change global engine settings. This policy is independent of the network and disk controls below. Network access defaults to none. Public customers must not receive unrestricted bridge networking: protect cloud metadata, private networks and other tenants with an operator-enforced egress policy. Named volumes need storage-level disk quotas; command output limits do not limit what a program can write to disk. Root ownership locks are local, not distributed fencing. ## Agent-controlled browser workflows Mount Browser Use as agent tools; use the dashboard to observe sessions or take temporary human control. The toolset binds to one authenticated worker and scopes resource IDs to a workflow. ``` import { BrowserUseClient, createBrowserAgentTools } from "station-browser-use/agent"; const tools = createBrowserAgentTools({ client: new BrowserUseClient({ baseUrl: "https://station.example.com", stationId: "tenant-browser-worker", apiKey: process.env.STATION_EXECUTION_KEY!, }), maxSessions: 2, profileIds: ["research"], }); // Mount name, description, inputSchema and execute(input, { signal }). // Feed result.images into the model's native image input channel. try { await runYourAgent(tools); } finally { await tools.close(); } ``` Tools cover opening sessions, DOM/ARIA observation, navigation, structured interactions, screenshots, checkpoints, resume and close. Each name starts with `station_browser_`. The model cannot choose credentials, tenant identity, worker or a human-control token. Retained profiles, sessions and checkpoints require explicit host grants. Screenshot bytes are returned separately from text. The Foundry example maps them to native model image observations after tool results have been committed. Ordinary JSON containing base64 does not provide vision. Treat website content as untrusted data. Text results are bounded and explicitly marked when truncated. The client defaults to tenant execution-only API keys. Operator access requires an explicit development configuration. Requests refuse redirects and never retry mutations; an unknown transport outcome requires checking worker state. Cleanup respects human control and surfaces failures for retry after the lease releases. An uncertain open or resume blocks further admission in that toolset. ` uncertainOpenings()` reports the count and cleanup reports unresolved sessions. The host must reconcile before creating another toolset; definitive missing-session responses during close release local capacity. The [Foundry browser-agent example](https://github.com/porkytheblack/station/tree/main/examples/19-foundry-browser) shows the complete tool and image bridge. Its sample budget is 14 turns, 600 output tokens per call and one live session, with no agent-level retries. Replace its in-memory conversation store with your application's durable storage when retaining history across runs. ## Independent browser sessions ``` import { BrowserSessionManager } from "station-browser-use"; import { BunBrowserAdapter } from "station-browser-use/bun"; const browsers = new BrowserSessionManager(new BunBrowserAdapter({ bunPath: "bun", backend: "chrome", operationTimeoutMs: 30_000, }), 3); try { const session = await browsers.open(); await browsers.perform(session.id, "navigate", "https://example.com"); const title = await browsers.perform(session.id, "evaluate", "document.title"); const image = await browsers.perform(session.id, "screenshot"); // image: { mimeType: "image/png", base64: string } console.log(title, image); await browsers.closeSession(session.id); } finally { await browsers.close(); } ``` Bun uses a dedicated subprocess per session with an ephemeral profile. Install a Bun version providing WebView and a compatible Chromium binary; ` chromePath` can select its executable. The default backend is Chrome; WebKit is an explicit macOS-only option. A Node Station controller can manage these Bun children without migrating its own runtime. ``` // Alternatively use the optional Playwright peer and installed Chromium: import { PlaywrightBrowserAdapter } from "station-browser-use/playwright"; const browsers = new BrowserSessionManager( new PlaywrightBrowserAdapter({ timeoutMs: 30_000 }), 3, ); ``` Both adapters support navigate, evaluate, click, type, press and screenshot. Use CSS selectors; focus an element before typing. Evaluation returns JSON-compatible values; wrap multiple statements in an IIFE for Bun. Screenshots capture the current viewport as PNG. The manager rejects concurrent actions on the same handle with `busy`. Browser sessions have independent lifecycles and are not tenant isolation boundaries. Playwright supports persistent profiles, multiple pages, structured form actions, uploads/downloads and operator-configured proxy settings. Configure profileRootDir to retain cookies across sessions. Live tabs and process memory are still lost on restart. The manager expires idle sessions and retains bounded audit metadata. Bun WebView is experimental. Both adapters passed real Chromium checks on macOS and in a Debian ARM64 container. Linux fixture tests disable Chromium's own sandbox; they do not establish production isolation, Railway deployment support, or a throughput/memory advantage. Direct navigation and new-page URLs accept absolute HTTP(S) addresses without embedded credentials, plus `about:blank`. Local files, executable URLs and internal browser pages are rejected. Playwright also guards document requests and closes unexpected non-web popup or frame navigations. Ordinary ` about:srcdoc` frames and Chromium network-error pages are allowed. These checks are not a domain or IP egress firewall. Host Playwright launches Chromium with an allowlisted environment and private temporary home, excluding worker secrets and loader overrides; this is credential hygiene, not OS isolation. ## Profiles, page tools and durable recordings ``` const browsers = new BrowserSessionManager( new PlaywrightBrowserAdapter({ profileRootDir: "/data/profiles" }), 3, { recordingRootDir: "/data/recordings", stateRootDir: "/data/browser-state", idleTimeoutMs: 900_000 }, ); const session = await browsers.open({ profileId: "research" }); await browsers.execute(session.id, { op: "newPage", url: "https://example.com" }); await browsers.execute(session.id, { op: "fill", selector: "#query", value: "Station" }); const pages = await browsers.execute(session.id, { op: "pages" }); const recording = browsers.startRecording(session.id); ``` Structured operations include fill, select, check, hover, scroll, waitFor, content, history navigation, page management and bounded upload/download artifacts. Bun advertises only its supported basic capabilities; the dashboard hides unsupported tools. A profile can be open only once per owning manager, and storage roots must have exactly one live owner. Download and trace artifacts are bounded and session-owned. Configure a separate stateRootDir for durable action history/checkpoints; recordings and saved profiles have their own storage roots. ## Semantic targets and page inspection ``` await browsers.execute(session.id, { op: "click", target: { by: "role", role: "button", name: "Continue", exact: true }, }); await browsers.execute(session.id, { op: "fill", target: { by: "label", value: "Code", frame: ["#payment-frame"] }, value: "1234", }); const page = await browsers.execute(session.id, { op: "inspect", maxElements: 100, maxTextLength: 256, }); const accessible = await browsers.execute(session.id, { op: "accessibility", depth: 10, boxes: true, }); ``` Targets support selector, role/name, text, label and testId, optional exact matching, an iframe-selector chain of up to eight frames, and an explicit zero-based nth match. Ambiguous matches fail. Existing form/upload/download operations accept exactly one selector or target; click, focus and press are structured operations too. Inspection returns bounded DOM metadata, truncation and coordinateSpace. Password and file input values are omitted. Defaults are 100 elements and 256 characters per field; maxima are 500 elements, 4096 characters and 4 MiB total output. Accessibility returns an ARIA YAML snapshot, bounded to 1 MiB and depth 20. Frame-targeted DOM boxes use that frame's viewport; main-page boxes use the main viewport. Re-resolve targets after the page changes. mouseClick and mouseMove use main-viewport CSS-pixel coordinates. drag takes source and destination targets; dragCoordinates takes from/to points and bounded movement steps. A one-shot dialog policy can accept or dismiss the next selected-page dialog, optionally supplying prompt text. It expires after ten seconds by default (maximum thirty seconds). Unexpected or expired dialogs dismiss immediately, so actions do not wait for a later RPC to resolve a modal. ## Live viewing and human control ``` const lease = browsers.acquireControl(session.id); // Default 30 seconds. try { await browsers.execute(session.id, { op: "mouseClick", x: 200, y: 120, }, lease.token); const frame = await browsers.liveFrame(session.id); // Renew before expiry when keeping manual control: browsers.renewControl(session.id, lease.token); } finally { browsers.releaseControl(session.id, lease.token); } ``` Live view polls current PNG frames with timestamps; it is not a video stream. Busy frames are skipped and viewing does not extend idle lifetime. acquireControl grants an exclusive live lease for 1–120 seconds. While held, actions require its token, so automation cannot interleave browser input. control reports mode/expiry without exposing the token. Renew or release intentionally; expiry returns control to automation. Leases do not survive worker restart. Remote close honors the lease; direct lifecycle cleanup remains available to the worker. ## Diagnostics and trace artifacts ``` import type { BrowserArtifact } from "station-browser-use"; const diagnostics = await browsers.execute(session.id, { op: "diagnostics" }); // Console text is intentionally absent unless explicitly enabled for future events: await browsers.execute(session.id, { op: "diagnostics", consoleText: true }); await browsers.execute(session.id, { op: "traceStart" }); // Perform the browser actions to investigate, then export: const trace = await browsers.execute(session.id, { op: "traceStop" }) as BrowserArtifact; const zip = await browsers.execute(session.id, { op: "downloadRead", artifactId: trace.id, }); await browsers.execute(session.id, { op: "downloadDelete", artifactId: trace.id }); ``` Diagnostics retain at most 200 console, network and dialog events. Default events omit console text, headers and bodies; URLs omit credentials, queries and fragments. Opt-in console text is bounded to 2 KiB per event and can still contain arbitrary application secrets. Turning it off purges retained console text; clear removes the ring. Dialog events contain type/action, not prompt contents or answers. Tracing is explicit and can contain sensitive screenshots, DOM and network/action data. ZIP files share the session's download artifact count/byte budget and expire on close. Traces abort and discard partial data at sixty seconds or their monitored raw-file budget. A failed/limited trace does not export a partial archive. Disk growth is sampled every fifty milliseconds; enforce a filesystem/container quota for a hard transient limit. Bun advertises these richer capabilities as unsupported. ## Durable action history and explicit resume ``` const browsers = new BrowserSessionManager(adapter, 4, { stateRootDir: "/data/browser-state", recordingRootDir: "/data/browser-recordings", auditLimit: 1000, // tenantId: "customer-a", // Required for already tenant-bound roots. }); const checkpoint = await browsers.checkpoint(session.id); await browsers.closeSession(session.id); const replacement = await browsers.resumeCheckpoint(checkpoint.id); const journal = browsers.audit(); await browsers.deleteCheckpoint(checkpoint.id); ``` stateRootDir enables an atomic single-owner journal separate from recordings and profiles. Action starts are persisted before execution and finishes afterward, with monotonic sequence numbers. Write failures stop action admission. A started entry without a finish has an unknown outcome; do not automatically replay the mutation. Default retention is the latest 1000 entries (maximum 10000), with an 8 MiB combined state limit. This bounded operational history is not an immutable compliance archive. Tenant identity is verified before recovery or retention can modify stored data. Checkpoints record backend, validated open options, selected page and sanitized HTTP(S) origin/path URLs; credentials, query strings and fragments are omitted. about:blank is also supported, with at most 64 checkpoints. Resume explicitly opens a new session on the same backend and navigates those URLs. It never runs automatically on startup. Persistent profiles can restore saved cookies after old ownership ends; they do not restore JavaScript memory, filled forms, pending requests or exact workflow progress. Re-navigation can itself have effects. Keep roots distinct, preserve their logical owner and use external fencing for failover. ## Use the Headquarters dashboard Sign in to Headquarters with its configured administrator account. ` /sandboxes` provides worker selection, workspace creation, commands/output, cancellation, interactive terminals, supervised services, files and deletion. `/browser-use` provides separate browser session controls, navigation, interaction and screenshots, profiles, pages, semantic/frame targeting, live human control, inspection, diagnostics/traces, checkpoints and recording playback. Both use Headquarters as their public entry point. The admin-only `GET /api/v1/execution` endpoint discovers advertised workers and returns their identities, statuses, capabilities, backend names and availability. Capabilities are not guessed from labels. Discovery still requires selecting the exact owner; it does not schedule or migrate resources. The internal worker token stays between services. ## Record screenshots and play them back Select a live browser and choose Start recording. The worker captures a viewport PNG immediately and then every five seconds, even when the dashboard is closed. Busy browser operations skip a capture rather than queueing screenshots. This is a sequence of still frames, not a video or a complete audit of every action. Select a recording to play, pause or scrub through timestamped frames. Closing a browser stops its recording and keeps captured frames available. Defaults are 120 frames per recording, 16 retained recordings, and 64 MiB of PNG data across the worker manager. Reaching a limit stops capture and preserves existing frames. Delete recordings to release space. Recordings use memory by default. Configure recordingRootDir on persistent storage to survive worker replacement; recordingTtlMs defaults to seven days. A recovered recording is stopped: the platform does not reconstruct the old browser. ``` const recording = browsers.startRecording(session.id); // Later, stop capture without closing the browser: await browsers.stopRecording(recording.id); const metadata = browsers.getRecording(recording.id); const image = browsers.recordingFrame(recording.id, metadata.frames[0].id); // image: { mimeType: "image/png", base64: string } await browsers.deleteRecording(recording.id); ``` The same owner-routed admin endpoint supports recordingStart (session id), recordingStop, recording, recordingDelete (recording id), recordings (list), and recordingFrame (recording id and frameId). Metadata omits PNG payloads; playback fetches individual frames on demand. ## Route through the exact owner Configure three services: public Headquarters, private Sandbox worker and private Browser Use worker. All share Station's network ID and durable coordination adapters. Workers configure `execution.sandbox` or ` execution.browser`; Headquarters needs the shared ` execution.token`. This service token must contain at least 32 characters and stays between trusted services. Public clients use a separate admin API key or authenticated admin session. ``` // Worker configuration fragment, merged with normal network/storage config: execution: { token: process.env.STATION_EXECUTION_TOKEN!, sandbox: sandboxes } // Browser worker: execution: { token, browser: browsers } // Headquarters: execution: { token } // All public calls are JSON POST requests: /api/v1/stations/:stationId/execution/sandbox /api/v1/stations/:stationId/execution/browser ``` ``` // Sandbox request bodies: { "method": "create" } { "method": "exec", "id": "WORKSPACE_ID", "command": "node --version", "timeoutMs": 30000 } { "method": "command", "id": "WORKSPACE_ID", "runId": "COMMAND_ID" } { "method": "cancel", "id": "WORKSPACE_ID", "runId": "COMMAND_ID" } { "method": "destroy", "id": "WORKSPACE_ID" } // Browser request bodies: { "method": "open" } { "method": "execute", "id": "SESSION_ID", "command": { "op": "inspect" } } { "method": "liveFrame", "id": "SESSION_ID" } { "method": "controlAcquire", "id": "SESSION_ID", "ttlMs": 30000 } { "method": "checkpoint", "id": "SESSION_ID" } { "method": "action", "id": "SESSION_ID", "action": "navigate", "value": "https://example.com" } { "method": "action", "id": "SESSION_ID", "action": "screenshot" } { "method": "close", "id": "SESSION_ID" } ``` Successful responses wrap results in `data`. Both primitives also support `list`; Sandbox supports `get`. Keep the selected owner station ID with every resource ID. This API does not automatically place environments or persist distributed session ownership. Existing signal queue placement remains separate. Headquarters rejects offline, expired-lease and wrong-network owners, follows no redirects and never forwards the public API key to a worker. Draining blocks new work and browser actions while preserving Sandbox inspection/cancellation/deletion and browser list/close operations. It does not reroute a live resource to another station. Requests are capped at 128 KiB for ordinary operations; file uploads allow an 8 MiB JSON envelope with at most 4 MiB decoded content. Successful proxied responses are capped at 33 MiB including JSON/base64 overhead. A timeout can leave the operation's outcome unknown: inspect the owner before repeating create, open, exec or any other mutation. ## Public tenant execution Keep the dashboard and operator API restricted to your staff. Customer keys must have only the execution scope; Headquarters maps their verified key record IDs to tenant IDs. Each private worker is dedicated to one tenant. Both Headquarters and the worker check ownership, and tenant mode refuses host or unrestricted-network backends. Persisted owner bindings prevent reusing a data root for a different tenant. ``` // Headquarters: operator-owned configuration execution: { token: hqSecret, targets: { "worker-a": { endpoint: "https://worker-a.internal", token: workerASecret, tenantId: "customer-a", } }, tenants: { apiKeyTenants: { "VERIFIED_KEY_RECORD_ID": "customer-a" } }, } // Dedicated private worker, with an isolated/restricted adapter: execution: { token: workerASecret, tenantId: "customer-a", sandbox } // Customer endpoints: // GET /api/v1/tenant/execution // POST /api/v1/tenant/stations/:stationId/execution/:primitive ``` Configure independent operator authentication on each tenant worker. Headquarters pins the endpoint, tenant and distinct worker credential in execution.targets; heartbeat advertisements cannot change those grants or redirect credentials. Tenant workers reject missing tenant, worker or network assertions. Use HTTPS for private remote targets. Session cookies default to Secure, including behind TLS termination. Set auth.secureCookies to false only for local HTTP development. The trustedProxies configuration accepts explicit ingress IP addresses; untrusted forwarded headers cannot change rate-limit buckets. Dashboard binding uses STATION\_DASHBOARD\_HOST with a loopback default, ignoring ambient container HOSTNAME. The default daemon FileLogStorage streams disk reads and keeps a current and previous segment, each bounded to 64 MiB by default. Pending writes are limited to 4 MiB; queries retain at most the newest 10,000 matching entries or 4 MiB. Oversized or over-capacity writes are dropped and reported through onError. These configurable limits provide bounded operational logs, not a complete audit archive or distributed log. Use a suitable custom store for that requirement. ContainerBrowserAdapter from station-browser-use/container runs each Playwright session inside a separately constrained Linux container with an immutable image worker. Profile volumes and recordings retain their tenant ownership. Default networking is disabled. An operator-enforced named egress network is required for permitted internet browsing; a configuration flag alone does not install that policy. The included Linux deployment profile under `scripts/execution-container/enforced` provisions a dedicated internal Docker network, an HTTPS CONNECT proxy that connects to validated public IPv4 addresses, host firewall rules and XFS project quotas. Configure its verified network and proxy on ContainerBrowserAdapter, and use ` profileStorageRoot` for quota-backed profiles. The immutable image includes a syscall guard that prevents browser descendants from changing quota metadata. This profile requires local rootful Linux Docker and XFS project-quota support. Direct internet connections, ordinary HTTP, UDP/QUIC, IPv6 and private destinations are denied. Both `stateRootDir` and `recordingRootDir` are required for public browser workers; place them under the same tenant quota tree. Verify the policy before starting workers and after host networking changes. Run ` pnpm test:execution:policy` for fast proxy/verifier regressions and the supplied live harness for real network and disk-exhaustion checks. Read the [tenant deployment contract](https://github.com/porkytheblack/station/tree/main/scripts/execution-container)for image builds, key provisioning, storage quotas, egress controls and rollout checks. These APIs supply execution boundaries; customer onboarding, billing, automatic provisioning and distributed failover remain responsibilities of the surrounding platform. ## Persistence and service deployment Use one process/replica per stable worker ID and one manager per Sandbox root. Mount persistent storage for workspace files and Station data; persist Headquarters' data directory for API keys and session secrets. Shared Postgres coordinates Station membership, jobs and schedules; it does not store browser memory or workspace files. A service volume is not a shared multi-worker filesystem. On restart, leftover running command records become interrupted and are never automatically replayed. Saved files and configured profiles/recordings can survive; processes, shells and browser sessions do not. Supervisors must reap the old process tree before a replacement takes ownership. Workers need private reachable HTTP endpoints, packaged tools and browser libraries, and required outbound access. Disable sleeping when retaining live sessions. The example describes an ordinary service deployment contract. Linux primitive checks passed separately from the local SQLite/PostgreSQL dashboard tests; a Railway deployment remains unvalidated. Automatic placement, distributed ownership, migration, high availability, customer onboarding and billing require a platform layer beyond these execution primitives. ## Exercise the full dashboard topology ``` pnpm test:execution:dashboard ``` The integration harness targets the built Headquarters dashboard, real private execution workers, browser interaction/screenshots and a custom CLI installed from a dependency-free local package offline. Prepare the browser dependencies first; the command builds the dashboard. It covers worker restart and installed-tool persistence, command failures, cancellation, timeouts, workspace deletion, and closing browsers during pending actions. Passing this local test does not establish cloud deployment readiness. ## Verify browser-agent integrations ``` # Real authenticated browser control, without model inference pnpm test:browser-use:tools # Native image delivery and error handling pnpm test:browser-use:bridge # Optional paid real-model test; configure the Foundry example first pnpm test:browser-use:agent ``` Normal tests and release preflight include the local tool and bridge checks and do not require a model-provider key. The real-model test needs a built Glove checkout and a configured OpenRouter key. It asks an agent to read an image, complete a generated form, verify confirmation and close its browser. Its explicit protocol-only mode verifies Foundry assembly without inference. Local browser and protocol checks are separate from successful model-driven execution; a rejected credential leaves that external test unverified. ## Opt in to Bun signal and beacon children ``` import { defineConfig } from "station-daemon"; import { BunProcessRuntime } from "station-signal"; export default defineConfig({ signalsDir: "./src/signals", beaconsDir: "./src/beacons", processRuntime: new BunProcessRuntime("bun"), }); ``` Node remains the default. `ProcessRuntime` selects signal/beacon bootstrap children and preserves JSON IPC; Bun loads TypeScript natively. The same option is available on `SignalRunner` and ` BeaconRunner`. It does not switch the controller, Sandbox shell or browser adapter, and provides no isolation. Validate dependencies and representative workload behavior before rollout; benchmark actual Station jobs before claiming faster throughput or lower resource use. Read the [Sandbox reference](https://github.com/porkytheblack/station/tree/main/packages/station-sandbox), [Browser Use reference](https://github.com/porkytheblack/station/tree/main/packages/station-browser-use), [three-service example](https://github.com/porkytheblack/station/tree/main/examples/18-execution-network) and [Station Network guide](https://station.dterminal.net/docs/network.md) for the complete setup. --- # Station dashboard > Canonical HTML: [https://station.dterminal.net/docs/dashboard](https://station.dterminal.net/docs/dashboard) The dashboard is the separate `station-dashboard` application. It connects to the API of a local or remote `station-daemon`. In standalone mode it shows one process. In a Station Network it gives Headquarters one fleet-wide view of queued work, workers, schedules, beacons, broadcasts, and environment configuration. **New here?** Follow the four-minute setup below, then use Overview → Signals → Run detail. **Operating a fleet?** Start with Stations, Schedules, Environment, and the v1 API panels shown at the bottom of each screen. * * * ## Quick start ``` pnpm add station-daemon station-adapter-sqlite pnpm add -D station-runtime-cli station-dashboard ``` ``` // station.config.ts import { defineConfig } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; import { BeaconSqliteAdapter } from "station-adapter-sqlite/beacon"; import { ScheduleSqliteAdapter } from "station-adapter-sqlite/schedules"; import { EnvSqliteAdapter } from "station-adapter-sqlite/env"; const dbPath = "./station.db"; export default defineConfig({ port: 4400, signalsDir: "./signals", broadcastsDir: "./broadcasts", beaconsDir: "./beacons", adapter: new SqliteAdapter({ dbPath }), broadcastAdapter: new BroadcastSqliteAdapter({ dbPath }), beaconAdapter: new BeaconSqliteAdapter({ dbPath }), scheduleAdapter: new ScheduleSqliteAdapter({ dbPath }), envStorage: new EnvSqliteAdapter({ dbPath }), auth: { username: "admin", password: process.env.STATION_PASSWORD! }, }); ``` ``` STATION_PASSWORD=change-me pnpm exec stationd ``` In another terminal, start the dashboard with the daemon URL. Open `http://127.0.0.1:4401`. The daemon stays on port 4400; dashboard shutdown does not stop its runners. ``` STATION_DAEMON_URL=http://127.0.0.1:4400 PORT=4401 STATION_DASHBOARD_HOST=127.0.0.1 pnpm exec station-dashboard ``` For a remote daemon, set `STATION_DAEMON_URL` to its trusted HTTPS address. The dashboard server forwards requests to that configured daemon; keep worker services private behind Headquarters. Use `PORT` and `STATION_DASHBOARD_HOST` for the dashboard listener. Authentication is optional for localhost, but do not expose an unauthenticated dashboard. In production, use environment-backed credentials, TLS, and scoped API keys for automation. ![Station login with username and password fields](https://station.dterminal.net/screenshots/login.png) * * * ## A map of the dashboard | Screen | Use it for | | --- | --- | | **Overview** | Fleet-wide status totals, recent failures, and live lifecycle events. | | **Signals** | Discover definitions, validate input, trigger work, and inspect run history. | | **Broadcasts** | Trigger and observe multi-signal DAG workflows. | | **Beacons** | Supervise long-running processes and their runtime instances. | | **Schedules** | Create, pause, preview, and inspect interval or timezone-aware cron schedules. | | **Stations** | See network membership, capacity, definitions, heartbeats, and drain state. | | **Environment** | Manage global or target-scoped runtime variables and redacted secrets. | | **Expressions** | Parse and test broadcast expressions before saving a workflow. | | **Settings** | Create scoped API keys and inspect server configuration. | ## Overview and live activity ![Overview with run totals, recent failures, and live activity](https://station.dterminal.net/screenshots/overview.png) Status cards and failure rows come from durable adapters. Live Activity arrives over the dashboard connection, so the green header dot confirms that new events can arrive without a refresh. Click a failed run to see its validated input, output, attempts, steps, and captured logs. ## Signals and broadcasts ![Signals catalog with trigger controls and execution limits](https://station.dterminal.net/screenshots/signals.png) A signal page is the quickest way to test a definition: Station renders its Zod schema as a form, also offers raw JSON, and records the resulting run. The configuration panel distinguishes station-local and fleet-wide concurrency and shows placement labels. Broadcast pages render the DAG, then color nodes as their underlying signal runs change state. ![Broadcast catalog with release-pipeline and trigger action](https://station.dterminal.net/screenshots/broadcasts.png) ## Schedules ![Runtime schedules showing an interval signal and timezone cron broadcast](https://station.dterminal.net/screenshots/schedules.png) Schedules can target a signal or broadcast. Use an interval for elapsed cadence, or five-field cron plus an IANA timezone for wall-clock time. Preview before saving. In a network, durable occurrence claims make one Headquarters instance enqueue each occurrence once. The displayed time is when work becomes eligible; queue pressure can delay handler start. ## Station Network ![Headquarters with CPU and GPU execution stations](https://station.dterminal.net/screenshots/station-network.png) Every row shows the stable station identity, role, lease-backed status, active/max capacity, advertised definitions, and last heartbeat. Drain a worker before deployment: current runs may finish, while new claims stop. An offline row means its lease expired, not merely that a browser lost its dashboard connection. On a phone, navigation collapses to its icons and wide operational tables scroll horizontally. ![Responsive Station Network dashboard on a phone](https://station.dterminal.net/screenshots/station-network-mobile.png) ## Beacons ![Fleet beacon catalog showing a running heartbeat poller](https://station.dterminal.net/screenshots/beacons.png) Beacons are supervised long-running services or pollers. Headquarters receives their definition metadata from workers, while shared state tracks which station owns each instance. Start mode controls whether an instance is seeded automatically, seeded stopped, or created only by an API request. Restart counts and events help distinguish clean exits from crash loops or heartbeat stalls. ## Environment and secrets ![Environment page with a redacted global ASSET_BUCKET value](https://station.dterminal.net/screenshots/environment.png) Variables can be global or scoped to named signals and beacons. Secrets are write-only in the API and stay redacted in the UI. A definition that declares `.env("ASSET_BUCKET")` will not start until a matching value exists; a scoped value overrides a global value with the same key. * * * ## Configuration reference | Option | What it controls | | --- | --- | | `port` / `host` | Single public UI and API listener. Defaults to `4400` / `localhost`. | | `role` | `standalone`, `headquarters`, or execution `station`. | | `network` | Fleet ID, stable station ID/name, durable membership adapter, labels, endpoint, heartbeat, and lease timing. | | `signalsDir`, `broadcastsDir`, `beaconsDir` | Definition discovery roots. | | `adapter` and specialized adapters | Durable signal, broadcast, beacon, schedule, environment, key, and log state. | | `runner.maxConcurrent` | Total signal processes allowed on this Station. Signal declarations can apply narrower local/fleet caps. | | `runRunners` | Whether this process executes work; defaults to false for Headquarters and true otherwise. | | `auth` | Dashboard credentials, session lifetime, and optional API-key storage. | ## Operator checklist - Use the same durable adapters on every process participating in one fleet. - Give every process a unique, stable `network.stationId`. - Restrict dashboard credentials and give automation only the API scopes it needs. - Alert on repeated failures, offline workers, stale heartbeats, and beacon restart loops. - Drain workers before deployments and verify active capacity reaches zero. - Back up adapter state and test expired-lease recovery before production rollout. Next: [design a Station Network](https://station.dterminal.net/docs/network.md), run the [local three-process example](https://station.dterminal.net/docs/examples/station-network.md), or use the [complete Station Daemon API reference](https://station.dterminal.net/docs/station.md). --- # Station Networks > Canonical HTML: [https://station.dterminal.net/docs/network](https://station.dterminal.net/docs/network) A **Station Network** scales Station across processes or machines. One logical **Headquarters** accepts requests, presents fleet-wide state, and reconciles schedules. Execution stations advertise capacity and definitions, then atomically claim work from the shared adapters. Queued work: any eligible worker can claim it 1. Public entry point**Headquarters**Authenticates a request, validates it and enqueues the run. 2. Shared durable state**Queue + leases**Atomic claims assign one attempt to one owner. State is shared across the fleet. 3. Private execution**Eligible worker**Checks definitions, placement and capacity, then executes the claimed run. Workers claim queued work; Headquarters does not push every job directly. Retries and lease recovery can repeat work, so external effects still need idempotency. ## Roles and request flow | Role | Responsibility | | --- | --- | | `headquarters` | API, schedules, broadcasts, routing and fleet inventory for the separate dashboard. It does not execute signals or beacons. | | `station` | Advertises local definitions and executes eligible signal runs and beacon instances. | | `standalone` | Backwards-compatible single-node mode that performs both roles. | Headquarters enqueues a run once. Stations race to claim it in the shared queue; the adapter's atomic pending-to-running transition chooses exactly one owner. If that owner disappears, its lease expires and the run is recovered. Fencing tokens prevent the old owner from later completing the recovered attempt. Specialized stations can also own [Sandbox workspaces and Browser Use sessions](https://station.dterminal.net/docs/execution.md). Their initial execution API routes requests to an explicitly selected owner through Headquarters; it does not use signal queue placement or migrate live sessions. Live environments: return to their owning worker 1. Client or agent**Workspace / session ID**Requests another command, browser action or screenshot. 2. Authenticated routing**Headquarters**Resolves the authorized worker target; tenant grants are operator-configured. 3. Existing owner**Sandbox or browser**The selected worker operates its retained workspace or live browser session. Session routing is not queue placement. A different worker cannot automatically pick up an open terminal or browser just because both workers share a database. For example, keep CPU build tools on one Station and browser sessions on another. Both can be private services behind Headquarters. A signal can invoke their APIs, but application code must retain resource IDs and handle interrupted or unknown outcomes. Read the [Sandbox](https://station.dterminal.net/docs/sandboxes.md) and [Browser Use](https://station.dterminal.net/docs/browser-use.md) lifecycle guides before planning recovery. A Station Network coordinates work; it does not provision a VPN or Docker-style network. Workers need access to shared adapters and Headquarters needs a reachable, authenticated endpoint for proxied execution. Configure DNS, TLS, firewalls and ingress in the deployment. Keep private worker credentials and tenant assignments in operator configuration; a heartbeat is discovery data, not permission to act as a tenant. ## Configure Headquarters ``` import { defineConfig } from "station-daemon"; import { PostgresAdapter } from "station-adapter-postgres"; import { StationNetworkPostgresAdapter } from "station-adapter-postgres/network"; const connectionString = process.env.DATABASE_URL!; export default defineConfig({ role: "headquarters", adapter: new PostgresAdapter({ connectionString }), network: { id: "production", stationId: "hq-1", name: "Production HQ", adapter: new StationNetworkPostgresAdapter({ connectionString }), }, signalsDir: "./signals", // catalog + validation; never executed here scheduleAdapter, beaconAdapter, }); ``` ## Configure an execution station ``` export default defineConfig({ role: "station", adapter: new PostgresAdapter({ connectionString }), // same queue beaconAdapter, // same beacon state network: { id: "production", stationId: process.env.STATION_ID!, name: "Kenya GPU worker", adapter: new StationNetworkPostgresAdapter({ connectionString }), labels: { region: "ke", gpu: "true" }, endpoint: "https://worker-ke.internal.example", }, signalsDir: "./signals", beaconsDir: "./beacons", runner: { maxConcurrent: 12 }, }); ``` Use the matching `/network` export for SQLite, PostgreSQL, MySQL, or Redis. Every process must use the same durable queue and network backends. Share beacon state on nodes that coordinate beacons, and share schedule state across Headquarters replicas. The memory implementations are only for standalone mode and tests. SQLite requires a shared filesystem; use PostgreSQL, MySQL, or Redis across machines. ## Capacity, placement, and draining ``` export const render = signal("render") .input(RenderInput) .concurrency({ station: 4, network: 20 }) .placement({ labels: { gpu: "true", region: "ke" } }) .run(async (input) => { /* ... */ }); export const gateway = beacon("gateway") .placement({ labels: { region: "ke" } }) .run(async (ctx) => { const server = await listen(); ctx.expose({ protocol: "http", port: server.port, path: "/gateway" }); ctx.ready(); await ctx.untilStopped(); }); ``` Per-station concurrency limits local process pressure. Network concurrency uses shared controller leases and is enforced across the fleet. Placement labels require an exact match. Marking a station `draining` through the Stations dashboard or v1 API stops new claims while current work finishes. ## Schedules and exact times Runtime schedules support five-field cron plus an IANA timezone. The stored `nextRunAt` is an absolute timestamp and occurrences advance from the prior planned time, so polling latency does not create cumulative drift. Atomic occurrence claims prevent duplicate fires across control-plane processes. As with OS cron, the timestamp is when work becomes eligible; actual handler start can be delayed by polling, queue pressure, or unavailable capacity. See [Schedules](https://station.dterminal.net/docs/schedules.md). ## Beacon services A networked beacon instance is protected by a single-owner lease. Calling `ctx.expose()` records its station, protocol, port, and base path. Headquarters proxies HTTP traffic at `/api/v1/beacons/:name/instances/:id/proxy/*`. The owning station must advertise a reachable `network.endpoint`; private/NAT-only stations need an operator-provided tunnel endpoint. The proxy requires a `trigger` or `admin` scope, removes the caller's authorization and cookie headers before forwarding, and does not proxy WebSocket upgrades. Protect direct station endpoints and do not treat the injected `x-station-*` headers as proof of identity on a publicly reachable service. ## What happens when a worker disappears? | Resource | Recovery model | | --- | --- | | Signal attempt | Lease expiry makes recovery possible. Fencing rejects stale completion; it cannot undo an external effect already performed. | | Beacon process | Shared intent and ownership leases coordinate a replacement. New process memory starts fresh. | | Sandbox workspace | Files may persist on its owner. Restart and service recovery depend on the adapter; there is no automatic cross-worker workspace migration. | | Browser session | The live browser is interrupted. Retained profiles, recordings and explicit checkpoints can help reopen on a compatible owner. | Draining stops new queued claims. Also inspect worker-owned shells, services and browser sessions before maintenance; an empty signal queue does not prove the worker has no live environments. ## Production checklist - Give every process a stable, unique `stationId` and the same `network.id`. - Keep the lease duration above normal database, network, and event-loop jitter. - Drain a station before maintenance; wait for active work before stopping it. - Test at least two workers against the production backend and assert single ownership, placement, both concurrency levels, schedule deduplication, and expired-lease recovery. - Measure the production workload. A local SQLite benchmark is useful for regression detection, not fleet sizing. --- # Agent skill > Canonical HTML: [https://station.dterminal.net/docs/agent-skill](https://station.dterminal.net/docs/agent-skill) Station ships with a Claude Code skill that teaches the AI assistant how to build with every Station package. Once installed, Claude knows how to create signals, broadcasts, beacons, schedules, Station Networks, adapters, subscribers, browser workers, shell workspaces, browser automation, and dashboard configs without you having to explain the API. * * * ## What is a skill? A Claude Code skill is a set of markdown files that get injected into Claude’s context when relevant. Skills contain API references, code patterns, and rules that guide the assistant’s output. The Station skill covers the framework packages, official adapters, Station Networks, and the REST API. * * * ## Install ``` npx skills add porkytheblack/station ``` The skill directory includes these focused references: | File | Description | | --- | --- | | `browser.md` | BrowserStation setup, worker and service-worker hosting, API usage, supported definitions, recovery, and known limits. Read this first for browser-local work. | | `images.md` | Compiled native/JavaScript images, deployment/environment bindings, beacon rollout, enrollment, native planner grants, invocation artifacts, private registries and independent Docker cleanup. | | `execution.md` | Separate Sandbox and Browser Use primitives, exact-owner Headquarters routing, trusted-code limits, persistence, and opt-in Bun process children. | | `SKILL.md` | Main skill file. Contains critical rules, code patterns, builder workflows, network operations, and verification guidance. This concise file is what Claude reads first. | | `api-reference.md` | Exhaustive package and v1 REST API reference, including signals, broadcasts, beacons, schedules, environment variables, expressions, Station Networks, adapters, station-daemon, and station-tauri. | | `examples.md` | Node examples and a linked browser walkthrough covering signals, pipelines, beacons, runtime schedules, environment variables, all four adapter backends, deployment, and a Headquarters/worker topology. | * * * ## Usage Once installed, the skill activates automatically when you ask Claude about Station topics. You can also invoke it explicitly: ``` /station ``` Example prompts that trigger the skill: - “Create a signal that sends welcome emails with retry” - “Set up a broadcast DAG for my CI pipeline” - “Run a local workflow and a polling beacon in browser workers” - “Configure PostgreSQL adapters for signals and broadcasts” - “Scale this across a Headquarters and three GPU stations” - “Add a runner with SQLite persistence and graceful shutdown” - “Write a subscriber that posts failures to Slack” * * * ## What the skill knows | Topic | Coverage | | --- | --- | | Browser runtime | Explicit registries, IndexedDB queues and checkpoints, worker drains, bounded service-worker wake slices, cooperative beacons, and configuration workarounds. See the [browser guide](https://station.dterminal.net/docs/browser.md). | | Server execution | Native shell workspaces, Bun WebView/Playwright sessions, screenshots, owner routing and interrupted-state recovery. See the [execution guide](https://station.dterminal.net/docs/execution.md). | | Signals | Builder chain (`.input()`, `.output()`, `.timeout()`, `.retries()`, `.concurrency()`, `.placement()`, `.every()`, `.onComplete()`, `.run()`), multi-step pipelines (`.step()` + `.build()`), triggering, validation | | Broadcasts | DAG builder (`.node()`, `.then()`), conditional nodes (`when`), failure policies, input/output mapping, fan-out and fan-in patterns | | Adapters | SQLite, PostgreSQL, MySQL, and Redis for signals, broadcasts, beacons, schedules, environment variables, and network state. Constructor patterns, connection options, and subpath imports | | Station Networks | Headquarters and station roles, shared storage, atomic run claims, fencing and recovery, per-station/fleet concurrency, placement, draining, inventory, and beacon HTTP proxying | | Schedules | Editable interval and timezone-aware cron schedules, overlap and misfire policies, atomic occurrence claims, and timing semantics | | Beacons | Supervised servers, pollers, and clients; restart policies, instances, placement, exposure, and graceful shutdown | | Runners | `SignalRunner`, `BroadcastRunner`, and `BeaconRunner` setup, auto-discovery, manual registration, graceful shutdown, poll intervals | | Subscribers | Lifecycle events for signal, broadcast, and beacon runners. Custom subscriber patterns for logging, metrics, and alerting | | Remote triggers | `configure({ endpoint, apiKey })`, `HttpTriggerAdapter`, environment variables, Station REST API endpoints | | Dashboard | `station.config.ts` options, CLI usage, auth configuration | * * * ## Documentation for other agents Use [llms.txt](https://station.dterminal.net/llms.txt) to find focused Markdown pages, including [the browser guide](https://station.dterminal.net/docs/browser.md) and [the lab walkthrough](https://station.dterminal.net/docs/examples/browser.md). [The full reference](https://station.dterminal.net/llms-full.txt) includes all generated documentation. These files are rebuilt from the site pages. ## Updating Re-run the install command to pull the latest version: ``` npx skills add porkytheblack/station ``` --- # Station Images and registry > Canonical HTML: [https://station.dterminal.net/docs/images](https://station.dterminal.net/docs/images) Station 3 accepts independently authored native executables and bundled JavaScript as signals, broadcast planners and beacons. An image is a manifest plus verified executable blobs. It is not an OCI filesystem image: isolation comes from the configured execution backend. Use the [complete authoring and operations guide](https://github.com/porkytheblack/station/blob/main/docs/STATION-IMAGES.md) for a runnable manifest generator, all protocol fields, environment grants, Docker policy, independent cleanup and recovery. Publish compiled work; execute verified bytes 1. Author**Build + manifest**Compile native code or bundle JavaScript. Declare exports, target compatibility and artifact digests. 2. Registry**Publish + resolve**Store verified blobs and metadata. Pin an immutable digest for execution. 3. Station worker**Install + invoke**Run a compatible signal, broadcast planner or beacon with granted input and environment. A Station image packages an execution protocol and artifacts. A Docker image packages a filesystem and runtime. The two serve different roles and can be used together. ## Build the artifact Compile native code for the target OS, architecture and ABI, or bundle JavaScript and its dependencies into a single file. Station does not install application dependencies or compile uploads. A manifest uses `station.image/v1`, artifact SHA-256 digests and byte counts, target declarations, and named exports of kind `signal`, `broadcast` or `beacon`. A finite signal receives an NDJSON invocation on stdin and returns a result on stdout. Parameters are structured JSON; stdout is reserved for the protocol. ``` // build/echo.mjs — no Station import required let text = ""; for await (const chunk of process.stdin) text += chunk; const request = JSON.parse(text); if (request.protocol !== "station.process/v1" || request.type !== "invoke") { throw new Error("Unsupported invocation"); } console.log(JSON.stringify({ protocol: "station.process/v1", type: "result", output: { input: request.input, prefix: process.env.APP_PREFIX ?? "" }, })); ``` The complete guide generates the manifest using the actual artifact bytes. A broadcast export returns a validated declarative DAG; its persisted plan invokes declared image signals or revision-pinned ordinary Station signals explicitly granted by the operator. A beacon remains connected over the supervised protocol and can request declared dependency invocations through a scoped broker. ## Configure, publish and run Configure `registry.rootDir` for storage and `registry.execution` for execution. Select an operator-owned Docker backend with a preinstalled digest-pinned Linux runtime image, matching target and bounded resource policy. Grant only the environment keys required by the exports. Controller credentials are not inherited. The trusted-local backend requires explicit opt-in and must only execute trusted code. ``` station context add local --url http://127.0.0.1:4400 --token-stdin station context use local station images publish ./station-image.json --artifacts-dir ./build station images inspect acme/echo@1.0.0 station images install acme/echo@1.0.0 station images run acme/echo@1.0.0 echo --input '{"message":"hello"}' station images tag acme/echo --tag stable --digest sha256:FULL_MANIFEST_DIGEST ``` Supply the operator admin token on stdin when adding the context. References are `name@version`, `name@tag` or immutable digests. Run returns a signal run, broadcast run or beacon instance ID; it does not wait for completion. Registry APIs live under `/api/v1/registry`: blob upload/read, image publish/list, resolve, tag, pull, install and run. The run body is `{reference, export, input, stationId?}`. ## Publish once at Headquarters Registry storage is adapter-based: `ImageRegistry` owns validation and immutable publication, with separate metadata and blob adapters. Configure `registry.storage` as `{ id, metadata, blobs }` instead of `rootDir`. File and memory adapters are built in; custom PostgreSQL/S3 providers implement `RegistryMetadataAdapter` and `RegistryBlobAdapter`. Those cloud drivers are not bundled. Custom providers must enforce atomic writes, bounded reads and shared blob quotas. Custom storage uses a verified local execution cache, optionally selected with `registry.cacheDir`. Adapter credentials stay in the daemon. Complete cached digest-pinned activations can recover offline, while moving tags still resolve at the authoritative registry. The storage ID identifies a namespace and must change when replacing it; it is not tenant authorization. Workers with a configured authenticated Headquarters upstream periodically import and verify compatible images and dependencies, install immutable definitions and advertise them. Shared durable queue and membership adapters still coordinate execution. Unsupported targets are skipped; corruption and permission failures stop the synchronization pass explicitly. ``` station images publish ./station-image.json --artifacts-dir ./build --context headquarters station images run acme/echo@1.0.0 echo --input @request.json --context headquarters # Optional immutable worker pin: station images run acme/echo@1.0.0 echo --input @request.json --context headquarters --station coding-worker ``` Signal and broadcast pins persist through retries; broadcast planners and children stay on the selected worker. Headquarters can create shared beacon instance intent with a worker pin; eligible workers execute it. A worker-local context can also create the instance directly. Without a pin, eligible workers can claim work normally. A context selects an endpoint and does not itself pin execution. ## Staged deployments and environment bindings A deployment retains immutable generations: image digest, aliases, optional worker pin and environment bindings. Staging checks compatibility without activation. Activation and rollback change the pointer used by future alias invocations; existing runs and beacons keep their identities. Drain blocks new alias invocations without deleting retained work. ``` station deployments stage --json @generation.json station deployments inspect DEPLOYMENT_ID station deployments activate DEPLOYMENT_ID --json '{"generation":"GENERATION_ID","expectedRevision":1}' station deployments run DEPLOYMENT_ID --json '{"alias":"echo","input":{"message":"hello"}}' station deployments rollback DEPLOYMENT_ID --json '{"generation":"EARLIER_GENERATION_ID","expectedRevision":4}' station deployments drain DEPLOYMENT_ID --json '{"expectedRevision":5}' ``` The stage body contains `{name,reference,aliases?,stationId?,bindings?,invocationEnv?}`. A binding is a persisted non-secret `{value:"production"}` or a store reference `{fromEnv:"API_TOKEN"}`. Both destination and source keys require operator grants. References resolve current scoped store values per attempt; they do not snapshot secret revisions. A staged `invocationEnv` allowlist can permit selected keys in an invocation’s `environment` bindings. Each override creates a retained derived generation and an `invoke` history entry without moving the active pointer. Use references for credentials; resolved secret values are not persisted. Refresh and review after a revision conflict. The dashboard nests Registry → image → version → export, and Deployments → generation. Publish, install, tags, invocation, staging, activation, rollback, drain and history each have focused views. Its binding editor accepts environment references or explicitly confirmed non-secret literals. A Registry Station selector keeps the chosen private target across links, breadcrumbs, publication and deployment pages; private image invocation is routed to and pinned on that worker. ## Cold workers and private registries Configure worker `registry.upstream.mode` as `on-demand` to advertise compatible finite exports without first downloading binaries. Separate network preparation reservations verify/import the image before an execution claim. `/api/v1/registry/preparations` reports a bounded local window of preparing, ready and failed observations. Images containing beacons remain eager because their reconciler has no cold-definition hook. Headquarters can configure `registry.targets` with fixed private worker URLs and credentials. Its `/api/v1/stations/:stationId/registry` proxy validates the worker identity and protocol. For image publish/list/inspect/install/tag/pull commands, `--station` selects that private registry. For `images run`, it means execution placement in the selected registry. Request bodies never choose a target URL. ## Resumable publication and tenant grants The CLI publisher verifies local bytes, reserves upload sessions, sends offset- and digest-checked chunks, commits each immutable blob, then publishes the manifest. Rerunning the command reconciles its private receipt with the server offset after an uncertain response. The dashboard uses the same resumable protocol with Pause/Resume, accepted-byte progress and tab-scoped receipts. After reload, reselect the same files; Resume first reads the server offset. It never silently retries a mutation. Upload quotas and expiry are independent of final blob storage quotas. Tenant APIs live under `/api/v1/tenant/registry`. Operator mappings bind registry-only key record IDs to separate storage namespaces and read/publish/activate/invoke grants. Revocation, mixed-scope refusal and cross-namespace digest/upload denial are enforced. The concrete createTenantRegistryWorkerGateway helper verifies a fixed dedicated worker identity and imports the pinned artifact closure before execution. A real Docker Headquarters/two-tenant-worker test covers signal execution and cross-tenant denial. The operator still provisions independent worker storage, queues, secrets and host policy; this is not a general tenant scheduler. Review the [numbered acceptance matrix](https://github.com/porkytheblack/station/blob/main/docs/STATION-IMAGES-ACCEPTANCE.md) for exact tests and remaining requirements. That map distinguishes verified native Docker beacons, actual daemon revocation, mixed native/image planners, SQLite SIGKILL recovery and static invocation artifact scopes from unverified production-host/failover claims and absent automatic cross-worker media transfer. ## CLI and terminal workflows The TUI nests workspace files, terminals, services and command receipts, and browser pages, profiles, recordings, diagnostics, recovery checkpoints and artifact receipts. Registry exports and deployment generations/history/rollouts have details. Owner routing, explicit mutation confirmation and bounded replay reconnect use the shared client. Receipts cover this TUI’s observed operations; visual playback remains in the dashboard and binary transfer uses CLI helpers. The [command coverage map](https://github.com/porkytheblack/station/blob/main/docs/STATION-CLI-COVERAGE.md) lists every current execution method and its actual tests; backend support still varies. For scripts, sandbox `exec --wait` returns the final JSON result and remote exit status; default exec remains asynchronous. `--wait-timeout-ms` bounds local polling, while Ctrl-C and local timeout stop waiting without cancelling remote work. `--json-errors` emits sanitized structured failures on stderr. Remote execution timeout and explicit cancellation remain separate operations. ## Worker enrollment and revocation Headquarters enables `network.enrollment: {authority:true}` with authentication. Invite a fixed worker, transfer the private invitation, then join independently of CLI contexts. The generated file supplies `role` and `network` fields including the credential; shared adapters and worker authentication are still operator configured. ``` station network invite worker-a --out worker-a.invitation.json station network join --file worker-a.invitation.json --out worker-a.enrollment.json station network members station network revoke worker-a station network leave --file worker-a.enrollment.json ``` Secrets stay in mode-0600 files or bounded stdin and are not printed. Invitations are single use; uncertain redemption needs a new invitation. Fresh daemon admission gates claims and renewal, fences active children after revocation and prevents heartbeat self-readmission. Enrollment does not distribute or revoke shared database credentials. ## Explicit beacon rollout ``` station deployments rollout DEPLOYMENT_ID --json '{"operationId":"rotate-a","expectedRevision":7,"sourceInstance":"OLD_INSTANCE","generation":"ACTIVATED_GENERATION","alias":"watch"}' ``` The daemon durably stops the source before creating a deterministic replacement, retains its old incarnation, and completes after replacement readiness. Retry the same operation ID and inspect retained rollout state after uncertain responses. This can have downtime. Alias activation alone does not replace live instances; new process environments resolve current granted references, not immutable secret revisions. ## Planner grants and invocation artifacts For ordinary Station signals, the image declares `nativeSignals` aliases with name and revision digest. Matching `registry.execution.nativeSignals` grants name an operator-owned, self-contained bundle; only `station-signal` may remain external. Uploaded code cannot choose a host module path. Saved plans use immutable names and cached bytes are reverified at bootstrap. Large runtime files use a separate `FileInvocationArtifactStore`. Exports declare artifact read/write capabilities; `registry.execution.artifacts` supplies the private root, per-scope/global quotas and static grants keyed by exact image digest and export. Reading requires explicitly allowed references; input strings never authorize themselves. Correlated `artifact:request`/`artifact:response` frames support bounded create/append/commit/read chunks and opaque `station-artifact:` references. Deadline/expiry cleanup removes incomplete payloads. Operator SDK scopes can import and read files, but no media upload/download HTTP endpoint, automatic cross-worker transfer or distributed artifact driver is included. Workers need explicitly configured shared filesystem access and grants. See the complete guide for policy and protocol fields. Executable image blobs and retained deployment generations are a different store and have no automatic reference-aware garbage collection. ## Docker operations and current limits Docker execution uses a non-root UID, read-only root, no network, dropped capabilities, no-new-privileges, memory/CPU/PID limits and seccomp. If the engine defaults to unconfined, configure an operator-reviewed deny-default `seccompProfile`; do not disable the check. The workload receives no Docker socket. Containers share the host kernel and are not VMs. Run an independent expiry reaper with the same backend options and private staging directory, under a host service manager. It validates invocation journals and container ownership before removing expired containers, including those whose controller died. ``` station-image-reaper --config /etc/station/image-reaper.json --interval-ms 5000 ``` Five real Docker tests passed on a Linux arm64 Docker Desktop engine for native/JavaScript invocation, isolation settings, timeout cleanup and independent expiry reaping. Validate the intended production host separately. Operator registry access requires admin authorization. Tenant registry namespaces, grants and a concrete dedicated execution gateway have real Docker fixture coverage. Durable worker enrollment/revocation, reconnectable TUI event streams, static artifact-reference scopes, mixed planner grants, audited environment overrides and explicit live-beacon rollout are implemented and have focused acceptance evidence. The user explicitly deferred unavailable production Linux staging; its deployment checks remain unverified; this is not a finished public multi-tenant hosting platform. --- # Remote triggers > Canonical HTML: [https://station.dterminal.net/docs/remote-triggers](https://station.dterminal.net/docs/remote-triggers) Remote triggers let you dispatch signals and broadcasts from any application to a Station server over HTTP. Your application code calls `.trigger()` as usual, but execution happens on the Station server instead of locally. * * * ## How it works When you call `configure()` with a remote endpoint, Station creates an `HttpTriggerAdapter` internally. Every subsequent `.trigger()` call sends an HTTP POST to the Station server's v1 API instead of writing to a local adapter. 1. Client validates input against the signal's Zod schema locally 2. Client sends `POST /api/v1/trigger` with the signal name and input 3. Server authenticates the request via API key, creates a pending run, and returns the run ID 4. The signal runner on the server picks up the pending run and executes it * * * ## 1\. Set up the Station server Your Station server needs an adapter for persistence, auth for API key management, and `host: "0.0.0.0"` to accept remote connections. ``` // station.config.ts import { defineConfig } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; export default defineConfig({ port: 4400, host: "0.0.0.0", signalsDir: "./signals", adapter: new SqliteAdapter({ dbPath: "./.station/data/jobs.db" }), auth: { username: "admin", password: "changeme", }, }); ``` Start the server with `pnpm exec stationd`. The API will be available at `http://localhost:4400`. Start the independent dashboard using the [dashboard guide](https://station.dterminal.net/docs/dashboard.md). * * * ## 2\. Create an API key API keys authenticate remote trigger requests. Create one from the dashboard Settings page, or via the v1 API: ``` curl -X POST http://localhost:4400/api/v1/keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "my-app", "scopes": ["trigger", "read"]}' ``` The response includes the full key (prefixed `sk_live_`). Store it securely — the key is only shown once. API keys support scoped access: `trigger` (dispatch jobs), `read` (view runs and status), `cancel` (cancel running jobs), and `admin` (manage keys). * * * ## 3\. Configure the client In your application, call `configure()` once at startup. All subsequent `.trigger()` calls will go to the remote server. ### Option A: Explicit configuration ``` import { configure } from "station-signal"; configure({ endpoint: "https://station.example.com", apiKey: "sk_live_abc123...", }); ``` ### Option B: Environment variables Station auto-detects these environment variables. No code changes needed. ``` STATION_ENDPOINT=https://station.example.com STATION_API_KEY=sk_live_abc123... ``` * * * ## 4\. Trigger signals remotely Once configured, `.trigger()` works the same as local triggering. The call returns a run ID immediately. ``` import { sendEmail } from "./signals/send-email.js"; // This sends an HTTP POST to your Station server const runId = await sendEmail.trigger({ to: "user@example.com", subject: "Welcome", body: "Thanks for signing up.", }); console.log("Dispatched run:", runId); ``` Broadcasts work the same way: ``` import { orderPipeline } from "./broadcasts/order-pipeline.js"; const runId = await orderPipeline.trigger({ orderId: "ord_123", amount: 99.99, }); ``` * * * ## Deployment Station includes a `deploy` command that generates deployment files for your server: ``` pnpm exec stationd deploy ``` This writes a `Dockerfile` and `nixpacks.toml` to `.station/out/`. Copy the appropriate file to your project root and deploy to any container platform (Railway, Render, Fly.io, AWS ECS, etc.). * * * ## CLI reference | Flag | Description | | --- | --- | | `--port ` | Override server port (default: 4400) | | `--host ` | Override server host (default: localhost) | | `--dir ` | Set station directory for generated files (default: .station) | | `--config ` | Path to config file (default: station.config.ts) | | `--no-runners` | Read-only mode — don't execute signals or broadcasts | --- # Dynamic broadcasts > Canonical HTML: [https://station.dterminal.net/docs/dynamic-broadcasts](https://station.dterminal.net/docs/dynamic-broadcasts) Dynamic broadcasts are broadcast definitions that live in your storage backend instead of in source files. They're built, edited, versioned and deleted over the API (or from the dashboard's graph builder), and they can reference any signal that's already registered with the runner. The DAG shape, per-node `input` mappings, and `when` guards are all expressed as a JSON-serialisable spec. File-defined broadcasts (the ones you build with [`broadcast(...)`](https://station.dterminal.net/docs/broadcasts.md)) keep working unchanged. Dynamic broadcasts live in a separate registry and are purely additive — a name in one registry never collides with a name in the other. * * * ## When to use which Reach for a file-defined broadcast when the DAG is part of your application's contract: it ships with the codebase, it's reviewed in PRs, and changes are deployed. Reach for a dynamic broadcast when the DAG is content rather than code — when you want operators to wire flows together without redeploying, or when the shape of a workflow is decided at runtime. The two coexist deliberately: signals are still the unit of arbitrary TypeScript. Dynamic broadcasts only let you compose them into different DAGs at runtime. If you need new code, write a signal; if you need a new wiring of existing signals, edit a dynamic broadcast. * * * ## The spec A dynamic broadcast is a `DynamicBroadcastSpec`. It's plain JSON — the runner round-trips it through the adapter without modification. ``` interface DynamicBroadcastSpec { name: string; version: number; failurePolicy: "fail-fast" | "skip-downstream" | "continue"; timeout?: number; nodes: DynamicNodeSpec[]; createdAt: Date; updatedAt: Date; createdBy?: string; deletedAt?: Date; } interface DynamicNodeSpec { name: string; signalName: string; dependsOn: string[]; /** ExprNode JSON; absent = pass-through (single-dep) or upstream object (multi-dep). */ input?: ExprNode; /** ExprNode JSON returning boolean; absent = always run. */ when?: ExprNode; } ``` Per-node `input` and `when` are [expressions](https://station.dterminal.net/docs/expressions.md) — a small, deterministic AST that can reference the broadcast's trigger input and any upstream node's output. They're stored as JSON, which is how the spec stays serialisable. ### A concrete example ``` { "name": "order-fulfillment", "version": 3, "failurePolicy": "skip-downstream", "nodes": [ { "name": "validate", "signalName": "validateOrder", "dependsOn": [] }, { "name": "charge", "signalName": "chargeCard", "dependsOn": ["validate"], "input": { "kind": "obj", "entries": { "amount": { "kind": "ref", "path": ["validate", "total"] }, "currency": { "kind": "lit", "value": "USD" } } } }, { "name": "ship", "signalName": "shipOrder", "dependsOn": ["charge"], "when": { "kind": "op", "op": "==", "args": [ { "kind": "ref", "path": ["charge", "status"] }, { "kind": "lit", "value": "paid" } ] } } ], "createdAt": "2026-04-12T08:11:02.000Z", "updatedAt": "2026-04-30T14:02:11.000Z" } ``` * * * ## HTTP API Dynamic broadcasts live under `/api/v1/broadcast-definitions`. All endpoints accept and return JSON; auth is the same API key flow used for [remote triggers](https://station.dterminal.net/docs/remote-triggers.md). ### Save a definition ``` curl -X POST http://localhost:4400/api/v1/broadcast-definitions \ -H "Authorization: Bearer $STATION_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "order-fulfillment", "failurePolicy": "skip-downstream", "nodes": [ { "name": "validate", "signalName": "validateOrder", "dependsOn": [] }, { "name": "charge", "signalName": "chargeCard", "dependsOn": ["validate"] } ] }' ``` The server validates the spec (signal names exist, no cycles, expressions type-check against signal schemas), bumps `version` by one, and writes the new version. Re-saving the same name is how you edit it; there's no separate `PATCH` endpoint. The response is the saved spec, including the assigned version number. ### Validate without saving The dashboard's graph builder uses this endpoint to surface errors as you edit: ``` POST /api/v1/broadcast-definitions/validate { "name": "...", "failurePolicy": "...", "nodes": [...] } // 200 OK { "ok": false, "errors": [ { "node": "charge", "field": "input", "message": "$.amount: expected number, got string" } ] } ``` ### Read | Endpoint | Returns | | --- | --- | | `GET /api/v1/broadcast-definitions` | Latest non-deleted version of each definition. | | `GET /api/v1/broadcast-definitions/:name` | Latest version of a single definition. | | `GET /api/v1/broadcast-definitions/:name/versions` | List of all versions, oldest first. Includes soft-deleted entries. | | `GET /api/v1/broadcast-definitions/:name/versions/:n` | A specific historical version. The dashboard deep-links to these. | ### Delete ``` curl -X DELETE http://localhost:4400/api/v1/broadcast-definitions/order-fulfillment \ -H "Authorization: Bearer $STATION_KEY" ``` Deletes are soft. The most recent version is marked with `deletedAt`; previous versions stay queryable so existing broadcast runs can still be inspected. If you create another definition with the same name later, version numbering continues from where it left off — a recreated `order-fulfillment` after three deleted versions becomes `v4`, not `v1`. This keeps run-history references stable. ### Trigger Static broadcasts use `/api/v1/broadcasts/:name/trigger`. Dynamic broadcasts have a dedicated endpoint that distinguishes the registry: ``` curl -X POST http://localhost:4400/api/v1/trigger-dynamic-broadcast \ -H "Authorization: Bearer $STATION_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "order-fulfillment", "input": { "orderId": "ORD-123" } }' ``` * * * ## Snapshot on trigger When a dynamic broadcast is triggered, the runner serialises the current spec into the broadcast run's `definitionSnapshot` field. The advance loop reads from the snapshot, never the live registry. This means: - Editing a definition while a run is in flight does not change the DAG that run is executing. - Re-triggering after a save uses the new spec; older runs continue on their snapshot. - The dashboard's run-detail view reconstructs the DAG from the snapshot, so historical runs render correctly even after the definition has been edited or deleted. ``` interface BroadcastRun { // ... /** JSON-serialised DynamicBroadcastSpec captured at trigger time. */ definitionSnapshot?: string; } ``` * * * ## Reconciliation The broadcast runner keeps an in-memory map of materialised dynamic broadcasts (parsed spec + compiled DAG). On a configurable cadence (default: every 5 ticks of the broadcast poll loop) it asks the adapter for the current set of definitions and reconciles: - New names are materialised and added to the registry. - Bumped `version` s replace the in-memory entry. - Soft-deleted names are removed from the registry. Materialisation is what catches mid-flight schema drift — if a node references a signal that's no longer registered, the entry is dropped from the registry with a warning rather than poisoning future triggers. * * * ## Adapter requirements Storage for dynamic definitions lives on the same `BroadcastQueueAdapter` you already use for broadcast runs, via four optional methods: ``` interface BroadcastQueueAdapter { // ...existing run/node methods omitted... saveDefinition?(spec: DynamicBroadcastSpec): Promise; getDefinition?(name: string, version?: number): Promise; listDefinitions?(): Promise; listDefinitionVersions?(name: string): Promise; deleteDefinition?(name: string): Promise; } ``` All four built-in adapters (`sqlite`, `postgres`, `mysql`, `redis`) implement these methods. If you're using `BroadcastMemoryAdapter`, dynamic broadcasts work in-process but disappear on restart. If you ship a custom adapter and don't implement these methods, the dynamic-broadcast endpoints return `501 Not Implemented` and the rest of the broadcast surface keeps working. * * * ## Dashboard builder The Station dashboard exposes a graphical builder for dynamic broadcasts under `/broadcasts/dyn`. It uses exactly the API documented above: each save round-trips through `POST /broadcast-definitions`, edits live-validate against `/validate`, and the version drawer is backed by `/versions/:n`. Anything you can do in the dashboard is scriptable from `curl`; anything you save with `curl` shows up in the dashboard immediately. Triggers issued from the dashboard land at the same `/api/v1/trigger-dynamic-broadcast` endpoint, so the snapshot-on-trigger semantics apply uniformly whether the trigger came from a human or an integration. --- # Schedules > Canonical HTML: [https://station.dterminal.net/docs/schedules](https://station.dterminal.net/docs/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](https://station.dterminal.net/docs/signals.md) and [broadcast](https://station.dterminal.net/docs/broadcasts.md) 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](https://station.dterminal.net/docs/dynamic-broadcasts.md) 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 `timezone` instead 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`, or `catch-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/schedules` - `station-adapter-postgres/schedules` - `station-adapter-mysql/schedules` - `station-adapter-redis/schedules` For tests, `ScheduleMemoryAdapter` ships in `station-schedules` itself. ### The interface ``` interface ScheduleAdapter { add(schedule: Schedule): Promise; get(id: string): Promise; list(filter?: { kind?: ScheduleKind; enabled?: boolean; due?: boolean }): Promise; update(id: string, patch: SchedulePatch): Promise; delete(id: string): Promise; /** * Atomically advance nextRunAt only if the stored value still matches * expectedNextRunAt. Required for multi-instance correctness. */ claimDue?(id: string, expectedNextRunAt: Date, newNextRunAt: Date): Promise; generateId(): string; ping(): Promise; close?(): Promise; } ``` `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 id` in a single statement, atomic across connections. - **MySQL** — `UPDATE ... WHERE ...` with `affectedRows > 0` deciding the winner. - **Redis** — Lua `EVAL` script that compares `ZSCORE` against 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: 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. --- # Environment Variables > Canonical HTML: [https://station.dterminal.net/docs/environment](https://station.dterminal.net/docs/environment) Environment variables let you feed configuration and secrets into your [signals](https://station.dterminal.net/docs/signals.md) and [beacons](https://station.dterminal.net/docs/beacons.md) without exporting everything into the Station process. Define a variable once — globally or scoped to specific targets — require it for a run, and change it from the dashboard while Station is running. It's the same mental model as Vercel's environments. Variables live in a pluggable store and are injected into each run's `process.env` over the private IPC channel — never the spawn environment — so secret values are not exposed via `/proc//environ` to other processes on the host. * * * ## Requiring a variable Declare what a signal or beacon needs with `.env()`. Before a run is dispatched, the runner checks each key against the env store *and* the host `process.env`. If a key is missing, a signal run **fails fast** with a clear error and a beacon is marked `errored` instead of being spawned — no wasted child process, no half-configured run. ``` import { signal, z } from "station-signal"; export const charge = signal("charge") .input(z.object({ amount: z.number() })) .env("STRIPE_API_KEY") // required — the run fails fast if unset .run(async (input) => { const stripe = new Stripe(process.env.STRIPE_API_KEY!); await stripe.charges.create({ amount: input.amount }); }); ``` Beacons take the same method. A missing required variable keeps the beacon down (`errored`) rather than crash-looping; defining the variable and restarting it clears the error. ``` import { beacon } from "station-beacon"; export const priceFeed = beacon("price-feed") .env("EXCHANGE_API_KEY") .restart("always") .run(async (ctx) => { /* ... */ }); ``` A run failed for a missing variable is terminal — it is not retried, because retrying without the value can't succeed. Define the variable, then trigger again. If the env store is briefly *unreachable* (a database blip), the run is left pending and retried instead of being failed with a misleading "not defined" error. * * * ## Global vs. scoped A variable with no targets is **global** — injected into every signal and beacon run. A variable scoped to specific targets is injected only into those, and **overrides** a global variable of the same key. This is how you keep one default and specialise it for a single job: | Definition | Applies to | | --- | --- | | `DB_URL` (no targets) | Every signal and beacon. | | `DB_URL` scoped to signal `reports` | Only `reports` — and it wins over the global `DB_URL` there. | Two variables may share a key only if their scopes can never both apply to one target, so resolution is always deterministic. The store rejects a definition that would make a key ambiguous. * * * ## Secrets Mark a variable `secret` and its value becomes write-only: the API and dashboard return `value: null`, and the real value is still injected at run time. A secret can't be downgraded to non-secret — once hidden, it stays hidden. Rotating a secret is a normal value edit; the stored scope and secret flag are preserved. A handful of keys are reserved and rejected: `PATH`, `NODE_OPTIONS`, `NODE_PATH`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, the `DYLD_*` loader variables, and any `STATION_*` / `__STATION` internal. They change how the child process executes rather than what your handler reads, so managing them through the store is disallowed — and the runner re-checks them at the child boundary as defense-in-depth. * * * ## Storage Variables live behind an `EnvStorageAdapter`. The default is a JSON file at `/station-env.json` (fsync'd, `0o600`, no native dependencies) — fine for a single-process deployment. For multi-process or multi-replica setups, pass a durable adapter via `envStorage`. Each ships in the `/env` sub-path of the corresponding adapter package: - `station-adapter-sqlite/env` - `station-adapter-postgres/env` - `station-adapter-mysql/env` - `station-adapter-redis/env` ``` // station.config.ts import { defineConfig } from "station-daemon"; import { EnvPostgresAdapter } from "station-adapter-postgres/env"; export default defineConfig({ signalsDir: "./signals", envStorage: new EnvPostgresAdapter({ connectionString: process.env.DATABASE_URL }), }); ``` For tests, `MemoryEnvStorage` ships in `station-env` itself, alongside the `EnvStore` that wraps any adapter with validation, secret masking, and resolution. * * * ## HTTP API Variables live under `/api/v1/env`. Reads require the `read` scope and redact secret values; mutations require `admin`. ### Create ``` # Global secret — injected into every signal and beacon. curl -X POST http://localhost:4400/api/v1/env \ -H "Authorization: Bearer $STATION_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "STRIPE_API_KEY", "value": "sk_live_...", "secret": true }' # Scoped to a single signal — overrides a global of the same key there. curl -X POST http://localhost:4400/api/v1/env \ -H "Authorization: Bearer $STATION_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "DB_URL", "value": "postgres://...", "targets": [{ "kind": "signal", "name": "charge" }] }' ``` ### List, edit, delete | Endpoint | Description | | --- | --- | | `GET /api/v1/env` | List variables. Secret values come back as `null`. | | `GET /api/v1/env/:id` | Single variable by ID (secret redacted). | | `POST /api/v1/env` | Create. Body: `key`, `value`, `secret?`, `targets?`. Rejects invalid or reserved keys and conflicting scopes with a `400`. | | `PATCH /api/v1/env/:id` | Partial update: `value`, `secret`, `targets`. The `key` is immutable. Omitted fields are left unchanged. | | `DELETE /api/v1/env/:id` | Remove a variable. Runs already dispatched are unaffected. | * * * ## Dashboard The **Environment** page lists every variable, lets you add one (choosing *Global* or specific targets and toggling *Secret*), edit a value in place, and delete. It also flags any variable a signal or beacon requires via `.env()` that isn't defined in the store — so you can see a missing configuration before a run fails. A value set in the Station host environment also satisfies a requirement; the dashboard can only see the store, so it says so. Changes take effect on the next run — there is no need to restart the Station process. --- # Tauri Desktop > Canonical HTML: [https://station.dterminal.net/docs/tauri-desktop](https://station.dterminal.net/docs/tauri-desktop) The `station-tauri` package lets you run Station as a Tauri v2 desktop app sidecar. Your Tauri app spawns Station as a background process, communicates over localhost HTTP, and shuts it down when the window closes. No server deployment needed — everything runs on the user’s machine. * * * ## Install ``` npm install station-tauri ``` * * * ## Programmatic API Use `createTauriStation` to start Station from Node.js code (e.g. an Electron main process, a test harness, or a custom launcher). ``` import { createTauriStation } from "station-tauri"; const station = await createTauriStation({ dataDir: "/path/to/app/data", // absolute path — Tauri app data directory port: 4400, // optional, default 4400 signalsDir: "./signals", // path to signal definitions broadcastsDir: "./broadcasts", // optional station: { /* overrides */ }, // optional partial StationUserConfig }); await station.start(); console.log(station.port); // 4400 console.log(station.apiKey); // "sk_live_..." await station.stop(); ``` ### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `dataDir` | `string` | — | Absolute path to the application data directory. The SQLite database and API key file are stored here. In Tauri, use the `appDataDir` resolver. | | `port` | `number` | `4400` | Port for the Station API server. Binds to 127.0.0.1 only. | | `signalsDir` | `string` | — | Path to signal definition files. | | `broadcastsDir` | `string` | — | Path to broadcast definition files. | | `station` | `Partial` | — | Optional overrides for the underlying Station configuration (adapters, auth, runner options, etc.). | ### Return value `createTauriStation` returns a station handle with these properties and methods: | Property / Method | Type | Description | | --- | --- | --- | | `port` | `number` | The port Station is listening on. | | `apiKey` | `string` | Auto-provisioned API key (`sk_live_...`) with all scopes. | | `start()` | `Promise` | Start the Station server and runners. | | `stop()` | `Promise` | Gracefully shut down the server and runners. | * * * ## Sidecar binary The package ships a `station-sidecar` binary designed for use as a Tauri v2 sidecar. Register it in `tauri.conf.json` and spawn it on app start. The binary communicates readiness and errors via structured JSON on stdout. ### Environment variables | Variable | Required | Default | Description | | --- | --- | --- | --- | | `STATION_DATA_DIR` | Yes | — | Absolute path to the data directory. | | `STATION_PORT` | No | `4400` | API server port. | | `STATION_SIGNALS_DIR` | No | `./signals` | Path to signal definitions. | | `STATION_BROADCASTS_DIR` | No | — | Path to broadcast definitions. | ### Stdout protocol On successful startup, the binary writes a single JSON line to stdout: ``` {"event":"ready","port":4400,"apiKey":"sk_live_..."} ``` On failure: ``` {"event":"error","message":"STATION_DATA_DIR is required"} ``` The binary handles `SIGTERM` and `SIGINT` for graceful shutdown — runners and adapters are stopped cleanly before the process exits. * * * ## Authentication The desktop integration auto-provisions a single API key with all scopes (`trigger`, `read`, `cancel`, `admin`) on first launch. The key is persisted to `{dataDir}/.station-key` and reused on subsequent launches. No login UI is needed. The Tauri frontend uses the API key directly in HTTP headers: ``` fetch("http://127.0.0.1:4400/api/v1/signals", { headers: { Authorization: "Bearer sk_live_...", }, }); ``` The server binds to `127.0.0.1` only — it is not accessible from other machines on the network. * * * ## Tauri v2 integration The typical integration pattern involves three pieces: registering the sidecar in Tauri config, spawning it from Rust on app start, and calling the API from the frontend. ### 1\. Register the sidecar Add the `station-sidecar` binary to your `tauri.conf.json` external binaries list. Tauri resolves sidecar paths relative to your app bundle. ### 2\. Spawn on app start In your Rust setup, spawn the sidecar process, set the required environment variables, and read stdout for the ready event: ``` // Pseudocode — Rust side // 1. Spawn the station-sidecar with STATION_DATA_DIR set to appDataDir // 2. Read stdout line-by-line until {"event":"ready",...} // 3. Extract port and apiKey from the JSON // 4. Pass port + apiKey to the frontend via Tauri state or IPC ``` ### 3\. Call the API from the frontend The Tauri frontend talks to Station over localhost using the port and API key extracted from the ready event: ``` // Frontend (TypeScript) const response = await fetch(`http://127.0.0.1:${port}/api/v1/signals`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const signals = await response.json(); ``` ### 4\. Shut down on app close When the Tauri window closes, kill the sidecar process. The binary intercepts `SIGTERM` and shuts down gracefully — flushing the database WAL, stopping runners, and closing adapter connections before exiting. --- # Signals > Canonical HTML: [https://station.dterminal.net/docs/signals](https://station.dterminal.net/docs/signals) A signal is a named, type-safe background job. You define its input schema, handler function, and execution constraints. The runner picks it up, spawns an isolated child process, and manages retries, timeouts, and concurrency on your behalf. A signal, from request to result 1. Trigger**Validated input**An API call, a schedule or your application submits a job. 2. Execute**One attempt**An eligible worker claims the run and starts its handler. 3. Record**Output or failure**Inspect the result, or retry within the configured budget. Retries can repeat external effects. Make handlers idempotent when sending messages, charging cards or changing remote state. This reference describes the Node runtime. The shared builder also works with the [experimental browser runtime](https://station.dterminal.net/docs/browser.md). Read that guide for supported methods, explicit registration, IndexedDB recovery, and cooperative worker execution; Node runner guarantees and APIs do not apply in the browser. ## `signal(name)` Creates a named signal definition. The name must be unique across your application, start with a letter, and contain only letters, digits, hyphens, and underscores. Returns a builder with chainable methods. ``` import { signal, z } from "station-signal"; const sendEmail = signal("sendEmail") .input(z.object({ to: z.string().email(), subject: z.string(), body: z.string(), })) .output(z.object({ messageId: z.string(), sentAt: z.string().datetime(), })) .timeout(10_000) // 10 seconds .retries(3) // up to 4 total attempts .concurrency(5) // max 5 concurrent sends .onComplete(async (output, input) => { console.log(`Email ${output.messageId} sent to ${input.to}`); }) .run(async (input) => { const result = await emailService.send(input); return { messageId: result.id, sentAt: new Date().toISOString(), }; }); ``` * * * ## Builder methods ### `.input(schema)` Sets the Zod schema for input validation. Every call to `.trigger()` validates the provided data against this schema before enqueuing. If validation fails, the run is rejected immediately and never enters the queue. The schema also drives TypeScript type inference: your handler receives the exact inferred type, and the compiler enforces it at build time. ``` const processOrder = signal("processOrder") .input(z.object({ orderId: z.string().uuid(), items: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive(), })), })) .run(async (input) => { // input is typed as { orderId: string; items: { sku: string; quantity: number }[] } for (const item of input.items) { await reserveInventory(item.sku, item.quantity); } }); ``` If no input schema is provided, the signal accepts an empty object `{}` by default. ### `.output(schema)` Optional output schema. When provided, the handler's return value is validated against it. This is particularly useful when chaining signals inside broadcasts: the downstream signal's input type must match the upstream signal's output type. The validated output is JSON-serialized and stored on the run record, making it available to subscribers and the broadcast orchestrator. ``` const geocode = signal("geocode") .input(z.object({ address: z.string() })) .output(z.object({ lat: z.number(), lng: z.number(), })) .run(async (input) => { const coords = await geocodingApi.lookup(input.address); return { lat: coords.latitude, lng: coords.longitude }; }); ``` ### `.timeout(ms)` Maximum execution time in milliseconds. If the handler exceeds this duration, the child process is killed with `SIGTERM` and the run is marked `"failed"` with a timeout error. Default: `300_000` (5 minutes). Set this lower for operations that should fail fast, such as HTTP requests or payment processing. ``` const healthCheck = signal("healthCheck") .input(z.object({ url: z.string().url() })) .timeout(5_000) // 5 seconds — fail fast if the service is down .run(async (input) => { const res = await fetch(input.url); if (!res.ok) throw new Error(`Health check failed: ${res.status}`); }); ``` ### `.retries(n)` Maximum retry attempts after the initial failure. Total execution attempts = `1 + n`. Default: `0` (no retry). When a run fails and has remaining attempts, it is re-enqueued with `"pending"` status and an incremented attempt counter. The runner applies exponential backoff between retries (base delay configurable via `retryBackoffMs` on the runner). The retry delay doubles with each attempt: `base * 2^(attempt - 1)`. ``` const syncInventory = signal("syncInventory") .input(z.object({ warehouseId: z.string() })) .retries(3) // up to 4 total attempts (1 initial + 3 retries) .run(async (input) => { await externalApi.sync(input.warehouseId); }); ``` ### `.concurrency(n)` Maximum concurrent executions of this specific signal. When the limit is reached, additional runs remain in the queue and are picked up on subsequent poll ticks as slots open. This is separate from the runner-level `maxConcurrent` setting, which caps total concurrent executions across all signal types. Use per-signal concurrency to rate-limit API calls or protect database-heavy operations. ``` const callStripeApi = signal("callStripeApi") .input(z.object({ customerId: z.string(), amount: z.number() })) .concurrency(3) // max 3 concurrent Stripe API calls .retries(2) .run(async (input) => { await stripe.charges.create({ customer: input.customerId, amount: input.amount, currency: "usd", }); }); ``` In a [Station Network](https://station.dterminal.net/docs/network.md), use `.concurrency({ station, network })` to set both the per-station limit and a fleet-wide limit enforced with shared leases. `.placement({ labels })` restricts claims to stations whose advertised labels match. ``` const render = signal("render") .concurrency({ station: 2, network: 10 }) .placement({ labels: { gpu: "true" } }) .run(renderJob); ``` ### `.every(interval)` Makes the signal recurring. Accepts human-readable duration strings: `"30s"`, `"5m"`, `"1h"`, `"1d"`. After each completion, the runner automatically enqueues the next run. If a pending or running instance already exists for this signal, the scheduler skips that tick and advances the schedule to prevent overlapping executions. ``` const cleanupExpiredSessions = signal("cleanupExpiredSessions") .every("15m") .run(async () => { const deleted = await db.sessions.deleteExpired(); console.log(`Cleaned up ${deleted} expired sessions`); }); ``` ### `.withInput(data)` Default input data for recurring signals. Without this, recurring signals run with `{}` as input. The data must conform to the input schema if one is defined. ``` const dailyReport = signal("dailyReport") .input(z.object({ reportType: z.enum(["summary", "detailed"]), recipients: z.array(z.string().email()), })) .every("1d") .withInput({ reportType: "summary", recipients: ["ops@example.com", "team@example.com"], }) .run(async (input) => { const report = await generateReport(input.reportType); await sendReport(report, input.recipients); }); ``` ### `.env(...keys)` Declares environment variables the signal requires. Before dispatching a run, the runner checks each key against the [env store](https://station.dterminal.net/docs/environment.md) and the host `process.env`; if any is missing the run **fails fast** with a clear error instead of spawning a child that can't succeed. Provided values are injected into the child's `process.env`, so the handler reads them the usual way. ``` const charge = signal("charge") .input(z.object({ amount: z.number() })) .env("STRIPE_API_KEY") // run fails fast if this is unset .run(async (input) => { const stripe = new Stripe(process.env.STRIPE_API_KEY!); await stripe.charges.create({ amount: input.amount }); }); ``` ### `.step(name, fn)` Adds a named execution step. Steps run sequentially within the same child process. The first step receives the signal's input. Each subsequent step receives the return value of the previous step. Step completion events are emitted to subscribers as each step finishes, giving you granular progress tracking. When using steps, finalize with `.build()` instead of `.run()`. ``` const processPayment = signal("processPayment") .input(z.object({ orderId: z.string(), amount: z.number(), currency: z.string(), })) .step("validate", async (input) => { const order = await db.orders.findById(input.orderId); if (!order) throw new Error(`Order ${input.orderId} not found`); return { order, amount: input.amount, currency: input.currency }; }) .step("charge", async (prev) => { const charge = await paymentGateway.charge({ amount: prev.amount, currency: prev.currency, }); return { orderId: prev.order.id, chargeId: charge.id }; }) .step("confirm", async (prev) => { await db.orders.update(prev.orderId, { chargeId: prev.chargeId, status: "paid" }); return { orderId: prev.orderId, chargeId: prev.chargeId }; }) .build(); ``` ### `.onComplete(fn)` Post-completion callback. Called with `(output, input)` after the handler finishes successfully. Runs in the same child process as the handler. If the callback throws, the run is still marked as completed but an `onCompleteError` event is emitted to subscribers. Use this for side effects like sending notifications, updating caches, or triggering downstream signals. ``` const generateInvoice = signal("generateInvoice") .input(z.object({ orderId: z.string() })) .output(z.object({ invoiceUrl: z.string().url() })) .onComplete(async (output, input) => { await notificationService.send({ channel: "email", template: "invoice-ready", data: { orderId: input.orderId, invoiceUrl: output.invoiceUrl }, }); }) .run(async (input) => { const pdf = await renderInvoice(input.orderId); const url = await storage.upload(pdf); return { invoiceUrl: url }; }); ``` `.onComplete()` can be called before or after `.run()`. When chaining after `.run()`, it returns the final `Signal` object. On a step-based builder, call it before `.build()`. ### `.run(handler)` Sets the handler function and finalizes the signal. The handler receives the validated input and should return the output (or void). The handler runs in an isolated child process spawned by the runner, so it has its own memory space and cannot crash the runner. ``` const resizeImage = signal("resizeImage") .input(z.object({ sourceUrl: z.string().url(), width: z.number().int().positive(), height: z.number().int().positive(), })) .run(async (input) => { const image = await downloadImage(input.sourceUrl); const resized = await sharp(image).resize(input.width, input.height).toBuffer(); await storage.upload(resized); }); ``` ### `.build()` Finalizes a step-based signal. Use this instead of `.run()` when the signal is defined with `.step()` calls. Returns the same `Signal` object. * * * ## Signal instance After calling `.run()` or `.build()`, you get a `Signal` object. This is the handle you use to trigger runs and the object you pass to the runner for registration. | Property | Type | Description | | --- | --- | --- | | `.name` | `string` | The signal name passed to `signal()`. | | `.inputSchema` | `z.ZodType` | The Zod input schema. Defaults to `z.object({})` if none was provided. | | `.outputSchema` | `z.ZodType | undefined` | The Zod output schema, if one was set via `.output()`. | | `.timeout` | `number` | Timeout in milliseconds. Default: `300_000`. | | `.maxAttempts` | `number` | Total attempts (1 + retries). Default: `1`. | | `.maxConcurrency` | `number | undefined` | Per-signal concurrency limit, if set. | | `.interval` | `string | undefined` | Recurring interval string (e.g. `"5m"`), if set. | | `.recurringInput` | `TInput | undefined` | Default input for recurring runs, if set via `.withInput()`. | | `.handler` | `Function | undefined` | The handler function, if defined via `.run()`. | | `.steps` | `StepDefinition[] | undefined` | Array of step definitions, if defined via `.step()`. | ### `.trigger(input)` Enqueues a run for execution. Validates the input against the schema first. Returns a `Promise` resolving to the unique run ID. Throws `SignalValidationError` if the input fails validation. The run is written to the adapter with `"pending"` status and picked up by the runner on the next poll tick. ``` const runId = await sendEmail.trigger({ to: "user@example.com", subject: "Order Confirmation", body: "Your order has been placed.", }); console.log(`Enqueued run: ${runId}`); ``` * * * ## SignalRunner The runner is the long-running process that polls the adapter for due entries, spawns isolated child processes to execute signal handlers, and manages the full signal lifecycle: enqueue, dispatch, execute, retry, timeout, and completion. It also handles recurring signal scheduling, per-signal concurrency enforcement, and graceful shutdown on `SIGINT`/`SIGTERM`. ``` import { SignalRunner, ConsoleSubscriber } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; const runner = new SignalRunner({ signalsDir: "./src/signals", adapter: new SqliteAdapter({ dbPath: "./data/signals.db" }), subscribers: [new ConsoleSubscriber()], pollIntervalMs: 1000, maxConcurrent: 10, retryBackoffMs: 2000, }); await runner.start(); ``` ### Constructor options | Option | Type | Default | Description | | --- | --- | --- | --- | | `signalsDir` | `string` | — | Directory path for auto-discovery. The runner recursively imports all `.ts` and `.js` files and registers any exported signal objects. Paths are resolved relative to the working directory. | | `adapter` | `SignalQueueAdapter` | `MemoryAdapter` | Storage backend for run persistence. The default `MemoryAdapter` is in-process only and loses data on restart. Use `SqliteAdapter` for production. The runner automatically calls `configure({ adapter })` so child processes can access the same adapter. | | `subscribers` | `SignalSubscriber[]` | `[]` | Array of subscriber objects notified on lifecycle events. Subscribers have all-optional methods; implement only the events you need. | | `pollIntervalMs` | `number` | `1000` | Milliseconds between poll ticks. Each tick checks for due runs, running timeouts, and recurring schedules. Lower values give faster pickup but higher CPU usage. | | `maxConcurrent` | `number` | `5` | Global maximum concurrent child processes across all signal types. When this limit is reached, no new runs are dispatched until a slot opens. This is independent of per-signal `.concurrency()` limits. | | `maxAttempts` | `number` | `1` | Default max attempts for signals that do not specify their own via `.retries()`. Per-signal settings override this. | | `retryBackoffMs` | `number` | `1000` | Base delay in milliseconds for exponential retry backoff. The actual delay is `retryBackoffMs * 2^(attempt - 1)`. First retry waits 1s, second 2s, third 4s, and so on. | ### Methods | Method | Returns | Description | | --- | --- | --- | | `start()` | `Promise` | Start the poll loop. Discovers signals from `signalsDir` (if set), installs `SIGINT`/`SIGTERM` shutdown handlers, and begins polling. This method blocks until `stop()` is called. | | `stop(opts?)` | `Promise` | Gracefully stop the runner. Accepts optional `{ graceful?: boolean, timeoutMs?: number }`. When graceful, waits for active child processes to finish (up to the timeout), then kills any remaining. Closes the adapter to release resources. | | `register(name, filePath, opts?)` | `this` | Manually register a signal by name and file path. Alternative to auto-discovery via `signalsDir`. Accepts optional `{ maxConcurrency }`. | | `listRegistered()` | `Array<{ name, filePath, maxConcurrency? }>` | Returns metadata for all registered signals. | | `hasSignal(name)` | `boolean` | Check whether a signal is registered by name. | | `getRun(id)` | `Promise` | Look up a run by its ID. | | `listRuns(signalName)` | `Promise` | List all runs for a specific signal. | | `getSteps(runId)` | `Promise` | Get all step records for a multi-step run. | | `waitForRun(runId, opts?)` | `Promise` | Poll until a run reaches a terminal status (completed, failed, or cancelled). Options: `{ pollMs?, timeoutMs?, waitForExistence? }`. Returns null if the run does not exist and `waitForExistence` is false. | | `cancel(runId)` | `Promise` | Cancel a specific run. Marks it as `"cancelled"` and kills the child process if running. Returns false if the run does not exist or is already in a terminal state. | | `purgeCompleted(olderThanMs)` | `Promise` | Delete completed, failed, and cancelled runs older than the specified age in milliseconds. Returns the count of purged runs. | | `getAdapter()` | `SignalQueueAdapter` | Access the underlying queue adapter. Used by the broadcast runner to coordinate with the signal layer. | | `subscribe(subscriber)` | `this` | Add a subscriber after construction. Chainable. | ### `SignalRunner.create(signalsDir, opts?)` Static factory method. Creates a runner with the given signals directory and a default `ConsoleSubscriber` if no subscribers are provided. ``` const runner = SignalRunner.create("./src/signals", { adapter: new SqliteAdapter({ dbPath: "./data/signals.db" }), maxConcurrent: 10, }); ``` * * * ## SignalQueueAdapter The adapter interface defines storage operations for runs and steps. Implement this to use a custom storage backend. Two adapters ship with the framework: `MemoryAdapter` (built into `station-signal`) and `SqliteAdapter` (from `station-adapter-sqlite`). | Method | Signature | Description | | --- | --- | --- | | `addRun(run)` | `(Run) => Promise` | Insert a new run record into the store. | | `removeRun(id)` | `(string) => Promise` | Delete a run and its associated steps by run ID. | | `getRun(id)` | `(string) => Promise` | Look up a single run by ID. Returns null if not found. | | `getRunsDue()` | `() => Promise` | Get all runs with `"pending"` status whose `nextRunAt` is in the past (or unset). Results are sorted by creation time, oldest first. | | `getRunsRunning()` | `() => Promise` | Get all runs with `"running"` status. Used by the runner for timeout detection. | | `updateRun(id, patch)` | `(string, RunPatch) => Promise` | Partially update a run. Identity fields (`id`, `signalName`, `kind`, `createdAt`) are immutable and excluded from the patch type. | | `listRuns(signalName)` | `(string) => Promise` | List all runs for a specific signal name. | | `hasRunWithStatus(name, statuses)` | `(string, RunStatus[]) => Promise` | Check if any run exists for the given signal in one of the specified statuses. Used for recurring overlap prevention. | | `purgeRuns(olderThan, statuses)` | `(Date, RunStatus[]) => Promise` | Delete runs in the given statuses that completed before the cutoff date. Returns the count deleted. | | `addStep(step)` | `(Step) => Promise` | Insert a step record. | | `updateStep(id, patch)` | `(string, StepPatch) => Promise` | Partially update a step record. | | `getSteps(runId)` | `(string) => Promise` | Get all steps for a given run ID. | | `removeSteps(runId)` | `(string) => Promise` | Delete all steps for a given run ID. | | `generateId()` | `() => string` | Generate a unique run ID. The built-in adapters use `crypto.randomUUID()`. | | `ping()` | `() => Promise` | Health check. Returns true if the adapter is operational. | | `close()` | `() => Promise` | Optional. Release resources (database connections, file handles). Called automatically by the runner on stop. | * * * ## Run The `Run` interface represents a single execution of a signal. It is the primary record stored by the adapter. | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique run identifier (UUID). | | `signalName` | `string` | Name of the signal this run belongs to. | | `kind` | `"trigger" | "recurring"` | Whether this run was created by an explicit `.trigger()` call or by the recurring scheduler. | | `input` | `string` | JSON-serialized input data. | | `output` | `string | undefined` | JSON-serialized output from the handler, set on completion. | | `error` | `string | undefined` | Error message, set on failure or timeout. | | `status` | `"pending" | "running" | "completed" | "failed" | "cancelled"` | Current lifecycle state. Transitions: pending → running → completed/failed. Can also be cancelled from any non-terminal state. | | `attempts` | `number` | Number of attempts executed so far. Starts at 0, incremented when dispatched. | | `maxAttempts` | `number` | Maximum allowed attempts (1 + retries). | | `timeout` | `number` | Timeout in milliseconds for this run. | | `interval` | `string | undefined` | Recurring interval string, present only for recurring runs. | | `nextRunAt` | `Date | undefined` | Earliest time this run should be picked up. | | `lastRunAt` | `Date | undefined` | Timestamp of the most recent execution attempt. | | `startedAt` | `Date | undefined` | When the current attempt started (reset on retry). | | `completedAt` | `Date | undefined` | When the run reached a terminal state. | | `createdAt` | `Date` | When the run was created. | ### Step For multi-step signals, each step has its own record: | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique step identifier. | | `runId` | `string` | ID of the parent run. | | `name` | `string` | Step name as defined in `.step(name, fn)`. | | `status` | `"pending" | "running" | "completed" | "failed"` | Current step state. | | `input` | `string | undefined` | JSON-serialized input passed to this step. | | `output` | `string | undefined` | JSON-serialized return value of this step. | | `error` | `string | undefined` | Error message if the step failed. | | `startedAt` | `Date | undefined` | When step execution started. | | `completedAt` | `Date | undefined` | When step execution finished. | * * * ## SignalSubscriber All methods are optional. Implement only the events you need. Subscriber methods are called synchronously and should not throw. If a subscriber throws, the error is caught, logged, and does not affect signal execution. | Method | Event data | When it fires | | --- | --- | --- | | `onSignalDiscovered` | `{ signalName, filePath }` | A signal file was found during auto-discovery from `signalsDir`. | | `onRunDispatched` | `{ run }` | A run was marked as `"running"` and the child process is about to spawn. | | `onRunStarted` | `{ run }` | The child process confirmed it found the signal and is about to execute the handler. | | `onRunCompleted` | `{ run, output? }` | The handler finished successfully. `output` is the JSON-serialized return value. | | `onRunTimeout` | `{ run }` | A running run exceeded its timeout. The child process is killed. If retries remain, the run is re-enqueued; otherwise it fails. | | `onRunRetry` | `{ run, attempt, maxAttempts }` | A failed run was reset to `"pending"` for another attempt. `attempt` is the current attempt number. | | `onRunFailed` | `{ run, error? }` | A run failed terminally. All retries are exhausted, or the error was marked as non-retryable. | | `onRunCancelled` | `{ run }` | A run was cancelled via `runner.cancel()`. | | `onRunSkipped` | `{ run, reason }` | A due run was skipped because the per-signal concurrency limit was reached or backoff has not elapsed. | | `onRunRescheduled` | `{ run, nextRunAt }` | A recurring run was enqueued and the next execution time was computed. | | `onStepStarted` | `{ run, step }` | A step within a multi-step run started execution. | | `onStepCompleted` | `{ run, step }` | A step completed successfully. | | `onStepFailed` | `{ run, step }` | A step threw an error. | | `onCompleteError` | `{ run, error }` | The `onComplete` callback threw. The run is still marked as completed because the handler itself succeeded. | | `onLogOutput` | `{ run, level, message }` | Console output (`stdout` or `stderr`) captured from the child process. | The built-in `ConsoleSubscriber` logs all events to stdout with a `[station-signal]` prefix. * * * ## `configure()` Sets a global default adapter. When the runner spawns a child process, that child may need to enqueue new runs (for example, if a signal's handler calls `.trigger()` on another signal). The child process reconstructs the adapter from serialized metadata and calls `configure()` automatically. You rarely need to call this directly; the runner does it for you in its constructor. ``` import { configure } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; configure({ adapter: new SqliteAdapter({ dbPath: "./data/signals.db" }), }); ``` If `configure()` is called more than once, a warning is logged and the previous adapter is replaced. Each runner should use its own adapter instance. * * * ## Re-exported Zod ``` import { signal, z } from "station-signal"; ``` `station-signal` re-exports `z` from Zod v4. One import for schema definitions and signal definitions. No need to install Zod as a separate dependency. --- # Broadcasts > Canonical HTML: [https://station.dterminal.net/docs/broadcasts](https://station.dterminal.net/docs/broadcasts) A broadcast chains multiple signals into a directed acyclic graph (DAG). Each node in the graph is a signal. Nodes execute when all their upstream dependencies complete. Outputs from upstream nodes are passed to downstream nodes automatically. The broadcast runner orchestrates the entire graph, handling fan-out, fan-in, conditional execution, and failure propagation. A broadcast follows dependencies 1. Extract**Fetch source**One signal produces the input for the next stage. 2. Fan out**Normalize + enrich**Independent signal nodes can run in parallel. 3. Fan in**Publish report**The final node waits for its required predecessors. A broadcast is the graph and its execution policy. The work inside each node is a signal. This reference describes the Node runtime. The shared builder also works with the [experimental browser runtime](https://station.dterminal.net/docs/browser.md). Read that guide for supported methods, explicit registration, IndexedDB recovery, and cooperative worker execution; Node runner guarantees and APIs do not apply in the browser. ## `broadcast(name)` Creates a named broadcast definition. The name must be unique, start with a letter, and contain only letters, digits, hyphens, and underscores. Returns a builder that connects signals into a dependency graph. ``` import { broadcast } from "station-broadcast"; import { signal, z } from "station-signal"; const validate = signal("validate") .input(z.object({ orderId: z.string() })) .output(z.object({ orderId: z.string(), total: z.number() })) .run(async (input) => { const order = await db.orders.findById(input.orderId); return { orderId: order.id, total: order.total }; }); const charge = signal("charge") .input(z.object({ orderId: z.string(), total: z.number() })) .output(z.object({ chargeId: z.string() })) .run(async (input) => { const result = await paymentGateway.charge(input.total); return { chargeId: result.id }; }); const notify = signal("notify") .input(z.object({ chargeId: z.string() })) .run(async (input) => { await emailService.sendReceipt(input.chargeId); }); export const orderFlow = broadcast("orderFlow") .input(validate) .then(charge) .then(notify) .build(); ``` * * * ## Builder methods ### `.input(signal)` Sets the entry signal -- the root node of the DAG. This is required and must be called first. The entry signal's input type becomes the broadcast's input type: when you call `broadcast.trigger(data)`, that data is passed to this signal. ``` const pipeline = broadcast("pipeline") .input(validate) // validate's input schema defines what .trigger() accepts // ... ``` ### `.then(signal, opts?)` Adds one or more downstream nodes. By default, each node depends on the most recently added tier (the "last tier"). Multiple signals passed to a single `.then()` call create parallel fan-out -- they all depend on the same upstream tier and run concurrently. ``` // Single signal with options: .then(charge, { as: "payment", after: ["validate"], map: (upstream) => ({ total: upstream.validate.total }), when: (upstream) => upstream.validate.total > 0, }) // Fan-out (multiple signals, no options): .then(emailReceipt, smsNotification, slackAlert) ``` | Option | Type | Description | | --- | --- | --- | | `as` | `string` | Custom name for this node. Defaults to the signal's name. Used in `after` arrays and as the key in downstream `map` functions. Required when the same signal appears multiple times in a broadcast. | | `after` | `string[]` | Explicit dependency list. The node waits for all named nodes to complete before executing. Overrides the default "depends on previous tier" behavior. Use this for fan-in patterns. | | `map` | `(upstream: Record) => unknown` | Transform function. Receives an object keyed by upstream node names, each containing that node's deserialized output. Returns the input for this signal. Without `map`, the behavior depends on the number of dependencies: a single dependency passes its output directly; multiple dependencies pass the entire upstream object. | | `when` | `(upstream: Record) => boolean` | Guard function. Receives the same upstream object as `map`. Return `false` to skip this node. Skipped nodes (with skip reason `"guard"`) do not propagate failure downstream -- their dependents still execute, receiving `undefined` for the skipped node's output. | Options (`as`, `after`, `map`, `when`) cannot be used with fan-out (multiple signals in a single `.then()` call). Use separate `.then()` calls with options for each signal instead. ### `.every(interval)` Makes the broadcast recurring. Accepts the same duration strings as signals: `"30s"`, `"5m"`, `"1h"`, `"1d"`. The broadcast runner enqueues a new run after each interval elapses. If a pending or running instance already exists, that tick is skipped to prevent overlap. ``` const hourlySync = broadcast("hourlySync") .input(fetchData) .then(transformData) .then(loadData) .every("1h") .withInput({ source: "production" }) .build(); ``` ### `.withInput(data)` Default input data for recurring broadcasts. Without this, recurring broadcasts run with `{}` as input. The data is passed to the entry signal. ### `.onFailure(policy)` Sets the failure policy that determines what happens when a node fails. Default: `"fail-fast"`. | Policy | Behavior | | --- | --- | | `"fail-fast"` | Stop the entire broadcast immediately. All running nodes are cancelled, all pending nodes are skipped. The broadcast is marked as failed. | | `"skip-downstream"` | Mark the failed node, skip its direct and transitive dependents (with skip reason `"upstream-failed"`), but continue executing independent branches. The broadcast is marked as failed when all branches finish. | | `"continue"` | Same as `"skip-downstream"` for node skipping, but the broadcast is marked as `"completed"` (with an error message noting the partial failure) rather than `"failed"`. Use this when some nodes are non-critical. | ``` const resilientPipeline = broadcast("resilientPipeline") .input(fetchData) .then(sendEmail) // non-critical: ok if this fails .then(updateDatabase) // critical .onFailure("continue") .build(); ``` ### `.timeout(ms)` Maximum total time for the entire broadcast execution in milliseconds. If the broadcast runs longer than this, all active nodes are cancelled and the broadcast is marked as failed with a timeout error. This is separate from per-signal timeouts, which still apply individually. ``` const timeBoundPipeline = broadcast("timeBoundPipeline") .input(validate) .then(processA) .then(processB) .timeout(60_000) // entire broadcast must finish within 60 seconds .build(); ``` ### `.build()` Finalizes the broadcast definition. Validates the DAG: checks for duplicate node names, missing dependencies, and cycles. Throws `BroadcastValidationError` or `BroadcastCycleError` if the graph is invalid. Returns a `BroadcastDefinition` object that can be registered with the runner and triggered. * * * ## Patterns ### Linear chain A → B → C. Each node depends on the previous. The output of each node is passed directly as input to the next. ``` const linear = broadcast("linear") .input(validate) // A .then(charge) // B: receives validate's output .then(notify) // C: receives charge's output .build(); ``` ### Fan-out A → \[B, C, D\]. Multiple nodes run in parallel after A completes. Each receives A's output. ``` const fanOut = broadcast("fanOut") .input(validate) .then(emailReceipt, smsNotification, slackAlert) .build(); // Equivalent to: const fanOutExplicit = broadcast("fanOutExplicit") .input(validate) .then(emailReceipt, { after: ["validate"] }) .then(smsNotification, { after: ["validate"] }) .then(slackAlert, { after: ["validate"] }) .build(); ``` ### Fan-in \[B, C\] → D. Node D waits for both B and C to complete. Use `after` to declare dependencies on multiple upstream nodes, and `map` to combine their outputs. ``` const fanIn = broadcast("fanIn") .input(validate) .then(emailReceipt, { as: "email" }) .then(notifyWarehouse, { as: "warehouse" }) .then(generateReport, { after: ["email", "warehouse"], map: (upstream) => ({ emailSent: upstream.email.sent, warehouseAck: upstream.warehouse.ackId, }), }) .build(); ``` ### Conditional execution Use `when` to skip nodes based on upstream results. Skipped nodes (reason: `"guard"`) do not count as failures and do not block their dependents. ``` const conditional = broadcast("conditional") .input(validate) .then(chargeCard, { when: (upstream) => upstream.validate.paymentMethod === "card", map: (upstream) => ({ amount: upstream.validate.total }), }) .then(chargeBankTransfer, { after: ["validate"], when: (upstream) => upstream.validate.paymentMethod === "bank", map: (upstream) => ({ amount: upstream.validate.total }), }) .then(sendConfirmation, { after: ["chargeCard", "chargeBankTransfer"], }) .build(); ``` ### Data mapping Use `map` to transform upstream outputs into the downstream signal's expected input format. The `upstream` argument is keyed by node name (or the `as` alias). ``` const mapped = broadcast("mapped") .input(fetchUser) .then(enrichProfile, { map: (upstream) => ({ userId: upstream.fetchUser.id, email: upstream.fetchUser.email, }), }) .build(); ``` ### Default data when upstream is skipped When a node is guard-skipped, its output is `undefined` in downstream `map` functions. Use nullish coalescing to provide fallback values. ``` const withFallback = broadcast("withFallback") .input(validate) .then(enrichFromCache, { when: (upstream) => upstream.validate.cacheEnabled, }) .then(process, { after: ["validate", "enrichFromCache"], map: (upstream) => ({ data: upstream.validate.data, enrichment: upstream.enrichFromCache ?? { source: "none" }, }), }) .build(); ``` * * * ## Triggering Trigger a broadcast to enqueue it for execution. The input is passed to the entry signal. Returns a broadcast run ID. ``` // Trigger via the definition (uses the global broadcast adapter) const runId = await orderFlow.trigger({ orderId: "ORD-9281" }); // Trigger via the runner (preferred — uses the runner's own adapter) const runId = await broadcastRunner.trigger("orderFlow", { orderId: "ORD-9281" }); // Optionally wait for the broadcast to complete const result = await broadcastRunner.waitForBroadcastRun(runId, { timeoutMs: 30_000, pollMs: 200, }); if (result?.status === "completed") { console.log("Broadcast finished successfully"); } else if (result?.status === "failed") { console.error("Broadcast failed:", result.error); } ``` * * * ## BroadcastRunner The broadcast runner orchestrates DAG execution. It polls for triggered broadcasts, initializes node run records, triggers root signals, and advances the graph as nodes complete. It coordinates with a `SignalRunner` instance to execute individual signals and monitor their completion. ``` import { BroadcastRunner, ConsoleBroadcastSubscriber } from "station-broadcast"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; const broadcastRunner = new BroadcastRunner({ signalRunner: runner, adapter: new BroadcastSqliteAdapter({ dbPath: "./data/broadcasts.db" }), subscribers: [new ConsoleBroadcastSubscriber()], pollIntervalMs: 1000, }); broadcastRunner.register(orderFlow); broadcastRunner.register(hourlySync); // Start the broadcast runner (blocks until stop() is called) await broadcastRunner.start(); ``` Shutdown order matters. The broadcast runner must stop before the signal runner because it queries the signal adapter during shutdown to check node completion status. Stop them in this order: ``` await broadcastRunner.stop({ graceful: true }); await signalRunner.stop({ graceful: true }); ``` ### Constructor options | Option | Type | Default | Description | | --- | --- | --- | --- | | `signalRunner` | `SignalRunner` | — | Required. The signal runner instance that executes individual signals. The broadcast runner reads from the signal runner's adapter to track node completion. | | `adapter` | `BroadcastQueueAdapter` | `BroadcastMemoryAdapter` | Storage backend for broadcast runs and node states. Use `BroadcastSqliteAdapter` for production persistence. | | `broadcastsDir` | `string` | — | Directory path for auto-discovery. Recursively imports all `.ts` and `.js` files and registers any exported broadcast definitions. | | `subscribers` | `BroadcastSubscriber[]` | `[]` | Array of subscriber objects notified on broadcast lifecycle events. | | `pollIntervalMs` | `number` | `1000` | Milliseconds between poll ticks. Each tick checks for pending broadcasts, advances running broadcasts, and handles recurring schedules. | ### Methods | Method | Returns | Description | | --- | --- | --- | | `register(definition)` | `this` | Register a broadcast definition. Alternative to auto-discovery via `broadcastsDir`. Chainable. Warns on duplicate names. | | `start()` | `Promise` | Start the poll loop. Discovers broadcasts from `broadcastsDir` (if set), installs shutdown handlers, and begins polling. Blocks until `stop()` is called. | | `stop(opts?)` | `Promise` | Stop the runner. Accepts optional `{ graceful: boolean, timeoutMs: number }`. When graceful, waits for running broadcasts to finish (up to the timeout). Closes the adapter. | | `trigger(name, input)` | `Promise` | Trigger a registered broadcast by name. Writes directly to this runner's adapter rather than the global singleton. Returns the broadcast run ID. | | `waitForBroadcastRun(id, opts?)` | `Promise` | Poll until a broadcast run reaches a terminal status (completed, failed, or cancelled). Options: `{ pollMs?, timeoutMs? }`. Default timeout: 60 seconds. | | `cancel(broadcastRunId)` | `Promise` | Cancel a broadcast run. Cancels all running signal runs, skips all pending nodes, and marks the broadcast as cancelled. Returns false if the run does not exist or is already terminal. | | `getBroadcastRun(id)` | `Promise` | Look up a broadcast run by its ID. | | `getNodeRuns(broadcastRunId)` | `Promise` | Get all node run records for a broadcast run. | | `listRegistered()` | `Array<{ name, nodeCount, failurePolicy, timeout?, interval? }>` | List metadata for all registered broadcast definitions. | | `hasBroadcast(name)` | `boolean` | Check whether a broadcast is registered by name. | | `subscribe(subscriber)` | `this` | Add a subscriber after construction. Chainable. | * * * ## BroadcastQueueAdapter The adapter interface for broadcast storage. Manages broadcast runs and their node runs. Two adapters ship with the framework: `BroadcastMemoryAdapter` (built into `station-broadcast`) and `BroadcastSqliteAdapter` (from `station-adapter-sqlite`). | Method | Signature | Description | | --- | --- | --- | | `addBroadcastRun(run)` | `(BroadcastRun) => Promise` | Insert a new broadcast run record. | | `getBroadcastRun(id)` | `(string) => Promise` | Look up a broadcast run by ID. | | `updateBroadcastRun(id, patch)` | `(string, BroadcastRunPatch) => Promise` | Partially update a broadcast run. Identity fields (`id`, `broadcastName`, `createdAt`) are immutable. | | `getBroadcastRunsDue()` | `() => Promise` | Get all broadcast runs with `"pending"` status, ready for initialization. | | `getBroadcastRunsRunning()` | `() => Promise` | Get all broadcast runs with `"running"` status, needing advancement. | | `listBroadcastRuns(broadcastName)` | `(string) => Promise` | List all runs for a specific broadcast name. | | `hasBroadcastRunWithStatus(name, statuses)` | `(string, BroadcastRunStatus[]) => Promise` | Check if any run exists for the given broadcast in one of the specified statuses. Used for recurring overlap prevention. | | `purgeBroadcastRuns(olderThan, statuses)` | `(Date, BroadcastRunStatus[]) => Promise` | Delete broadcast runs in terminal statuses older than the cutoff. Returns count deleted. | | `addNodeRun(nodeRun)` | `(BroadcastNodeRun) => Promise` | Insert a node run record. | | `getNodeRun(id)` | `(string) => Promise` | Look up a node run by ID. | | `updateNodeRun(id, patch)` | `(string, BroadcastNodeRunPatch) => Promise` | Partially update a node run. Identity fields (`id`, `broadcastRunId`, `nodeName`, `signalName`) are immutable. | | `getNodeRuns(broadcastRunId)` | `(string) => Promise` | Get all node runs for a given broadcast run. | | `generateId()` | `() => string` | Generate a unique ID for runs and node runs. | | `ping()` | `() => Promise` | Health check. Returns true if the adapter is operational. | | `close()` | `() => Promise` | Optional. Release resources. Called automatically on stop. | * * * ## BroadcastRun Represents a single execution of a broadcast. | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique broadcast run identifier. | | `broadcastName` | `string` | Name of the broadcast definition. | | `input` | `string` | JSON-serialized input provided when triggered. | | `status` | `"pending" | "running" | "completed" | "failed" | "cancelled"` | Current lifecycle state. | | `failurePolicy` | `"fail-fast" | "skip-downstream" | "continue"` | The failure policy in effect for this run. | | `timeout` | `number | undefined` | Broadcast-level timeout in milliseconds, if set. | | `interval` | `string | undefined` | Recurring interval string, if this is a recurring broadcast. | | `nextRunAt` | `Date | undefined` | Scheduled time for the next recurring execution. | | `createdAt` | `Date` | When the broadcast run was created. | | `startedAt` | `Date | undefined` | When the broadcast began executing (first nodes triggered). | | `completedAt` | `Date | undefined` | When the broadcast reached a terminal state. | | `error` | `string | undefined` | Error message on failure. Also set on completed broadcasts with `"continue"` policy if any nodes failed (partial failure). | ### BroadcastNodeRun Represents a single node's execution within a broadcast run. | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique node run identifier. | | `broadcastRunId` | `string` | ID of the parent broadcast run. | | `nodeName` | `string` | Name of this node in the DAG (signal name or the `as` alias). | | `signalName` | `string` | Name of the underlying signal. | | `signalRunId` | `string | undefined` | ID of the signal run created for this node. Links to the `Run` record in the signal adapter. | | `status` | `"pending" | "running" | "completed" | "failed" | "skipped"` | Current node state. | | `skipReason` | `"guard" | "upstream-failed" | "cancelled" | undefined` | Why this node was skipped. Only set when status is `"skipped"`. `"guard"`: the `when` function returned false. `"upstream-failed"`: an upstream dependency failed. `"cancelled"`: the broadcast was cancelled. | | `input` | `string | undefined` | JSON-serialized input passed to the signal. | | `output` | `string | undefined` | JSON-serialized output from the completed signal. | | `error` | `string | undefined` | Error message if the node failed. | | `startedAt` | `Date | undefined` | When the node started executing. | | `completedAt` | `Date | undefined` | When the node reached a terminal state. | * * * ## BroadcastSubscriber All methods are optional. Implement only the events you need. Subscriber errors are caught and logged without affecting broadcast execution. | Method | Event data | When it fires | | --- | --- | --- | | `onBroadcastDiscovered` | `{ broadcastName, filePath }` | A broadcast file was found during auto-discovery from `broadcastsDir`. | | `onBroadcastQueued` | `{ broadcastRun }` | A broadcast run was created and added to the queue. | | `onBroadcastStarted` | `{ broadcastRun }` | A broadcast run transitioned from `"pending"` to `"running"`. Node run records have been created and root nodes are about to be triggered. | | `onBroadcastCompleted` | `{ broadcastRun }` | All nodes reached terminal states and the broadcast is marked as completed. Under the `"continue"` policy, the `broadcastRun.error` field may contain a partial failure message. | | `onBroadcastFailed` | `{ broadcastRun, error }` | The broadcast failed. Under `"fail-fast"`, this fires immediately when any node fails. Under `"skip-downstream"`, this fires after all branches finish. | | `onBroadcastCancelled` | `{ broadcastRun }` | The broadcast was cancelled via `broadcastRunner.cancel()`. | | `onNodeTriggered` | `{ broadcastRun, nodeRun }` | A node's signal was triggered via `.trigger()`. The `nodeRun.signalRunId` is now set. | | `onNodeCompleted` | `{ broadcastRun, nodeRun }` | A node's signal completed. The `nodeRun.output` contains the JSON-serialized result. | | `onNodeFailed` | `{ broadcastRun, nodeRun, error }` | A node's signal failed, its `map` function threw, its `when` function threw, or input validation failed. | | `onNodeSkipped` | `{ broadcastRun, nodeRun, reason }` | A node was skipped. Reasons include: guard returned false, upstream dependency failed, or broadcast was cancelled. | The built-in `ConsoleBroadcastSubscriber` logs all events to stdout with a `[station-broadcast]` prefix. * * * ## Dynamic broadcasts Everything on this page describes file-defined broadcasts: definitions you build with `broadcast(...)` at module scope and ship in your codebase. Station also supports [dynamic broadcasts](https://station.dterminal.net/docs/dynamic-broadcasts.md) — runtime-defined DAGs persisted through the broadcast adapter and edited over the v1 API or the dashboard's graph builder. The two registries are separate; a name in one never collides with a name in the other. The builder API documented above is unchanged — dynamic broadcasts are an opt-in additive surface, not a replacement. If you need to compose existing signals into a workflow at runtime without redeploying, see the [Dynamic broadcasts](https://station.dterminal.net/docs/dynamic-broadcasts.md) guide. --- # Beacons > Canonical HTML: [https://station.dterminal.net/docs/beacons](https://station.dterminal.net/docs/beacons) A **beacon** is a long-running, supervised process. Where a [signal](https://station.dterminal.net/docs/signals.md) runs to completion and exits, and a [broadcast](https://station.dterminal.net/docs/broadcasts.md) wires signals into a DAG, a beacon *stays up* — an HTTP server, a queue consumer, a poller, a websocket client. The `BeaconRunner` supervises each beacon in its own child process: it keeps it alive according to a restart policy, backs off between restarts, detects startup timeouts and heartbeat stalls, and shuts it down gracefully — reconciling a per-beacon **desired state** (running / stopped) you can flip at runtime. A beacon follows desired state 1. Start**Launch + ready**The supervised process initializes and reports readiness. 2. Run**Serve or listen**Keep a connection, poll a source or expose a service. 3. Reconcile**Restart or stop**Apply restart limits and backoff, or stop when requested. A beacon is a supervised long-lived process. Its memory and open connections still disappear when that process exits. This reference describes the Node runtime. The shared builder also works with the [experimental browser runtime](https://station.dterminal.net/docs/browser.md). Read that guide for supported methods, explicit registration, IndexedDB recovery, and cooperative worker execution; Node runner guarantees and APIs do not apply in the browser. ## `beacon(name)` Creates a named beacon definition. The name must be unique, start with a letter, and contain only letters, digits, hyphens, and underscores. Returns a builder. There are two terminals: `.run()` for a general long-running handler, and `.poll()` for a framework-managed interval loop. Import `beacon` and `z` from `station-beacon`. ``` import { beacon, z } from "station-beacon"; import { createServer } from "node:http"; export const webhookServer = beacon("webhook-server") .config(z.object({ port: z.number().default(8080) })) .restart("always") .run(async (ctx) => { const server = createServer(handler).listen(ctx.config.port); ctx.ready(); // mark healthy (optional) ctx.onStop(() => server.close()); // cleanup when asked to stop await ctx.untilStopped(); // park until stopped }); ``` A beacon isn't triggered like a signal — it is started, stopped, and restarted by the `BeaconRunner`, which keeps it alive per its restart policy. Export one beacon per file for auto-discovery. * * * ## Builder methods ### `.config(schema)` · `.withConfig(data)` `.config()` declares a Zod schema for the beacon's configuration. It is validated (with defaults applied) in the child process before each start; the parsed value is available as `ctx.config`. An invalid config is a *fatal* error — the beacon goes to `errored` and is never restarted (retrying with the same bad config would just loop). `.withConfig()` sets the default config used when the beacon is started without an override. ``` beacon("indexer") .config(z.object({ batchSize: z.number().default(100), source: z.string() })) .withConfig({ source: "s3://bucket/data" }) .run(async (ctx) => { /* ctx.config.batchSize === 100 */ }); ``` ### `.restart(policy)` How the supervisor reacts when the process exits. Default: `"on-failure"`. | Policy | Behavior | | --- | --- | | `"always"` | Bring it back up on any exit — clean or crash. For servers and clients that should always be running. | | `"on-failure"` | Restart only on a crash/failure, a heartbeat stall, or a startup timeout. A clean return parks the beacon. The default. | | `"never"` | Run once — a clean return or a failure is terminal. | ### `.backoff(base, opts?)` Configures exponential backoff between restarts. `base` is the first-restart delay (an interval string like `"1s"` or a millisecond number). The delay grows as `base × factor^n`, capped at `max`. After the process stays up longer than `resetAfter`, the consecutive- restart counter resets, so a beacon that ran fine for a while then blips restarts quickly instead of at the top of the curve. | Option | Type | Default | Description | | --- | --- | --- | --- | | `factor` | `number` | `2` | Multiplier applied per consecutive restart. Must be ≥ 1. | | `max` | `string | number` | `"30s"` | Upper bound on any single restart delay. | | `resetAfter` | `string | number` | `"60s"` | Uptime after which the consecutive-restart counter resets. | ``` beacon("stream-consumer") .restart("on-failure") .backoff("1s", { factor: 2, max: "30s", resetAfter: "60s" }) .run(connectAndConsume); ``` ### `.heartbeat(interval, opts?)` Opts into stall detection. The handler must call `ctx.heartbeat()` at least every `interval`; if the supervisor sees no heartbeat within the timeout (default 3× the interval) it treats the process as stalled and restarts it. The clock starts when the handler actually starts, so process boot time never counts against the deadline. ``` beacon("worker") .heartbeat("10s", { timeout: "45s" }) .run(async (ctx) => { ctx.ready(); for await (const job of queue.stream({ signal: ctx.signal })) { ctx.heartbeat(); await process(job); } }); ``` ### `.startupTimeout(ms)` Sets a deadline, measured from spawn, for the beacon to reach ready via `ctx.ready()`. If it doesn't come up in time the supervisor kills the process and restarts it per the restart policy, recording the exit reason as `startup-timeout`. This catches two things heartbeat detection can't: a boot or module import that never resolves (the handler never even runs), and a handler that starts but wedges before it's ready — a server that never binds its port, say. Startup timeout covers the pre-ready window; heartbeats cover everything after. Off by default; requires the beacon to call `ctx.ready()`. ``` beacon("api-server") .startupTimeout("30s") // must call ctx.ready() within 30s of spawn .heartbeat("10s") // ...and keep reporting liveness once ready .run(async (ctx) => { const server = await listen(ctx.config.port); ctx.ready(); ctx.onStop(() => server.close()); await ctx.untilStopped(); }); ``` ### `.stopTimeout(ms)` Sets the grace period a beacon gets to exit after a stop is requested before it is force-killed (default `"10s"`). ### `.manualStart()` · `.onDemand()` · `.maxInstances(n)` These decide how a beacon comes to be running. By default a beacon is seeded with one instance on discovery and started. `.manualStart()` still seeds that instance but leaves it stopped until `startBeacon(name)` is called. `.onDemand()` seeds nothing — the beacon becomes a template whose instances are created at runtime, each with its own config. See [Instances](https://station.dterminal.net/docs/beacons.md#instances) below. `.maxInstances(n)` caps how many can exist at once. ### `.env(...keys)` Declares [environment variables](https://station.dterminal.net/docs/environment.md) the beacon requires. Before each launch the supervisor checks each key against the env store and the host `process.env`; if any is missing it marks the beacon `errored` instead of spawning a process that can't come up. Provided values are injected into the child's `process.env`. ``` beacon("price-feed") .env("EXCHANGE_API_KEY") // errored (not spawned) if unset .restart("always") .run(async (ctx) => { /* ... */ }); ``` ### `.placement({ labels })` In a [Station Network](https://station.dterminal.net/docs/network.md), restricts the beacon to execution stations whose labels exactly match. A shared, fenced instance lease ensures only one eligible station owns each instance. ### `.run(handler)` Finalizes with a long-running handler. It runs until it returns, throws, or `ctx.signal` aborts. Use it for servers and stream clients. A server handler typically starts the thing, calls `ctx.ready()`, registers `ctx.onStop()` cleanup, and parks on `await ctx.untilStopped()`. Returning early is treated as a clean completion. ### `.poll(interval, fn)` Finalizes as a poller — the framework calls `fn` every `interval` until the beacon is stopped, and marks it ready on the first tick. Throwing from `fn` crashes the incarnation and lets the restart policy take over; catch inside `fn` to keep polling through transient errors. ``` beacon("price-watcher").poll("30s", async (ctx) => { const price = await fetchPrice({ signal: ctx.signal }); if (price > 100) await priceAlert.trigger({ price }); }); ``` * * * ## The beacon context Every handler receives a `ctx` — its window into the supervisor. | Member | Type | Description | | --- | --- | --- | | `ctx.config` | `TConfig` | Validated config for this incarnation (schema defaults applied). | | `ctx.name` | `string` | The beacon's name. | | `ctx.incarnation` | `number` | 1 on first start, incremented on each supervised restart. | | `ctx.signal` | `AbortSignal` | Fires when the beacon should stop. Pass it to `fetch`, stream iterators, etc. so in-flight work unwinds promptly. | | `ctx.ready()` | `() => void` | Mark the beacon ready/healthy (records `readyAt`). Optional. | | `ctx.heartbeat()` | `() => void` | Report liveness. Required if you declared `.heartbeat()`. | | `ctx.expose(opts)` | `() => void` | Advertise an HTTP/WebSocket protocol, port, and optional base path for Headquarters discovery and HTTP proxying. | | `ctx.log(msg)` | `(string) => void` | Emit a structured log line to subscribers. | | `ctx.onStop(fn)` | `(fn) => void` | Register cleanup to run when a stop is requested. Multiple run in order. | | `ctx.untilStopped()` | `() => Promise` | Resolves when `ctx.signal` aborts — the idiomatic tail of a server handler. | * * * ## The three modes ### Server Start the server, mark ready, register cleanup, and park on `untilStopped()`. `restart("always")` keeps it up. ``` export const api = beacon("api") .config(z.object({ port: z.number().default(3000) })) .restart("always") .run(async (ctx) => { const server = createServer(app).listen(ctx.config.port); ctx.ready(); ctx.onStop(() => new Promise((r) => server.close(() => r()))); await ctx.untilStopped(); }); ``` ### Poller The framework drives the interval; the beacon can trigger signals as it polls. ``` export const healthPoller = beacon("health-poller").poll("15s", async (ctx) => { const res = await fetch("https://api.example.com/health", { signal: ctx.signal }); if (!res.ok) await pageOncall.trigger({ status: res.status }); }); ``` ### Client Maintain a connection; throwing on a dropped connection lets the supervisor reconnect with backoff. Heartbeats guard against a silently wedged connection. ``` export const consumer = beacon("consumer") .restart("on-failure") .backoff("1s", { max: "30s" }) .heartbeat("10s") .run(async (ctx) => { const conn = await connect(); ctx.ready(); for await (const msg of conn.stream({ signal: ctx.signal })) { ctx.heartbeat(); await ingest.trigger(msg); } }); ``` * * * ## BeaconRunner The supervisor. It discovers beacons, runs each enabled one in its own child process, keeps it alive per its restart policy, and reconciles the per-beacon desired state each tick. ``` import path from "node:path"; import { BeaconRunner, ConsoleBeaconSubscriber } from "station-beacon"; const runner = new BeaconRunner({ beaconsDir: path.join(import.meta.dirname, "beacons"), subscribers: [new ConsoleBeaconSubscriber()], signalRunner, // optional — lets beacons trigger signals into the shared queue }); await runner.start(); // discovers beacons and supervises them (blocks until stop) ``` ### Constructor options | Option | Type | Default | Description | | --- | --- | --- | --- | | `beaconsDir` | `string` | — | Directory for auto-discovery. Recursively imports `.ts`/`.js` files and registers exported beacon definitions. | | `adapter` | `BeaconStateAdapter` | `BeaconMemoryAdapter` | Storage for supervision state (status, desired state, counters, events). | | `signalRunner` | `SignalRunner` | — | Wire a signal runner so beacons can `signal.trigger()` into the same queue it drains (its adapter manifest is passed to children). | | `signalAdapter` | `SignalQueueAdapter` | — | Alternative to `signalRunner` — pass the signal adapter directly. | | `subscribers` | `BeaconSubscriber[]` | `[]` | Objects notified on beacon lifecycle events. | | `pollIntervalMs` | `number` | `1000` | Milliseconds between reconcile ticks. | ### Methods | Method | Returns | Description | | --- | --- | --- | | `start()` | `Promise` | Discover beacons, seed/resume state, install shutdown handlers, and run the reconcile loop. Blocks until `stop()`. | | `stop(opts?)` | `Promise` | Stop the supervisor. With `{ graceful: true, timeoutMs }`, running beacons are asked to stop and awaited before being force-killed. Desired state is left untouched so a restart resumes them. | | `startBeacon(name, opts?)` | `Promise` | Set desired state to running and schedule an immediate launch. Accepts `{ config }` to override the config for this run. Recovers an `errored` beacon. | | `stopBeacon(name)` | `Promise` | Set desired state to stopped and gracefully stop the running incarnation. | | `restartBeacon(name)` | `Promise` | Gracefully stop the current incarnation, then relaunch with a fresh incarnation. | | `createInstance(name, opts?)` | `Promise` | Create a new instance with its own config and (by default) start it. `{ id?, label?, config?, start? }`. | | `updateInstance(id, opts)` | `Promise` | Change an instance's `config` or `label`. Takes effect on the next start, or immediately with `{ restart: true }`. | | `deleteInstance(id, opts?)` | `Promise` | Stop the instance and remove its record. Runtime-created instances only — stop a definition-owned one instead. | | `startInstance(id, opts?)` · `stopInstance(id)` · `restartInstance(id)` | `Promise` | The per-instance equivalents of the definition-level controls above. | | `stopAllInstances(name)` | `Promise` | Stop every instance of a beacon; returns how many were stopped. | | `getInstance(id)` | `Promise` | An instance record by id (status, desired state, counters, timestamps). A beacon's definition-owned instance uses the beacon name as its id. | | `listInstances(filter?)` | `Promise` | All known instance records, optionally narrowed with `{ beaconName }`. | | `whenReady()` | `Promise` | Resolves once `start()` has finished discovery, hydration, and seeding. `start()` itself never settles while supervising, so await this before serving an API or creating instances at boot. | | `register(beacon, filePath)` | `this` | Register a beacon explicitly (alternative to `beaconsDir`). Call before `start()`. | | `listRegistered()` | `Array<{ name, filePath, mode, restartPolicy, startMode, maxInstances }>` | Metadata for all registered beacons. | | `subscribe(subscriber)` | `this` | Add a subscriber after construction. | ### Runtime control Flip a beacon's desired state at any time — the supervisor reconciles toward it on the next tick. ``` await runner.stopBeacon("consumer"); // stop and keep stopped await runner.startBeacon("consumer", { // start with a config override config: { source: "s3://other-bucket" }, }); await runner.restartBeacon("consumer"); // graceful stop, then relaunch const inst = await runner.getInstance("consumer"); // { status: "running", desiredState: "running", incarnation: 3, restartCount: 0, ... } ``` * * * ## Running many instances of one beacon A beacon definition can back many running **instances**. Each is supervised independently — its own process, config, status, restart counter, and logs — so the same beacon can run once per tenant, queue, or stream, driven from the dashboard or the API. | Start mode | Behaviour | | --- | --- | | `auto` (default) | One instance is seeded on discovery and started. Its id is the beacon name. | | `.manualStart()` | One instance is seeded but left stopped until someone starts it. | | `.onDemand()` | Nothing is seeded. Instances exist only once created at runtime. | The instance seeded from the file has `origin: "definition"` and uses the beacon name as its id, so `startBeacon` / `stopBeacon` keep acting on it. Instances created at runtime have `origin: "api"`, their own ids, and can be deleted outright. ``` // beacons/queue-worker.ts — a template, not a single process export const queueWorker = beacon("queue-worker") .config(z.object({ queue: z.string(), batchSize: z.number().default(10) })) .onDemand() .maxInstances(8) .run(async (ctx) => { ctx.log(`worker ${ctx.instanceId} draining ${ctx.config.queue}`); ctx.ready(); await ctx.untilStopped(); }); ``` ``` // start() only settles when the supervisor stops, so wait for setup runner.start().catch(console.error); await runner.whenReady(); const worker = await runner.createInstance("queue-worker", { id: "worker-acme", // optional — generated when omitted label: "acme", config: { queue: "acme", batchSize: 25 }, // validated against the config schema }); // starts immediately (start: false to stage it) await runner.listInstances({ beaconName: "queue-worker" }); await runner.updateInstance(worker.id, { config: { queue: "acme2" }, restart: true }); await runner.stopInstance(worker.id); await runner.deleteInstance(worker.id); // stops the process, then removes the record ``` Instance ids are unique across all beacons, must match `/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/`, and are at most 128 characters. Runtime-created instances are persisted, so a supervisor restart resumes them; an instance whose definition is no longer registered is surfaced as `errored` with an explanation rather than silently dropped. ### Over the API The same operations are available over HTTP. The authenticated v1 API mirrors these under `/api/v1`, with creating and starting on the `trigger` scope, stopping on `cancel`, and editing or deleting an instance on `admin`. ``` POST /api/beacons/:name/instances { id?, label?, config?, start? } GET /api/beacons/:name/instances GET /api/beacons/:name/instances/:id POST /api/beacons/:name/instances/:id/{start,stop,restart} PATCH /api/beacons/:name/instances/:id { config?, label?, restart? } DELETE /api/beacons/:name/instances/:id POST /api/beacons/:name/stop?all=true stop every instance ``` Creation failures are distinguishable: `400 invalid_config`, `404 not_found` for an unknown beacon, `409 instance_exists` for a taken id, and `409 instance_limit` at the cap. * * * ## BeaconInstance The supervised record for one instance of a beacon, updated as incarnations start, become ready, and exit. A definition may have many. | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique instance id. A beacon's definition-owned instance uses the beacon name. | | `beaconName` | `string` | The name of the beacon definition this instance runs. | | `label` | `string | undefined` | Optional human-readable label for a runtime-created instance. | | `origin` | `"definition" | "api"` | Whether the instance was seeded from the beacon file or created at runtime. Only `api` instances can be deleted. | | `status` | `"stopped" | "starting" | "running" | "stopping" | "backoff" | "errored"` | Observed lifecycle status. `backoff` means a (re)start is scheduled at `nextRestartAt`; `errored` is terminal (won't auto-restart). | | `desiredState` | `"running" | "stopped"` | What the operator wants. The supervisor reconciles toward this. | | `incarnation` | `number` | How many times the beacon has been started over its lifetime. | | `restartCount` | `number` | Consecutive restart attempts since the beacon was last healthy. | | `pid` | `number | undefined` | OS process id of the current incarnation, when running. | | `readyAt` / `startedAt` / `lastHeartbeatAt` | `Date | undefined` | Timestamps for readiness, incarnation start, and the last heartbeat. | | `lastExitReason` | `"clean" | "failure" | "stopped" | "stalled" | "startup-timeout"` | How the most recent incarnation ended. | | `lastError` / `nextRestartAt` | `string` / `Date | undefined` | Last error message; and, in `backoff`, when the next restart fires. | * * * ## BeaconSubscriber All methods are optional. Subscriber errors are caught and logged without affecting supervision. The built-in `ConsoleBeaconSubscriber` logs every event with a `[station-beacon]` prefix. | Method | When it fires | | --- | --- | | `onBeaconDiscovered` | A beacon file was found during auto-discovery. | | `onBeaconInstanceCreated` | A new instance was created at runtime (dashboard / API). | | `onBeaconInstanceRemoved` | A runtime-created instance was stopped and removed. | | `onBeaconStarting` | The supervisor is about to spawn a child process. | | `onBeaconStarted` | The child reported the handler has started executing. | | `onBeaconReady` | The handler called `ctx.ready()`. | | `onBeaconHeartbeat` | A heartbeat was received. | | `onBeaconExited` | The child process exited (with `reason` and `code`). | | `onBeaconRestartScheduled` | A restart was scheduled after an exit (with the backoff delay). | | `onBeaconStopped` | The beacon reached a cleanly stopped state. | | `onBeaconErrored` | The beacon failed terminally and will not be restarted. | | `onBeaconStalled` | A heartbeat deadline or startup timeout was missed; the process is being restarted. | | `onBeaconLog` | Log output — from `ctx.log()` or captured stdout/stderr. | * * * ## Triggering signals from a beacon Beacons commonly trigger [signals](https://station.dterminal.net/docs/signals.md) — a poller firing an alert, a consumer enqueuing work. Wire a `SignalRunner` into the `BeaconRunner` and use a **persistent** signal adapter so the trigger — which happens in the beacon's child process — reaches the same queue the `SignalRunner` drains. ``` import { SignalRunner } from "station-signal"; import { BeaconRunner } from "station-beacon"; import { SqliteAdapter } from "station-adapter-sqlite"; const signalRunner = new SignalRunner({ signalsDir: "./signals", adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), }); const beaconRunner = new BeaconRunner({ beaconsDir: "./beacons", signalRunner, // beacons can now signal.trigger() into the shared queue }); await signalRunner.start(); await beaconRunner.start(); ``` With the default in-memory adapter a beacon's `signal.trigger()` writes to an isolated adapter in its own child process, so the parent `SignalRunner` never sees it. Use a persistent signal adapter (SQLite/Postgres/…) whenever beacons trigger signals. ## Dashboard Point the [dashboard](https://station.dterminal.net/docs/dashboard.md) at a beacons directory and it supervises them and surfaces them under a **Beacons** page — live status, incarnation and restart counts, lifecycle events, streaming logs, and start / stop / restart controls. ``` // station.config.ts import { defineConfig } from "station-daemon"; export default defineConfig({ beaconsDir: "./beacons", // beaconAdapter: new BeaconSqliteAdapter(...), // optional, for durable state }); ``` Then run `pnpm exec stationd`, start the separate dashboard using the [dashboard guide](https://station.dterminal.net/docs/dashboard.md), and open `/beacons`. A beacon's page lists its instances, builds new ones from the config schema, and scopes logs and controls to the selected instance. The REST surface behind the page (see [Instances](https://station.dterminal.net/docs/beacons.md#instances)) is available for your own tooling, and `beaconMaxInstances` in `defineConfig` sets the default instance cap. ## Persistence Supervision state (the instance record + lifecycle event log) lives behind a `BeaconStateAdapter`. The default `BeaconMemoryAdapter` is single-process; on restart the supervisor re-derives desired state from each beacon's start mode, and runtime-created instances do not survive. For durable state across restarts — and to keep instances created through the API — use a `/beacon` subpath adapter: ``` import { BeaconSqliteAdapter } from "station-adapter-sqlite/beacon"; // or /postgres/beacon, /mysql/beacon (async .create()), /redis/beacon const adapter = new BeaconSqliteAdapter({ dbPath: "./station.db" }); // new BeaconRunner({ beaconsDir, adapter }) // or defineConfig({ beaconsDir, beaconAdapter: adapter }) ``` Each adapter persists the instance records and the lifecycle event log, so a supervisor restart resumes desired state, brings back instances created through the API, and keeps the dashboard's history. A database written before instances existed is migrated in place on first open — the old per-beacon record becomes that beacon's definition-owned instance, keeping its desired state and counters. --- # Expressions > Canonical HTML: [https://station.dterminal.net/docs/expressions](https://station.dterminal.net/docs/expressions) Expressions are Station's small, deterministic, side-effect-free language for the `input` mappings and `when` guards in [dynamic broadcasts](https://station.dterminal.net/docs/dynamic-broadcasts.md). They're pure functions of the broadcast's trigger input and upstream node outputs — no I/O, no time, no randomness, no loops, no user-defined functions. The persisted form is a JSON AST; a familiar string syntax exists for the dashboard and playground and compiles to the same AST. Expressions ship in the `station-expressions` package. It has no dependencies on the rest of Station and is safe to use anywhere a small, sandboxable expression language is useful. * * * ## ExprNode Six kinds of node make up the AST: ``` type ExprNode = | { kind: "ref"; path: string[] } | { kind: "lit"; value: unknown } | { kind: "tmpl"; parts: (string | ExprNode)[] } | { kind: "op"; op: BinaryOp | UnaryOp; args: ExprNode[] } | { kind: "obj"; entries: Record } | { kind: "arr"; items: ExprNode[] }; ``` | Kind | Shape | Description | | --- | --- | --- | | `ref` | `{ path: string[] }` | A path lookup against the evaluation context. See [Reference paths](https://station.dterminal.net/docs/expressions.md#reference-paths). | | `lit` | `{ value: unknown }` | A constant. Any JSON-serialisable value: number, string, boolean, null, array, or plain object. | | `tmpl` | `{ parts: (string | ExprNode)[] }` | Template literal. Interleaves string fragments with embedded expressions and concatenates them — every embedded value is coerced to its string form. | | `op` | `{ op: string; args: ExprNode[] }` | Operator application. Unary ops have a single arg; binary ops have two. | | `obj` | `{ entries: Record }` | Object construction. Each value is an expression that evaluates into the corresponding key. | | `arr` | `{ items: ExprNode[] }` | Array construction. | * * * ## Reference paths A `ref` resolves against an evaluation context with two roots: `input` (the broadcast's trigger input) and `upstream` (an object keyed by upstream node name). | Path | Resolves to | | --- | --- | | `["input", "orderId"]` | The broadcast trigger input's `orderId` field. | | `["upstream", "validate", "total"]` | The `validate` node's output, field `total`. | | `["validate", "total"]` | Shorthand — any first segment that is not `input` is treated as `upstream.`. | Missing paths return `undefined` rather than throwing — the evaluator never panics on a typo at runtime. The validator catches missing-property errors at save time, against the signal schemas. * * * ## Operators | Operator | Arity | Notes | | --- | --- | --- | | `==` `!=` | binary | Strict equality (`===` / `!==` in JS terms). | | `<` `>` `<=` `>=` | binary | Numeric or string comparison. | | `&&` `||` | binary | Short-circuit boolean logic. | | `!` | unary | Boolean negation. | | `+` | binary | Overloaded: if either operand is a string, the result is a string concatenation; otherwise numeric addition. | | `-` `*` `/` | binary | Numeric. | That's the full operator set. There are no bitwise operators, no modulo, no ternary, no spread. If you need them, push the logic into a signal. * * * ## String syntax The parser accepts a familiar string form and compiles to the same AST. It's what the dashboard's expression editor produces and what the `POST /expressions/parse` endpoint exposes. ``` import { parse, stringify } from "station-expressions"; parse('input.amount > 100 && input.user.tier == "premium"'); // → { kind: "op", op: "&&", args: [...] } stringify(node); // → '(input.amount > 100)' ``` Parsing is lossless against the AST: `parse(stringify(n))` produces a structurally identical node. * * * ## Evaluation ``` import { evaluate } from "station-expressions"; const node = { kind: "op", op: ">", args: [ { kind: "ref", path: ["input", "amount"] }, { kind: "lit", value: 100 }, ], }; evaluate(node, { input: { amount: 250 }, upstream: {} }); // → true ``` Evaluation is bounded by `MAX_NODES = 10_000` — a runaway expression throws `ExpressionEvalError` rather than spinning. There are no closures, no captured state, no global access. * * * ## Validation `validate` type-checks an expression against the schemas derived from a signal's Zod input/output types. It's what the broadcast-definition save endpoint runs before it accepts a spec. ``` import { validate } from "station-expressions"; validate(node, { inputSchema: { type: "object", properties: { amount: { type: "number" } }, }, upstreamSchemas: {}, expectedSchema: { type: "boolean" }, }); // → { ok: true, errors: [] } ``` ### Behaviour around unknown and any Schema fields with `type: "any"` opt out of type-checking — refs into them are treated as compatible with anything, and operator type rules are relaxed when either side is `any`. This is what lets you write expressions against signals whose schemas haven't been narrowed (or against the broadcast trigger input when you haven't declared an explicit schema). Schema fields with `type: "unknown"`, by contrast, propagate as errors when used in operator positions that require a concrete type. Use `any` when you genuinely don't want the validator to check; use `unknown` when you want it to force an upstream type narrowing. * * * ## HTTP API The Station server exposes the expression toolkit under `/api/v1/expressions` for clients (mainly the dashboard) to parse, evaluate and validate without re-implementing the language. | Endpoint | Body | Returns | | --- | --- | --- | | `POST /api/v1/expressions/parse` | `{ "source": "input.x > 0" }` | `{ "node": ExprNode }` on success; `400` with parse error details on failure. | | `POST /api/v1/expressions/evaluate` | `{ "node": ExprNode, "context": { input, upstream } }` | `{ "value": unknown }` | | `POST /api/v1/expressions/validate` | `{ "node": ExprNode, "inputSchema": SchemaField, "upstreamSchemas": {...}, "expectedSchema": SchemaField }` | `{ "ok": boolean, "errors": [...] }` | * * * ## Determinism guarantees - `evaluate` is total over inputs that pass `validate` against the real schemas — it never throws on type mismatches at runtime if the validator passed. - Bounded by `MAX_NODES = 10_000`. Larger expressions throw rather than running. - No closures, no captured state, no access to the host environment. Two evaluations with the same AST and context return the same value. * * * ## The escape hatch If you can't express something in this language — you need a loop, a regex, an HTTP call, a clever transform — write a code-defined [signal](https://station.dterminal.net/docs/signals.md) that does the logic in TypeScript and reference it from your broadcast graph. The signal is Station's unit of arbitrary code; expressions are for connecting signals together. Don't fight the language to embed logic that belongs in a signal. --- # Adapters > Canonical HTML: [https://station.dterminal.net/docs/adapters](https://station.dterminal.net/docs/adapters) Adapters are pluggable storage backends. They determine how signal runs are persisted and retrieved. The runner polls the adapter for due entries on every tick, and signal handlers write their results back through it. Two built-in adapters ship with Station. You can write custom adapters by implementing the `SignalQueueAdapter` interface (for signals) or `BroadcastQueueAdapter` interface (for broadcasts). * * * ## MemoryAdapter The default adapter when none is specified. Stores all runs in a JavaScript `Map` inside the process. No data survives a restart. Good for development, testing, and single-run scripts where persistence is irrelevant. - No external dependencies - No configuration required - Cannot share state across processes — each process gets its own isolated store - Does not implement `SerializableAdapter`, so child processes spawned by the runner cannot access the parent’s in-memory data ``` import { SignalRunner } from "station-signal"; // MemoryAdapter is the default — no configuration needed const runner = new SignalRunner({ signalsDir: "./signals", }); ``` To explicitly construct one (for example, to pass to Station): ``` import { MemoryAdapter } from "station-signal"; const adapter = new MemoryAdapter(); ``` * * * ## SqliteAdapter Production-ready persistent storage backed by [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — synchronous C++ bindings that are significantly faster than async alternatives for single-node workloads. WAL (Write-Ahead Logging) mode is enabled on connection, allowing concurrent reads and writes without blocking. Tables, indexes, and columns are created automatically on first use. Date fields are stored as ISO-8601 text strings. The adapter interface is async (all methods return Promises) even though better-sqlite3 is synchronous. This preserves compatibility with the adapter contract so that truly async backends (Postgres, DynamoDB, etc.) can implement the same interface without friction. ### Install ``` pnpm add station-adapter-sqlite ``` **pnpm 10+:** better-sqlite3 is a native addon that compiles C++ during installation. pnpm 10 blocks dependency build scripts by default. Add this to your project’s `package.json` and re-run `pnpm install`: ``` { "pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] } } ``` ### Usage ``` import { SqliteAdapter } from "station-adapter-sqlite"; const adapter = new SqliteAdapter({ dbPath: "./jobs.db", }); ``` ### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `dbPath` | `string` | `"station.db"` | Path to the SQLite database file. Created automatically if it does not exist. | | `tableName` | `string` | `"runs"` | Table name for signal run entries. Must be alphanumeric and underscores only. A companion `{tableName}_steps` table is created for step data. | ### Methods Implements every method from `SignalQueueAdapter` (see below), plus: | Method | Description | | --- | --- | | `close()` | Close the database connection. Call during graceful shutdown to flush the WAL and release the file lock. | | `toManifest()` | Returns a serializable descriptor so child processes can reconstruct this adapter automatically. Part of the `SerializableAdapter` interface. | ### Database schema The adapter creates a `runs` table (or whatever you set in `tableName`) with these columns: | Column | Type | Description | | --- | --- | --- | | `id` | TEXT PK | UUID generated by the adapter | | `signal_name` | TEXT | Name of the signal that owns this run | | `kind` | TEXT | `trigger` or `recurring` | | `input` | TEXT | JSON-serialized input payload | | `output` | TEXT | JSON-serialized output on completion | | `error` | TEXT | Error message on failure | | `status` | TEXT | `pending` | `running` | `completed` | `failed` | `cancelled` | | `attempts` | INTEGER | How many times this run has been attempted | | `max_attempts` | INTEGER | Maximum retry count before marking failed | | `timeout` | INTEGER | Timeout in milliseconds | | `interval` | TEXT | Recurring interval string (e.g. `"every 5m"`). Null for triggered runs. | | `next_run_at` | TEXT | ISO-8601 timestamp of when this run becomes due | | `last_run_at` | TEXT | ISO-8601 timestamp of last execution start | | `started_at` | TEXT | ISO-8601 timestamp when execution began | | `completed_at` | TEXT | ISO-8601 timestamp when execution finished | | `created_at` | TEXT | ISO-8601 timestamp when the run was queued | Three indexes are created automatically: a composite index on `(status, next_run_at)` for the `getRunsDue()` query, a partial index on `status` where `status = 'running'` for the `getRunsRunning()` query, and an index on `signal_name` for `listRuns()` queries. A companion `{tableName}_steps` table stores step execution records, linked by a foreign key with `ON DELETE CASCADE`. * * * ## BroadcastSqliteAdapter Persistent storage for broadcast runs and their individual node runs. Ships in the same package as `SqliteAdapter` — import it from the `/broadcast` subpath. ### Install ``` pnpm add station-adapter-sqlite ``` ### Usage ``` import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; const broadcastAdapter = new BroadcastSqliteAdapter({ dbPath: "./jobs.db", }); ``` You can (and should) point both adapters at the same database file. They use separate tables: `runs` for signals and `broadcast_runs` / `broadcast_runs_nodes` for broadcasts. Sharing a file avoids managing multiple SQLite databases. ### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `dbPath` | `string` | `"station.db"` | Path to the SQLite database file. | | `tableName` | `string` | `"broadcast_runs"` | Table name for broadcast run entries. A companion `{tableName}_nodes` table is created for node runs. | ### Methods Implements every method from `BroadcastQueueAdapter` (see below), plus `close()` for graceful shutdown. * * * ## Sharing adapters across processes When the runner spawns a child process to execute a signal handler, that child process needs to know which adapter to use. If the handler calls `.trigger()` on another signal, the trigger must write to the same database. There are two ways to solve this. ### Automatic: SerializableAdapter `SqliteAdapter` implements the `SerializableAdapter` interface. When the runner detects a serializable adapter, it passes a compact manifest (adapter name + constructor options) to the child process, which reconstructs an identical adapter instance automatically. No extra configuration is needed. ``` import path from "node:path"; import { SignalRunner } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; // SqliteAdapter is serializable — child processes // reconstruct it from the manifest automatically. const runner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), }); ``` ### Manual: configModule For adapters that are not serializable, or when you need to run additional setup code in the child process, use the `configModule` option. Create a module that calls `configure()`, and point the runner at it: ``` // config.ts import { configure } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; configure({ adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), }); ``` ``` // runner.ts import path from "node:path"; import { SignalRunner } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; const runner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), configModule: path.join(import.meta.dirname, "config.ts"), }); ``` The runner imports the `configModule` before executing each signal handler in the child process. This sets the global adapter so that `.trigger()` calls inside handlers write to the correct database. If your triggers always happen in the runner process (e.g. recurring signals, or signals triggered from an API route in the same process), you do not need `configModule`. The runner’s adapter is already available. * * * ## Writing a custom adapter ### SignalQueueAdapter Implement this interface to create a custom storage backend for signals. Every method is async to support both synchronous and network-based backends. ``` interface SignalQueueAdapter { addRun(run: Run): Promise; removeRun(id: string): Promise; getRun(id: string): Promise; getRunsDue(): Promise; getRunsRunning(): Promise; updateRun(id: string, patch: RunPatch): Promise; listRuns(signalName: string): Promise; hasRunWithStatus(signalName: string, statuses: RunStatus[]): Promise; purgeRuns(olderThan: Date, statuses: RunStatus[]): Promise; addStep(step: Step): Promise; updateStep(id: string, patch: StepPatch): Promise; getSteps(runId: string): Promise; removeSteps(runId: string): Promise; generateId(): string; ping(): Promise; close?(): Promise; } ``` | Method | Contract | | --- | --- | | `addRun(run)` | Store a new run. The run arrives with `status: "pending"` and a `nextRunAt` timestamp indicating when it becomes due. | | `removeRun(id)` | Delete a run and its associated steps. Called for completed non-recurring runs during cleanup. | | `getRun(id)` | Retrieve a single run by ID. Return `null` if it does not exist. | | `getRunsDue()` | Return all runs where `status === "pending"` and `nextRunAt <= now` (or `nextRunAt` is null). The runner calls this on every poll tick. Order by `createdAt` ascending. | | `getRunsRunning()` | Return all runs with `status === "running"`. Used by the runner for timeout detection. | | `updateRun(id, patch)` | Partially update a run’s fields. Used to change status, increment attempts, set timestamps, and store output or error messages. | | `listRuns(signalName)` | Return all runs for a given signal name. Used by Station and for concurrency checks. | | `hasRunWithStatus(signalName, statuses)` | Return `true` if any run for the given signal has one of the specified statuses. Used for concurrency gating (e.g. preventing duplicate recurring runs). | | `purgeRuns(olderThan, statuses)` | Delete runs in terminal statuses whose `completedAt` is older than the given date. Return the count deleted. | | `addStep(step)` | Store a new step record. Steps belong to a run and track individual step execution within multi-step signals. | | `updateStep(id, patch)` | Partially update a step’s fields — status, output, error, timestamps. | | `getSteps(runId)` | Return all steps for a given run. | | `removeSteps(runId)` | Delete all steps for a given run. | | `generateId()` | Return a unique ID string for new runs and steps. UUID, nanoid, ULID, or any scheme that produces unique strings. | | `ping()` | Health check. Return `true` if the adapter is operational. Called during runner startup and by Station’s health endpoint. | | `close()` | Optional. Clean up resources (close database connections, flush buffers). Called during graceful shutdown. | ### BroadcastQueueAdapter Implement this interface for custom broadcast storage. Broadcast adapters track two entity types: broadcast runs (the overall execution) and node runs (one per DAG node per execution). ``` interface BroadcastQueueAdapter { addBroadcastRun(run: BroadcastRun): Promise; getBroadcastRun(id: string): Promise; updateBroadcastRun(id: string, patch: BroadcastRunPatch): Promise; getBroadcastRunsDue(): Promise; getBroadcastRunsRunning(): Promise; listBroadcastRuns(broadcastName: string): Promise; hasBroadcastRunWithStatus( broadcastName: string, statuses: BroadcastRunStatus[], ): Promise; purgeBroadcastRuns( olderThan: Date, statuses: BroadcastRunStatus[], ): Promise; addNodeRun(nodeRun: BroadcastNodeRun): Promise; getNodeRun(id: string): Promise; updateNodeRun(id: string, patch: BroadcastNodeRunPatch): Promise; getNodeRuns(broadcastRunId: string): Promise; generateId(): string; ping(): Promise; close?(): Promise; } ``` | Method | Contract | | --- | --- | | `addBroadcastRun(run)` | Store a new broadcast run with `status: "pending"`. Includes the broadcast name, serialized input, failure policy, and scheduling fields. | | `getBroadcastRun(id)` | Retrieve a broadcast run by ID. Return `null` if not found. | | `updateBroadcastRun(id, patch)` | Partially update broadcast run fields — status, timestamps, error. | | `getBroadcastRunsDue()` | Return pending broadcast runs where `nextRunAt <= now`. Polled on each broadcast runner tick. | | `getBroadcastRunsRunning()` | Return all broadcast runs with `status === "running"`. Used for timeout detection. | | `listBroadcastRuns(broadcastName)` | Return all runs for a given broadcast name. | | `hasBroadcastRunWithStatus(name, statuses)` | Return `true` if any run for the broadcast has one of the specified statuses. | | `purgeBroadcastRuns(olderThan, statuses)` | Delete broadcast runs (and their node runs via cascade) older than the given date. Return count deleted. | | `addNodeRun(nodeRun)` | Store a node run. Each node in the DAG gets one record per broadcast execution. Includes the node name, linked signal name, and initial status. | | `getNodeRun(id)` | Retrieve a single node run by ID. Return `null` if not found. | | `updateNodeRun(id, patch)` | Partially update a node run — status, signal run ID, output, error, skip reason, timestamps. | | `getNodeRuns(broadcastRunId)` | Return all node runs for a given broadcast run. | | `generateId()` | Return a unique ID string for new broadcast and node runs. | | `ping()` | Health check. Return `true` if operational. | | `close()` | Optional. Clean up resources during graceful shutdown. | * * * ## SerializableAdapter If you write a custom adapter that needs to work across processes (child process execution of signal handlers), implement the `SerializableAdapter` interface in addition to `SignalQueueAdapter`: ``` interface SerializableAdapter extends SignalQueueAdapter { toManifest(): AdapterManifest; } interface AdapterManifest { name: string; // Registry key (e.g. "sqlite") options: Record; // Constructor options (must be JSON-serializable) moduleUrl?: string; // Absolute URL to the module that registers this adapter } ``` Register your adapter with a factory function so the child process can reconstruct it from the manifest: ``` import { registerAdapter } from "station-signal"; registerAdapter("my-adapter", (options) => { return new MyAdapter(options as MyAdapterOptions); }); ``` When the runner detects a `SerializableAdapter`, it skips the `configModule` path entirely — the manifest is passed to the child process as a lightweight JSON payload, and the adapter is reconstructed from the registered factory. * * * ## Schedule adapters Runtime [schedules](https://station.dterminal.net/docs/schedules.md) are stored through a separate `ScheduleAdapter` interface, not the signal or broadcast queue adapter. Each backend ships its implementation in a sub-path of the corresponding adapter package, so you import only what you need: | Import | Backend | | --- | --- | | `station-adapter-sqlite/schedules` | SQLite (better-sqlite3, single-writer DB serialises claims). | | `station-adapter-postgres/schedules` | Postgres (`UPDATE … RETURNING id` for atomic claims). | | `station-adapter-mysql/schedules` | MySQL (`UPDATE … WHERE …`; `affectedRows` decides the winner). | | `station-adapter-redis/schedules` | Redis (Lua `EVAL` compares `ZSCORE` and updates atomically). | All four implement `claimDue`, which is what makes schedules safe across multiple runner processes. The in-memory adapter shipped with `station-schedules` is single-process only and is meant for tests. * * * ## Env adapters [Environment variables](https://station.dterminal.net/docs/environment.md) are stored through an `EnvStorageAdapter`, again in a sub-path of each adapter package. The default (a JSON file) needs no adapter at all; reach for a durable backend when you run more than one Station process against shared state: | Import | Backend | | --- | --- | | `station-adapter-sqlite/env` | SQLite (`EnvSqliteAdapter`). | | `station-adapter-postgres/env` | Postgres (`EnvPostgresAdapter`). | | `station-adapter-mysql/env` | MySQL (`EnvMysqlAdapter.create()`, async factory). | | `station-adapter-redis/env` | Redis (`EnvRedisAdapter`). | Pass one via `envStorage` in `defineConfig`. The default `FileEnvStorage` (and `MemoryEnvStorage` for tests) ship in `station-env` itself. * * * ## Dynamic broadcast methods [Dynamic broadcasts](https://station.dterminal.net/docs/dynamic-broadcasts.md) live on the same `BroadcastQueueAdapter` as broadcast runs, via five optional methods: ``` interface BroadcastQueueAdapter { // ...existing run/node methods... saveDefinition?(spec: DynamicBroadcastSpec): Promise; getDefinition?(name: string, version?: number): Promise; listDefinitions?(): Promise; listDefinitionVersions?(name: string): Promise; deleteDefinition?(name: string): Promise; } ``` All four backends — `station-adapter-sqlite`, `station-adapter-postgres`, `station-adapter-mysql` and `station-adapter-redis` — implement these on their broadcast adapter. Custom adapters that omit them stay compatible: the rest of the broadcast surface keeps working, and the dynamic-broadcast endpoints respond `501 Not Implemented`. --- # Station > Canonical HTML: [https://station.dterminal.net/docs/station](https://station.dterminal.net/docs/station) `station-daemon` runs Station without a dashboard process. It composes signal, broadcast and beacon runners, storage, authentication, the Hono API and real-time events. Its executable is `stationd`. `station-runtime-cli` supplies the `station` client; `station-dashboard` supplies the independently started web UI. Both can connect to a local or remote daemon. Stopping a client does not stop the daemon or its work. Station 3.0 removes `station-kit` without compatibility exports. Replace configuration imports with `station-daemon` and start the dashboard separately. Existing published 2.x packages remain available; this change does not delete persisted data. * * * ## Install ``` pnpm add station-daemon ``` * * * ## Configuration Create a `station.config.ts` (or `.js` / `.mjs`) in your project root: ``` import { defineConfig } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; export default defineConfig({ port: 4400, signalsDir: "./signals", broadcastsDir: "./broadcasts", adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), broadcastAdapter: new BroadcastSqliteAdapter({ dbPath: "./jobs.db" }), }); ``` The `defineConfig` helper provides type checking and autocompletion. It is a pass-through function — it returns the object unchanged. The default role is `standalone`. To run a multi-machine control plane, configure `role: "headquarters"` or `role: "station"` plus a shared `network.adapter`. See [Station Networks](https://station.dterminal.net/docs/network.md). ### Config options | Option | Type | Default | Description | | --- | --- | --- | --- | | `role` | `"standalone" | "headquarters" | "station"` | `"standalone"` | Selects the combined, control-plane, or execution-plane runtime. | | `network` | `StationNetworkConfig` | `{ id: "default", ... }` | Fleet id, stable station id/name, durable adapter, placement labels, public endpoint, heartbeat cadence, and lease duration. | | `port` | `number` | `4400` | HTTP port for the daemon API. | | `host` | `string` | `"localhost"` | Hostname to bind the daemon API to. Set to `"0.0.0.0"` to listen on all interfaces. | | `signalsDir` | `string` | — | Directory containing signal definition files. Station imports these to display signal metadata: input/output schemas, timeouts, retry counts, intervals, and concurrency settings. Falls back to a `signals/` directory in the working directory if one exists. | | `broadcastsDir` | `string` | — | Directory containing broadcast definition files. Station imports these to display DAG structure, failure policies, and node dependencies. Falls back to a `broadcasts/` directory in the working directory if one exists. | | `adapter` | `SignalQueueAdapter` | `MemoryAdapter` | Signal storage adapter. Must point to the same database as your runner to see its data. See [Adapters](https://station.dterminal.net/docs/adapters.md). | | `broadcastAdapter` | `BroadcastQueueAdapter` | — | Broadcast storage adapter. Required for broadcast monitoring features. If omitted and `broadcastsDir` is set, a memory adapter is used. | | `subscribers` | `{ signal?, broadcast?, beacon? }` | — | Your own lifecycle subscribers for metrics, alerting, or audit logging. Registered *in addition to* Station’s own, which always run first. Errors thrown from a subscriber are caught and logged, never propagated into a run. Requires `runRunners: true`. | | `runRunners` | `boolean` | `true` | When `true`, Station runs its own SignalRunner and BroadcastRunner internally. Set to `false` for read-only monitoring of an existing runner’s database. | | `logLevel` | `"debug" | "info" | "warn" | "error"` | `"info"` | Controls Station’s own console output verbosity. | | `auth` | `{ username, password, sessionTtlMs? }` | — | Dashboard login credentials. When set, the dashboard presents a login screen. `sessionTtlMs` controls session expiry (default: 86,400,000 ms / 24 hours). Omit to disable auth. | | `runner` | `Partial` | — | Override signal runner settings. Only applies when `runRunners: true`. Fields: | ### Runner config (nested under `runner`) | Option | Type | Default | Description | | --- | --- | --- | --- | | `pollIntervalMs` | `number` | `1000` | Milliseconds between poll ticks for due runs. | | `maxConcurrent` | `number` | `5` | Maximum number of signal runs executing simultaneously. | | `maxAttempts` | `number` | `1` | Default maximum retry attempts for signals that do not specify their own. | | `retryBackoffMs` | `number` | `1000` | Base delay in milliseconds between retry attempts. | ### Broadcast runner config (nested under `broadcastRunner`) | Option | Type | Default | Description | | --- | --- | --- | --- | | `pollIntervalMs` | `number` | `1000` | Milliseconds between poll ticks for due broadcast runs. | * * * ## Configuring auth storage Station's API key store is pluggable. The default backend is SQLite, but any storage that implements the `ApiKeyStorageAdapter` interface (Postgres, MySQL, Redis, an in-memory store for tests, …) can be wired in via `auth.keyStorage` on `defineConfig`. ``` import { defineConfig, SqliteKeyStorage } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; export default defineConfig({ adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), auth: { keyStorage: new SqliteKeyStorage({ dbPath: "./station-keys.db" }), }, }); ``` Without an explicit `keyStorage`, Station constructs a `SqliteKeyStorage` at a default path. For production deployments using Postgres or another backend, implement the adapter and pass an instance: ``` // keys-postgres.ts import type { ApiKeyStorageAdapter, ApiKey, ApiKeyPublic } from "station-daemon"; export class PostgresKeyStorage implements ApiKeyStorageAdapter { constructor(private pool: Pool) {} async insert(record: ApiKey): Promise { await this.pool.query("INSERT INTO api_keys (...) VALUES (...)", [...]); } async findByHash(keyHash: string): Promise { const { rows } = await this.pool.query( "SELECT * FROM api_keys WHERE key_hash = $1", [keyHash], ); return rows[0] ? rowToApiKey(rows[0]) : null; } async list(): Promise { /* ... */ } async touch(id: string, lastUsedIso: string): Promise { /* ... */ } async revoke(id: string): Promise { /* ... */ } async close(): Promise { await this.pool.end(); } } ``` ``` // station.config.ts import { defineConfig } from "station-daemon"; import { PostgresKeyStorage } from "./keys-postgres.js"; export default defineConfig({ auth: { keyStorage: new PostgresKeyStorage(pool), }, }); ``` ### The interface ``` interface ApiKeyStorageAdapter { insert(record: ApiKey): Promise | void; findByHash(keyHash: string): Promise | ApiKey | null; list(): Promise | ApiKeyPublic[]; touch(id: string, lastUsedIso: string): Promise | void; revoke(id: string): Promise | boolean; close?(): Promise | void; } ``` Adapters can be sync or async — the `KeyStore` awaits all results either way. `MemoryKeyStorage` ships in `station-daemon` for tests and ephemeral deployments. **Breaking change:** `KeyStore` methods are now async. Any code that calls `keyStore.create()`, `keyStore.verify()`, `keyStore.list()`, or `keyStore.revoke()` directly must `await` them. Calls inside Station's own request handlers were updated as part of the same change; integrations and scripts that touch `KeyStore` directly need a one-line fix. * * * ## Running Station ``` pnpm exec stationd ``` Station looks for `station.config.ts` (or `.js` / `.mjs`) in the current working directory. If no config file is found, it starts with default settings (MemoryAdapter, no signal directory). ### Active mode vs. read-only mode Station operates in one of two modes depending on the `runRunners` setting. **Active mode** (`runRunners: true`, default) Station creates its own SignalRunner and BroadcastRunner. It discovers signals from `signalsDir`, polls the adapter for due runs, and executes them. Use this when you want Station to be your only runner process. The dashboard provides full functionality: monitoring, triggering signals, and cancelling runs. **Read-only mode** (`runRunners: false`) Station only reads from the adapter. It does not create runners, does not execute signals, and does not poll for due runs. Use this when you have a separate runner process and want Station purely for monitoring. Trigger and cancel endpoints return `403` in this mode. In read-only mode, Station still needs `signalsDir` and `broadcastsDir` to import signal/broadcast definitions for metadata display (schemas, intervals, DAG structure). Without these directories, the dashboard shows run data but not signal configuration details. * * * ## Dashboard features ### Signals list View all registered signals with their name, input/output schemas (rendered from Zod definitions), timeout, retry count, recurring interval, concurrency settings, step names, and source file path. ### Scheduled signals Recurring signals get a dedicated view showing the interval, next scheduled run time, last execution time, and last execution status. ### Run history Browse all past and current signal runs. Filter by status (pending, running, completed, failed, cancelled) or by signal name. Each run shows the full detail: input data, output data, error messages, timing (created, started, completed), attempt count, and step execution records. ### Run logs View stdout and stderr output captured from signal handler execution. Logs are stored in an in-memory buffer and persisted to a separate SQLite database (`station-logs.db`) for survival across restarts. ### Broadcast visualization See the DAG structure of registered broadcasts — which nodes exist, their signal mappings, and dependency edges. During execution, node statuses (pending, running, completed, failed, skipped) update in real time. Includes skip reasons when nodes are bypassed due to guard conditions, upstream failures, or cancellation. ### Real-time updates A WebSocket connection on `/api/events` pushes lifecycle events as they happen. The frontend subscribes automatically — no polling required. Events cover the full signal and broadcast lifecycle (see WebSocket events table below). ### Actions In active mode, the dashboard allows triggering signals with custom input and cancelling in-progress runs or broadcast runs. * * * ## API endpoints Station exposes a REST API on the configured port. All responses use the shape `{ data: ... }` on success or `{ error: string, message: string }` on failure. ### Health | Endpoint | Method | Description | | --- | --- | --- | | `/api/health` | GET | Health check. Calls `ping()` on the signal adapter and broadcast adapter (if configured). Returns `{ ok, signal, broadcast }`. | ### Signals | Endpoint | Method | Description | | --- | --- | --- | | `/api/signals` | GET | List all registered signals with metadata — name, file path, input/output schemas, interval, timeout, max attempts, max concurrency, step names. | | `/api/signals/scheduled` | GET | List recurring signals with their interval, next scheduled run, last run time, and last run status. | | `/api/signals/:name` | GET | Get details for a specific signal. Returns 404 if not found. | | `/api/signals/:name/trigger` | POST | Trigger a signal with optional input. Body: `{ "input": { ... } }`. Returns the new run ID. Returns 403 in read-only mode, 404 if signal not found. | | `/api/signals/:name/runs` | GET | List all runs for a specific signal. | ### Runs | Endpoint | Method | Description | | --- | --- | --- | | `/api/runs` | GET | List runs. Query params: `?status=pending|running|completed|failed|cancelled`, `?signalName=name`. Sorted by `createdAt` descending. | | `/api/runs/stats` | GET | Aggregate run counts by status. Returns `{ pending, running, completed, failed, cancelled }`. | | `/api/runs/:id` | GET | Get a single run’s full details including input, output, error, timing, and attempts. Returns 404 if not found. | | `/api/runs/:id/steps` | GET | Get step execution records for a run. Each step includes name, status, input, output, error, and timestamps. | | `/api/runs/:id/logs` | GET | Get captured stdout/stderr log lines for a run. | | `/api/runs/:id/cancel` | POST | Cancel a run. Returns 403 in read-only mode, 400 if the run cannot be cancelled. | ### Broadcasts | Endpoint | Method | Description | | --- | --- | --- | | `/api/broadcasts` | GET | List all registered broadcasts with DAG structure — node names, signal mappings, dependency edges, failure policy. | | `/api/broadcasts/:name` | GET | Get a single broadcast’s metadata and DAG structure. Returns 404 if not found. | | `/api/broadcasts/:name/trigger` | POST | Trigger a broadcast with optional input. Body: `{ "input": { ... } }`. Returns the new broadcast run ID. Returns 403 in read-only mode. | | `/api/broadcasts/:name/runs` | GET | List all runs for a specific broadcast. | | `/api/broadcast-runs/:id` | GET | Get a broadcast run’s full details. Returns 404 if not found. | | `/api/broadcast-runs/:id/nodes` | GET | Get all node runs for a broadcast execution. Each node includes name, signal name, signal run ID, status, skip reason, input, output, error, and timestamps. | | `/api/broadcast-runs/:id/logs` | GET | Get aggregated logs from all node signal runs in a broadcast execution. Sorted by timestamp. | | `/api/broadcast-runs/:id/cancel` | POST | Cancel a broadcast run. Returns 403 in read-only mode, 400 if it cannot be cancelled. | * * * ## WebSocket events Connect to `/api/events` on the API server port. Each message is a JSON object with `type`, `timestamp`, and `data` fields. ### Signal events | Event type | Description | | --- | --- | | `signal:discovered` | A signal file was found during directory scanning. | | `run:dispatched` | A run was picked up from the queue and dispatched for execution. | | `run:started` | A run’s handler began executing in the child process. | | `run:completed` | A run finished successfully. Includes output data. | | `run:failed` | A run failed after exhausting all retry attempts. Includes error message. | | `run:timeout` | A run exceeded its timeout and was killed. | | `run:retry` | A run failed but has remaining attempts. Includes current attempt and max attempts. | | `run:cancelled` | A run was cancelled via the API or programmatically. | | `run:skipped` | A recurring run was skipped (e.g. previous run still active). | | `run:rescheduled` | A recurring run was rescheduled. Includes the next run time. | | `step:started` | A step within a multi-step signal began executing. | | `step:completed` | A step finished successfully. | | `step:failed` | A step failed. | | `log:output` | A line of stdout or stderr was captured from a running signal handler. Includes run ID, signal name, level, and message. | | `run:completeError` | An error occurred while trying to mark a run as complete (e.g. adapter failure during finalization). | ### Broadcast events | Event type | Description | | --- | --- | | `broadcast:discovered` | A broadcast file was found during directory scanning. | | `broadcast:queued` | A broadcast run was added to the queue. | | `broadcast:started` | A broadcast began executing its DAG. | | `broadcast:completed` | All nodes in the broadcast finished successfully. | | `broadcast:failed` | The broadcast failed. Includes error message. | | `broadcast:cancelled` | The broadcast was cancelled. | | `node:triggered` | A DAG node’s signal was triggered for execution. | | `node:completed` | A DAG node’s signal completed successfully. | | `node:failed` | A DAG node’s signal failed. Includes error message. | | `node:skipped` | A DAG node was skipped. Includes the reason: `guard` (guard function returned false), `upstream-failed` (a dependency failed), or `cancelled`. | * * * ## Using Station with an existing runner By default `stationd` executes signals and serves the API. The dashboard is a separate client of that API. If you already have a process executing signals — Station embedded in an app server you own, say — you can point a second, read-only Station at the same database purely for monitoring. Both processes share one SQLite file; only the first one runs jobs. ``` // runner.ts — an existing process that executes signals import path from "node:path"; import { SignalRunner } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; const runner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), }); runner.start(); ``` ``` // station.config.ts — read-only monitoring import { defineConfig } from "station-daemon"; import { SqliteAdapter } from "station-adapter-sqlite"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; export default defineConfig({ port: 4400, signalsDir: "./signals", broadcastsDir: "./broadcasts", adapter: new SqliteAdapter({ dbPath: "./jobs.db" }), broadcastAdapter: new BroadcastSqliteAdapter({ dbPath: "./jobs.db" }), runRunners: false, // Don't execute signals — just monitor }); ``` SQLite with WAL mode supports concurrent readers and a single writer. The runner process writes; Station reads. Both can open the same database file simultaneously without conflict. * * * ## Graceful shutdown Station listens for `SIGINT` and `SIGTERM`. On shutdown, it stops the broadcast runner first (it may query the database during cleanup), then the signal runner, then closes the WebSocket server, log store, and HTTP server. Both runners are given a 5-second grace period to finish in-flight work. --- # Examples > Canonical HTML: [https://station.dterminal.net/docs/examples](https://station.dterminal.net/docs/examples) Guided examples from local browser workers and a single Node signal to a multi-process Station Network. Each links concepts to working source in the repository. [ Browser lab — Experimental Run signals, DAGs, and beacons on the device with IndexedDB recovery and worker execution. browserworkersIndexedDB ](https://station.dterminal.net/docs/examples/browser.md)[ 01 — Basic The simplest signal. Define it, trigger it, done. signaltrigger ](https://station.dterminal.net/docs/examples/basic.md)[ 02 — With Output Signals that return typed values and react to completion. signaloutputonComplete ](https://station.dterminal.net/docs/examples/with-output.md)[ 03 — With Steps Multi-step signals where each step's output pipes to the next. signalstepssubscriber ](https://station.dterminal.net/docs/examples/with-steps.md)[ 04 — Recurring Signals that fire on a schedule without manual triggers. signalrecurring ](https://station.dterminal.net/docs/examples/recurring.md)[ 05 — With Retries Automatic retry behavior for flaky operations. signalretriestimeout ](https://station.dterminal.net/docs/examples/with-retries.md)[ 06 — With SQLite Persistent storage with separate trigger and runner processes. signalsqliteconfigure ](https://station.dterminal.net/docs/examples/with-sqlite.md)[ 07 — Broadcast DAG workflow orchestration with fan-out and conditional execution. broadcastfan-outwhen ](https://station.dterminal.net/docs/examples/broadcast.md)[ 08 — ETL Pipeline Extract-transform-load with multi-step signals in a linear broadcast. broadcaststepsretries ](https://station.dterminal.net/docs/examples/etl-pipeline.md)[ 09 — CI Pipeline Fan-out, branch guards, result fallback. The most complex DAG. broadcastfan-outguardsqlite ](https://station.dterminal.net/docs/examples/ci-pipeline.md)[ 10 — Fleet Monitor Six parallel health checks converging into an aggregate report. broadcastrecurringcontinuesqlite ](https://station.dterminal.net/docs/examples/fleet-monitor.md)[ 11 — Beacon A supervised server, poller, and reconnecting client with restart + backoff. beaconserverpollbackoff ](https://station.dterminal.net/docs/examples/beacon.md)[ 12 — Station Network One Headquarters and two workers with fleet limits, placement, schedules, and draining. networkheadquartersscalingsqlite ](https://station.dterminal.net/docs/examples/station-network.md) --- # Browser lab > Canonical HTML: [https://station.dterminal.net/docs/examples/browser](https://station.dterminal.net/docs/examples/browser) `examples/17-browser` runs all three Station primitives locally in Web Workers and service workers. The static file server does not run the jobs. Start here before adapting the [browser runtime guide](https://station.dterminal.net/docs/browser.md) to your own application. ``` pnpm install pnpm dev:browser # Open http://127.0.0.1:4317 ``` ## Exercise the execution contract 1. Queue a local report in Web Worker mode. After a step is saved, interrupt the worker and resume. Its seven-second lease must expire before a new attempt reuses the saved steps. 2. Try a temporary failure and reload the page. Observe persisted checkpoints and the attempt count. 3. Run text analysis: word and character counts fan out, join into a report, and conditionally highlight longer text. Simulate a branch failure to see dependent nodes skipped. 4. Start pulse and recovering client. Inspect heartbeat, incarnation, desired state, and logs. The recovering client deliberately fails on its first incarnation. 5. Switch to service-worker mode. Signals and DAGs run on wakes; beacons run bounded slices and suspend between them. Stop a beacon and reload to verify it stays stopped. 6. After the offline shell is cached, stop the static server and reload. Local demo work can execute offline; application handlers that fetch remote data still need connectivity. ## Run browser checks Stop manually started demo beacons, then use the Run browser checks link to navigate to `/tests.html`. Close other demo tabs so their executors do not claim integration-test work. Checks use real IndexedDB, worker termination, service-worker execution, ownership fencing, DAG recovery, and beacon lifecycle behavior. The suite also runs in isolated headless Chromium through `pnpm test`. Run `pnpm test:browser:install` once before local testing; the release command installs it automatically. These checks do not certify all browsers or execution after browser exit. ## Files to adapt | File | Purpose | | --- | --- | | src/signals.ts, src/workloads.ts | Shared definitions and registry. | | src/worker.ts | Independent job drains and beacon ticks. | | src/sw.ts | Bounded wake handling, optional Background Sync, and demo-only shell caching. | | src/app.ts | Enqueueing, mode controls, and persisted activity views. | | build.mjs, serve.mjs | Browser bundles and a localhost static server. No hot reload; rebuild after source changes. | [Browse the source](https://github.com/porkytheblack/station/tree/main/examples/17-browser). The lab is experimental: it promises recoverable local state, not continuous polling after a PWA closes. Read the guide's configuration guidance and storage/versioning limits before using custom definitions. --- # Basic > Canonical HTML: [https://station.dterminal.net/docs/examples/basic](https://station.dterminal.net/docs/examples/basic) The simplest signal. Define it, trigger it, done. ### signals/greet.ts ``` import { signal, z } from "station-signal"; export const greet = signal("greet") .input(z.object({ name: z.string() })) .every("5s") .run(async (input) => { console.log(`Hello, ${input.name}!`); }); ``` ### runner.ts ``` import path from "node:path"; import { SignalRunner } from "station-signal"; import { greet } from "./signals/greet.js"; const runner = SignalRunner.create(path.join(import.meta.dirname, "signals")); setTimeout(async () => { const id = await greet.trigger({ name: "World" }); console.log(`[trigger] Enqueued run: ${id}`); }, 500); await runner.start(); ``` `signal()` creates a named job. `.input()` sets a Zod schema for validation. `.every("5s")` makes it recurring. `.run()` defines the handler. `SignalRunner.create()` is a shorthand that auto-discovers all signals exported from files in a directory. **Run it:** `pnpm --filter example-basic start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # With Output > Canonical HTML: [https://station.dterminal.net/docs/examples/with-output](https://station.dterminal.net/docs/examples/with-output) Signals that return typed values and react to completion. ### signals/add.ts ``` import { signal, z } from "station-signal"; export const add = signal("add") .input(z.object({ a: z.number(), b: z.number() })) .output(z.number()) .run(async (input) => { const sum = input.a + input.b; console.log(`${input.a} + ${input.b} = ${sum}`); return sum; }) .onComplete(async (output, input) => { console.log(`[onComplete] add(${input.a}, ${input.b}) returned ${output}`); }); ``` `.output()` validates the return value against a Zod schema. `.onComplete()` fires after successful execution with the output and original input. **Run it:** `pnpm --filter example-with-output start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # With Steps > Canonical HTML: [https://station.dterminal.net/docs/examples/with-steps](https://station.dterminal.net/docs/examples/with-steps) Multi-step signals where each step's output pipes to the next. ### signals/process-order.ts ``` import { signal, z } from "station-signal"; export const processOrder = signal("processOrder") .input(z.object({ orderId: z.string(), amount: z.number() })) .timeout(30_000) .step("validate", async (input) => { console.log(`[validate] Checking order ${input.orderId}...`); if (input.amount <= 0) throw new Error("Invalid amount"); return { orderId: input.orderId, amount: input.amount, validated: true }; }) .step("charge", async (prev) => { console.log(`[charge] Charging $${prev.amount} for order ${prev.orderId}...`); await new Promise((r) => setTimeout(r, 500)); const chargeId = `ch_${Math.random().toString(36).slice(2, 10)}`; return { orderId: prev.orderId, chargeId }; }) .step("fulfill", async (prev) => { console.log(`[fulfill] Fulfilling order ${prev.orderId} (charge: ${prev.chargeId})...`); await new Promise((r) => setTimeout(r, 300)); return { orderId: prev.orderId, status: "fulfilled", chargeId: prev.chargeId }; }) .build(); ``` ### runner.ts (relevant parts) ``` const runner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), subscribers: [ new ConsoleSubscriber(), { onStepCompleted({ run, step }) { console.log(` step "${step.name}" done (run ${run.id})`); }, }, ], }); ``` `.step()` chains sequential operations. Each step receives the previous step's return value. Use `.build()` instead of `.run()` when using steps. The `onStepCompleted` subscriber hook fires after each step finishes. **Run it:** `pnpm --filter example-with-steps start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # Recurring > Canonical HTML: [https://station.dterminal.net/docs/examples/recurring](https://station.dterminal.net/docs/examples/recurring) Signals that fire on a schedule without manual triggers. ### signals/heartbeat.ts ``` import { signal } from "station-signal"; export const heartbeat = signal("heartbeat") .every("5s") .run(async () => { console.log(`[heartbeat] ping at ${new Date().toISOString()}`); }); ``` No input schema needed for recurring signals. `.every("5s")` schedules the signal to run every 5 seconds. The runner handles re-enqueuing after each execution. **Run it:** `pnpm --filter example-recurring start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # With Retries > Canonical HTML: [https://station.dterminal.net/docs/examples/with-retries](https://station.dterminal.net/docs/examples/with-retries) Automatic retry behavior for flaky operations. ### signals/flaky-task.ts ``` import { signal, z } from "station-signal"; export const flakyTask = signal("flakyTask") .input(z.object({ message: z.string() })) .timeout(3_000) .retries(3) .run(async (input) => { const shouldFail = Math.random() < 0.6; if (shouldFail) { console.log(`[flakyTask] "${input.message}" — failed! (will retry)`); throw new Error("Random failure"); } console.log(`[flakyTask] "${input.message}" — success!`); }); ``` `.retries(3)` means 4 total attempts (1 initial + 3 retries). `.timeout(3_000)` kills the handler after 3 seconds. With a 60% failure rate, the signal almost always succeeds within 4 attempts. **Run it:** `pnpm --filter example-with-retries start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # With SQLite > Canonical HTML: [https://station.dterminal.net/docs/examples/with-sqlite](https://station.dterminal.net/docs/examples/with-sqlite) Persistent storage with separate trigger and runner processes. The pattern used by web applications: the API server enqueues jobs, a background worker processes them. ### signals/send-email.ts ``` import { signal, z } from "station-signal"; export const sendEmail = signal("sendEmail") .input(z.object({ to: z.string(), subject: z.string(), body: z.string() })) .timeout(10_000) .step("validate", async (input) => { console.log(`[validate] Checking email to ${input.to}...`); if (!input.to.includes("@")) throw new Error("Invalid email address"); return input; }) .step("send", async (email) => { console.log(`[send] Sending "${email.subject}" to ${email.to}...`); await new Promise((r) => setTimeout(r, 500)); const messageId = `msg_${Math.random().toString(36).slice(2, 10)}`; console.log(`[send] Sent! Message ID: ${messageId}`); return { messageId }; }) .build(); ``` ### runner.ts ``` import path from "node:path"; import { SignalRunner, ConsoleSubscriber } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; const DB_PATH = path.join(import.meta.dirname, "jobs.db"); const runner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: DB_PATH }), subscribers: [new ConsoleSubscriber()], }); await runner.start(); ``` ### trigger.ts ``` import path from "node:path"; import { configure } from "station-signal"; import { SqliteAdapter } from "station-adapter-sqlite"; import { sendEmail } from "./signals/send-email.js"; const DB_PATH = path.join(import.meta.dirname, "jobs.db"); configure({ adapter: new SqliteAdapter({ dbPath: DB_PATH }) }); const id = await sendEmail.trigger({ to: "alice@example.com", subject: "Hello from station-signal", body: "This run was persisted to SQLite.", }); console.log(`Run triggered: ${id}`); ``` `SqliteAdapter` persists runs to disk. `configure()` sets a global adapter so triggers from other processes write to the same database. Run the runner in one terminal, then trigger from another. The runner picks up the persisted job and executes it. **Run it:** `pnpm --filter example-with-sqlite start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # Broadcast > Canonical HTML: [https://station.dterminal.net/docs/examples/broadcast](https://station.dterminal.net/docs/examples/broadcast) DAG workflow orchestration. Chain signals into a dependency graph with fan-out and conditional execution. ### signals/validate-order.ts ``` import { signal, z } from "station-signal"; export const validateOrder = signal("validate-order") .input(z.object({ orderId: z.string(), amount: z.number() })) .output(z.object({ orderId: z.string(), amount: z.number(), valid: z.boolean() })) .run(async (input) => { console.log(`Validating order ${input.orderId} ($${input.amount})`); return { orderId: input.orderId, amount: input.amount, valid: input.amount > 0 }; }); ``` ### signals/charge-payment.ts ``` import { signal, z } from "station-signal"; export const chargePayment = signal("charge-payment") .input(z.object({ orderId: z.string(), amount: z.number(), valid: z.boolean() })) .output(z.object({ orderId: z.string(), chargeId: z.string() })) .run(async (input) => { const chargeId = `ch_${Math.random().toString(36).slice(2, 8)}`; console.log(`Charging $${input.amount} for order ${input.orderId}`); return { orderId: input.orderId, chargeId }; }); ``` ### signals/send-receipt.ts ``` import { signal, z } from "station-signal"; export const sendReceipt = signal("send-receipt") .input(z.object({ orderId: z.string(), chargeId: z.string() })) .run(async (input) => { console.log(`Sending receipt for order ${input.orderId} (charge: ${input.chargeId})`); }); ``` ### signals/notify-warehouse.ts ``` import { signal, z } from "station-signal"; export const notifyWarehouse = signal("notify-warehouse") .input(z.object({ orderId: z.string(), chargeId: z.string() })) .run(async (input) => { console.log(`Notifying warehouse for order ${input.orderId}`); }); ``` ### broadcasts/order-pipeline.ts ``` import { broadcast } from "station-broadcast"; import { validateOrder } from "../signals/validate-order.js"; import { chargePayment } from "../signals/charge-payment.js"; import { sendReceipt } from "../signals/send-receipt.js"; import { notifyWarehouse } from "../signals/notify-warehouse.js"; export const orderPipeline = broadcast("order-pipeline") .input(validateOrder) .then(chargePayment, { when: (prev) => (prev["validate-order"] as { valid: boolean }).valid === true, }) .then(sendReceipt, notifyWarehouse) // fan-out: both run in parallel .build(); ``` ### runner.ts ``` import path from "node:path"; import { SignalRunner, ConsoleSubscriber } from "station-signal"; import { BroadcastRunner, ConsoleBroadcastSubscriber } from "station-broadcast"; import { orderPipeline } from "./broadcasts/order-pipeline.js"; const signalRunner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), subscribers: [new ConsoleSubscriber()], }); const broadcastRunner = new BroadcastRunner({ signalRunner, subscribers: [new ConsoleBroadcastSubscriber()], }); broadcastRunner.register(orderPipeline); setTimeout(async () => { const broadcastRunId = await orderPipeline.trigger({ orderId: "ORD-42", amount: 99.99, }); console.log(`\nTriggered broadcast: ${broadcastRunId}\n`); const result = await broadcastRunner.waitForBroadcastRun(broadcastRunId, { timeoutMs: 30_000, }); console.log(`\nBroadcast finished: ${result?.status}\n`); await broadcastRunner.stop(); await signalRunner.stop(); }, 500); signalRunner.start(); broadcastRunner.start(); ``` `broadcast()` creates a DAG. `.input()` sets the entry signal. `.then()` adds downstream nodes. Multiple signals in one `.then()` = fan-out (parallel). `when` is a guard that returns false to skip a node. `BroadcastRunner` orchestrates the DAG. `waitForBroadcastRun` blocks until completion. **Run it:** `pnpm --filter example-broadcast start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # ETL Pipeline > Canonical HTML: [https://station.dterminal.net/docs/examples/etl-pipeline](https://station.dterminal.net/docs/examples/etl-pipeline) Extract-transform-load workflow with multi-step signals. A linear broadcast chain: extract, transform, load, report. Each signal's output becomes the next signal's input. ### broadcasts/etl-pipeline.ts ``` import { broadcast } from "station-broadcast"; import { extractUsers } from "../signals/extract-users.js"; import { transformUsers } from "../signals/transform-users.js"; import { loadUsers } from "../signals/load-users.js"; import { generateReport } from "../signals/generate-report.js"; export const etlPipeline = broadcast("etl-pipeline") .input(extractUsers) .then(transformUsers) .then(loadUsers) .then(generateReport) .timeout(60_000) .build(); ``` ### signals/extract-users.ts ``` import { signal, z } from "station-signal"; export const extractUsers = signal("extract-users") .input(z.object({ source: z.string(), batchSize: z.number() })) .output( z.object({ records: z.array(z.object({ id: z.number(), name: z.string(), email: z.string() })), source: z.string(), }), ) .timeout(15_000) .step("connect", async (input) => { console.log(`[extract] Connecting to ${input.source}...`); await new Promise((r) => setTimeout(r, 400)); return { ...input, connected: true }; }) .step("query", async (prev) => { console.log(`[extract] Querying ${prev.batchSize} records from ${prev.source}...`); await new Promise((r) => setTimeout(r, 800)); const records = Array.from({ length: prev.batchSize }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@${prev.source}`, raw_signup: `2024-0${(i % 9) + 1}-15`, status_code: i % 3 === 0 ? "A" : i % 3 === 1 ? "I" : "P", })); console.log(`[extract] Fetched ${records.length} records.`); return { records, source: prev.source }; }) .step("validate", async (prev) => { console.log(`[extract] Validating ${prev.records.length} records...`); const valid = prev.records.filter((r: { email: string }) => r.email.includes("@")); const dropped = prev.records.length - valid.length; if (dropped > 0) console.log(`[extract] Dropped ${dropped} invalid records.`); return { records: valid.map((r: { id: number; name: string; email: string }) => ({ id: r.id, name: r.name, email: r.email, })), source: prev.source, }; }) .build(); ``` ### signals/load-users.ts ``` import { signal, z } from "station-signal"; const userRecord = z.object({ id: z.number(), name: z.string(), email: z.string() }); export const loadUsers = signal("load-users") .input( z.object({ records: z.array(userRecord), source: z.string(), transformedAt: z.string(), }), ) .output(z.object({ inserted: z.number(), updated: z.number(), source: z.string() })) .timeout(20_000) .retries(2) .step("upsert", async (input) => { console.log(`[load] Upserting ${input.records.length} records into target database...`); await new Promise((r) => setTimeout(r, 600)); if (Math.random() < 0.1) { throw new Error("Connection to target database lost"); } const inserted = Math.floor(input.records.length * 0.7); const updated = input.records.length - inserted; console.log(`[load] Inserted ${inserted}, updated ${updated}.`); return { inserted, updated, source: input.source }; }) .step("verify", async (prev) => { console.log(`[load] Verifying load integrity...`); await new Promise((r) => setTimeout(r, 300)); const total = prev.inserted + prev.updated; console.log(`[load] Verified ${total} records in target.`); return prev; }) .build(); ``` The extract signal uses three steps (connect, query, validate) to demonstrate multi-step signals within a broadcast. The load signal has `.retries(2)` to handle transient database failures. Each signal's final output shape must match the next signal's input schema. **Run it:** `pnpm --filter example-etl-pipeline start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # CI Pipeline > Canonical HTML: [https://station.dterminal.net/docs/examples/ci-pipeline](https://station.dterminal.net/docs/examples/ci-pipeline) Simulated CI/CD workflow with fan-out, branch guards, and result fallback. The most complex DAG in the examples. ``` checkout |---> lint (parallel) |---> test-unit (parallel, 2 retries) |---> test-integration (parallel, 1 retry) | build-app (waits for all above) | deploy-staging | deploy-prod (guard: only on "main" branch) | notify (fallback: uses staging output if prod skipped) ``` ### broadcasts/ci-pipeline.ts ``` import { broadcast } from "station-broadcast"; import { checkout } from "../signals/checkout.js"; import { lint } from "../signals/lint.js"; import { testUnit } from "../signals/test-unit.js"; import { testIntegration } from "../signals/test-integration.js"; import { buildApp } from "../signals/build-app.js"; import { deployStaging } from "../signals/deploy-staging.js"; import { deployProd } from "../signals/deploy-prod.js"; import { notify } from "../signals/notify.js"; export const ciPipeline = broadcast("ci-pipeline") .input(checkout) .then(lint, testUnit, testIntegration) // fan-out: all run in parallel .then(buildApp, { // Wait for all tests AND checkout; pass checkout output as build input after: ["lint", "test-unit", "test-integration", "checkout"], map: (upstream) => upstream["checkout"], }) .then(deployStaging) .then(deployProd, { // Need checkout data for the branch guard after: ["deploy-staging", "checkout"], map: (upstream) => upstream["deploy-staging"], when: (upstream) => { const co = upstream["checkout"] as { branch: string } | undefined; return co?.branch === "main"; }, }) .then(notify, { // If deploy-prod was skipped (non-main branch), fall back to staging output after: ["deploy-prod", "deploy-staging"], map: (upstream) => upstream["deploy-prod"] ?? upstream["deploy-staging"], }) .onFailure("fail-fast") .timeout(120_000) .build(); ``` ### signals/checkout.ts ``` import { signal, z } from "station-signal"; export const checkout = signal("checkout") .input(z.object({ repo: z.string(), branch: z.string(), commitSha: z.string() })) .output(z.object({ repo: z.string(), branch: z.string(), commitSha: z.string(), workdir: z.string(), })) .timeout(10_000) .run(async (input) => { console.log(`[checkout] Cloning ${input.repo}@${input.branch} (${input.commitSha.slice(0, 7)})...`); await new Promise((r) => setTimeout(r, 600)); const workdir = `/tmp/ci/${input.commitSha.slice(0, 7)}`; console.log(`[checkout] Workspace ready at ${workdir}`); return { ...input, workdir }; }); ``` ### runner.ts ``` import path from "node:path"; import { SignalRunner, ConsoleSubscriber } from "station-signal"; import { BroadcastRunner, ConsoleBroadcastSubscriber } from "station-broadcast"; import { SqliteAdapter } from "station-adapter-sqlite"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; import { ciPipeline } from "./broadcasts/ci-pipeline.js"; const DB_PATH = path.join(import.meta.dirname, "jobs.db"); const signalRunner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: DB_PATH }), subscribers: [new ConsoleSubscriber()], maxConcurrent: 4, retryBackoffMs: 500, }); const broadcastRunner = new BroadcastRunner({ signalRunner, adapter: new BroadcastSqliteAdapter({ dbPath: DB_PATH }), subscribers: [new ConsoleBroadcastSubscriber()], }); broadcastRunner.register(ciPipeline); const branch = process.argv[2] || "main"; const sha = Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6); setTimeout(async () => { const id = await ciPipeline.trigger({ repo: "acme/web-app", branch, commitSha: sha, }); console.log(`\nTriggered CI pipeline: ${id}`); console.log(` repo: acme/web-app`); console.log(` branch: ${branch}`); console.log(` commit: ${sha.slice(0, 7)}`); console.log(`\nProd deploy ${branch === "main" ? "enabled" : "skipped"} (branch guard).`); }, 500); signalRunner.start(); broadcastRunner.start(); ``` `after` overrides implicit dependencies so `build-app` waits for all three parallel steps. `map` transforms upstream outputs into the shape the next signal expects. `when` conditionally skips nodes — here it gates prod deployment on the `"main"` branch. The `??` in notify's map provides a fallback when `deploy-prod` was skipped and returned undefined. `onFailure("fail-fast")` stops the entire pipeline on the first failure. Pass a branch name as a CLI argument to test the guard: `pnpm --filter example-ci-pipeline start -- feature/xyz` skips the prod deploy step. **Run it:** `pnpm --filter example-ci-pipeline start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # Fleet Monitor > Canonical HTML: [https://station.dterminal.net/docs/examples/fleet-monitor](https://station.dterminal.net/docs/examples/fleet-monitor) Real-time service health monitoring. Six parallel health check signals fan out from an init signal and converge into an aggregate report. Triggered on a recurring 60-second interval. ### broadcasts/full-health-check.ts ``` import { broadcast } from "station-broadcast"; import { initHealthCheck } from "../signals/init-health-check.js"; import { checkApi } from "../signals/check-api.js"; import { checkDatabase } from "../signals/check-database.js"; import { checkRedis } from "../signals/check-redis.js"; import { checkQueue } from "../signals/check-queue.js"; import { checkDisk } from "../signals/check-disk.js"; import { checkMemory } from "../signals/check-memory.js"; import { aggregateReport } from "../signals/aggregate-report.js"; export const fullHealthCheck = broadcast("full-health-check") .input(initHealthCheck) .then(checkApi, checkDatabase, checkRedis, checkQueue, checkDisk, checkMemory) .then(aggregateReport) .onFailure("continue") .timeout(30_000) .build(); ``` ### signals/check-api.ts ``` import { signal, z } from "station-signal"; export const checkApi = signal("check-api") .output(z.object({ service: z.string(), healthy: z.boolean(), latencyMs: z.number(), checkedAt: z.string(), })) .every("5s") .run(async () => { const latencyMs = 20 + Math.floor(Math.random() * 80); await new Promise((r) => setTimeout(r, latencyMs)); if (Math.random() < 0.1) { throw new Error(`API responded with 503 (latency: ${latencyMs}ms)`); } console.log(`[check-api] OK ${latencyMs}ms`); return { service: "api-gateway", healthy: true, latencyMs, checkedAt: new Date().toISOString(), }; }); ``` ### runner.ts ``` import path from "node:path"; import { SignalRunner, ConsoleSubscriber } from "station-signal"; import { BroadcastRunner, ConsoleBroadcastSubscriber } from "station-broadcast"; import { SqliteAdapter } from "station-adapter-sqlite"; import { BroadcastSqliteAdapter } from "station-adapter-sqlite/broadcast"; import { fullHealthCheck } from "./broadcasts/full-health-check.js"; const DB_PATH = path.join(import.meta.dirname, "jobs.db"); const signalRunner = new SignalRunner({ signalsDir: path.join(import.meta.dirname, "signals"), adapter: new SqliteAdapter({ dbPath: DB_PATH }), subscribers: [new ConsoleSubscriber()], maxConcurrent: 8, }); const broadcastRunner = new BroadcastRunner({ signalRunner, adapter: new BroadcastSqliteAdapter({ dbPath: DB_PATH }), subscribers: [new ConsoleBroadcastSubscriber()], }); broadcastRunner.register(fullHealthCheck); console.log("Fleet monitor started."); console.log("6 recurring health checks running at different intervals."); console.log(`Data persisted in ${DB_PATH}`); console.log("Open Station to watch real-time service health.\n"); // Trigger a full health check broadcast every 60 seconds setInterval(async () => { const id = await fullHealthCheck.trigger({ label: `scheduled-${Date.now().toString(36)}`, }); console.log(`\n[broadcast] Triggered full health check: ${id}\n`); }, 60_000); // Also trigger one immediately after startup setTimeout(async () => { const id = await fullHealthCheck.trigger({ label: "startup-check" }); console.log(`\n[broadcast] Triggered startup health check: ${id}\n`); }, 1000); signalRunner.start(); broadcastRunner.start(); ``` `onFailure("continue")` keeps checking remaining services even if one health check throws. Each check signal also runs independently on its own `.every()` interval. The broadcast adds a coordinated sweep that fans out all six checks in parallel and funnels results into a single aggregate report. Use `setInterval` to trigger the broadcast periodically. **Run it:** `pnpm --filter example-fleet-monitor start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # Beacon > Canonical HTML: [https://station.dterminal.net/docs/examples/beacon](https://station.dterminal.net/docs/examples/beacon) Three long-running, supervised processes — a server, a poller, and a reconnecting client — kept alive by the `BeaconRunner`. See the [Beacons API](https://station.dterminal.net/docs/beacons.md) for the full reference. ### beacons/health-server.ts — server mode ``` import { beacon, z } from "station-beacon"; import { createServer } from "node:http"; export const healthServer = beacon("health-server") .config(z.object({ port: z.number().default(8099) })) .withConfig({ port: 8099 }) .restart("always") .backoff("1s", { max: "10s" }) .run(async (ctx) => { let hits = 0; const server = createServer((_req, res) => { hits++; res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, hits })); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(ctx.config.port, resolve); }); ctx.ready(); ctx.onStop(async () => { await new Promise((r) => server.close(() => r())); }); await ctx.untilStopped(); }); ``` ### beacons/uptime-poller.ts — poll mode ``` import { beacon } from "station-beacon"; export const uptimePoller = beacon("uptime-poller").poll("3s", async (ctx) => { try { const res = await fetch("http://localhost:8099/", { signal: ctx.signal }); const body = (await res.json()) as { hits: number }; ctx.log(`health-server up — ${body.hits} total hits`); } catch (err) { if (ctx.signal.aborted) return; ctx.log(`health-server DOWN: ${(err as Error).message}`); } }); ``` ### beacons/stream-client.ts — client mode ``` import { beacon } from "station-beacon"; // Simulates a flaky upstream that drops the connection; throwing lets the // supervisor reconnect with exponential backoff. export const streamClient = beacon("stream-client") .restart("on-failure") .backoff("500ms", { factor: 2, max: "8s" }) .heartbeat("2s") .run(async (ctx) => { ctx.log(`connecting (incarnation ${ctx.incarnation})…`); ctx.ready(); const dropsAfterMs = 4_000 + (ctx.incarnation % 3) * 1_000; const startedAt = Date.now(); while (!ctx.signal.aborted) { ctx.heartbeat(); await new Promise((r) => setTimeout(r, 1_000)); if (Date.now() - startedAt > dropsAfterMs) throw new Error("connection dropped"); } }); ``` ### runner.ts ``` import path from "node:path"; import { BeaconRunner, ConsoleBeaconSubscriber } from "station-beacon"; const runner = BeaconRunner.create(path.join(import.meta.dirname, "beacons"), { subscribers: [new ConsoleBeaconSubscriber()], pollIntervalMs: 500, }); await runner.start(); // Ctrl-C runs each beacon's onStop cleanup, then exits ``` Watch the client drop and get restarted with a growing backoff (500ms → 1s → 2s…). The server stays up under `restart("always")`; the poller reports live hit counts. Press `Ctrl-C` for a graceful shutdown. **Run it:** `pnpm --filter simple-example-15-beacon start` * * * [← All examples](https://station.dterminal.net/docs/examples.md) --- # From one Station to a fleet > Canonical HTML: [https://station.dterminal.net/docs/examples/station-network](https://station.dterminal.net/docs/examples/station-network) Run one logical Headquarters and two execution stations on your laptop. This is the smallest topology that demonstrates real distribution, placement, draining, shared schedules, and worker recovery. ![Station Network dashboard showing Headquarters, a GPU station, and a CPU station with live capacity and heartbeat data](https://station.dterminal.net/screenshots/station-network.png) The working source is in `examples/16-station-network`. SQLite coordinates these local processes because they share one filesystem. Across machines, switch all shared adapters to PostgreSQL, MySQL, or Redis. ## 1\. Define work with two levels of capacity ``` import { signal, z } from "station-signal"; export const renderPreview = signal("render-preview") .input(z.object({ release: z.string() })) .env("ASSET_BUCKET") .concurrency({ station: 1, network: 1 }) .placement({ labels: { gpu: "true", region: "ke" } }) .run(async ({ release }) => { return renderTo(process.env.ASSET_BUCKET!, release); }); ``` `station: 1` protects each worker. `network: 1` protects the fleet. Placement means only an online station with both labels may claim this signal. ## 2\. Configure Headquarters ``` export default defineConfig({ role: "headquarters", port: 5600, signalsDir: "./signals", broadcastsDir: "./broadcasts", adapter: new SqliteAdapter({ dbPath }), broadcastAdapter: new BroadcastSqliteAdapter({ dbPath }), beaconAdapter: new BeaconSqliteAdapter({ dbPath }), scheduleAdapter: new ScheduleSqliteAdapter({ dbPath }), envStorage: new EnvSqliteAdapter({ dbPath }), network: { id: "release-demo", stationId: "headquarters", adapter: new StationNetworkSqliteAdapter({ dbPath }), }, auth: { username: "admin", password: "station" }, }); ``` Headquarters serves the API and dashboard, validates definitions, reconciles schedules and broadcasts, and enqueues work. Its signal execution capacity is always zero. ## 3\. Configure reusable workers ``` export default defineConfig({ role: "station", port: Number(process.env.STATION_PORT), signalsDir: "./signals", beaconsDir: "./beacons", adapter: new SqliteAdapter({ dbPath }), beaconAdapter: new BeaconSqliteAdapter({ dbPath }), envStorage: new EnvSqliteAdapter({ dbPath }), network: { id: "release-demo", stationId: process.env.STATION_ID!, adapter: new StationNetworkSqliteAdapter({ dbPath }), labels: { region: process.env.STATION_REGION!, gpu: process.env.STATION_GPU!, }, }, runner: { maxConcurrent: 4 }, }); ``` ## 4\. Start the topology ``` # Terminal 1 pnpm hq # Terminal 2 STATION_ID=worker-ke-1 STATION_PORT=5610 STATION_GPU=true pnpm worker # Terminal 3 STATION_ID=worker-ke-2 STATION_PORT=5620 STATION_GPU=false pnpm worker ``` Open `http://127.0.0.1:5600`, sign in, and visit **Stations**. Create `ASSET_BUCKET` under **Environment**, then trigger work from a signal page or create a runtime schedule. ## What to prove before production - One run and one schedule occurrence have exactly one owner. - Unconstrained work reaches more than one eligible station. - Station and network concurrency never exceed their declarations. - GPU work never reaches a CPU-only station. - A draining station receives no new work. - Expired leases recover, and stale owners cannot commit results. - Shutdown lets active work finish and marks the station offline. Continue with the full [Station Networks guide](https://station.dterminal.net/docs/network.md)for adapter selection, beacon proxying, timing semantics, and production security.