Guide

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(...)) 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 — 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.

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

EndpointReturns
GET /api/v1/broadcast-definitionsLatest non-deleted version of each definition.
GET /api/v1/broadcast-definitions/:nameLatest version of a single definition.
GET /api/v1/broadcast-definitions/:name/versionsList of all versions, oldest first. Includes soft-deleted entries.
GET /api/v1/broadcast-definitions/:name/versions/:nA 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:

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:

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<DynamicBroadcastSpec>;  getDefinition?(name: string, version?: number): Promise<DynamicBroadcastSpec | null>;  listDefinitions?(): Promise<DynamicBroadcastSpec[]>;  listDefinitionVersions?(name: string): Promise<DynamicBroadcastSpec[]>;  deleteDefinition?(name: string): Promise<boolean>;}

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.