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
| 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
versions 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<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.
/api/v1/trigger-dynamic-broadcast endpoint, so the snapshot-on-trigger semantics apply uniformly whether the trigger came from a human or an integration.