Skip to main content

How it fits together

Enough of the shape to find your way around, and pointers to the file that argues each decision. The arguments live in the repository rather than here, because they change with the code.

The parts​

your provider a model a voice
(Spotify / Navidrome (local or hosted) (Kokoro / Rhapsode
/ Jellyfin / Emby / Plex) / ElevenLabs)
│ │ │
└───────────── plugins ─────────┴────────────────────┘
│
┌────────────────────────────────────┴─────────────────────────────────┐
│ the station (Node) │
│ director ── one running order, and the only thing that writes it │
│ render ── words, then audio, one state per stage │
│ playout ── hands records over, one at a time, and holds a lease │
└────────────────────────────────────┬─────────────────────────────────┘
│ HTTP
┌──────────────────────┼──────────────────────┐
Liquidsoap PostgreSQL analysis
(mixing, on air) (everything kept) (cue points, loudness)
│
Icecast ──────────────► /live.mp3

Nothing in Node decodes, mixes or encodes audio. Liquidsoap and Icecast run beside the station and the measurement sidecar is a separate Python service for exactly that reason. The station drives all three over HTTP.

The workspace​

PathWhat it is
apps/apithe Koa server: modules, ContractKit routers, dbmate migrations
apps/webthe console: React, Vite, TanStack Router, Mantine
apps/sitethis website. Never in the image
packages/plugin-sdkthe plugin contract and the capabilities a host offers
packages/sdkthe typed API client, generated from the contracts
packages/sdk-kotlin, sdk-csharp, sdk-swiftthe same contracts for the listener apps. Generated
plugins/*the bundled plugins: providers, enrichment, speech, models, news, weather
analysis/the measurement sidecar. Python
apps/android, apps/ios, apps/desktop, apps/streamdeckthe listener and operator apps
stream/, nginx/, Dockerfile, docker/the audio chain, and the production image

Three things own the air​

The director owns the running order, and nothing else writes it. It is one document of items, each carrying its own state, several hours deep. The console, the schedule and the model all post commands to the director rather than editing the order themselves, which is why an edit made at 3pm is still true at 3.05. A record is not committed to air until its audio is on the machine.

Render turns words into audio, one state per stage. A break is written by whoever is presenting, by a model when one is configured and by the station's own phrasings when none is, when the model is slow, or when what it wrote failed a check. That floor cannot fail: a station whose presenter went quiet because a GPU was busy is not a station.

Playout hands records over one at a time and holds a lease. Liquidsoap airs nothing unless the station is actively renewing a short claim, so a crashed or redeployed station takes itself off the air within seconds. Eleven ordered gates over one snapshot answer "why is nothing playing" with a single causal verdict.

The API's modules​

apps/api/src/modules/modules.ts registers every module, and the order is load-bearing. It is the source of truth for what exists: check it rather than assuming a subsystem is there.

What is generated, and from what​

Nothing in these is hand-edited, and CI regenerates all of it and fails on anything that moved.

GeneratedFromCommand
routers, request and response types, the four SDKs, this site's API reference, openapi.yamlapps/api/data/contracts/*.ckpnpm build:contracts
permission typesapps/api/data/permissions/*.permpnpm build:permissions
database typesthe migrated schemapnpm build:datatypes

Migrations themselves are dbmate SQL under apps/api/data/migrations, in the deadair schema. To change an endpoint, edit its .ck and commit the regenerated output beside it.

Where the reasoning lives​

CLAUDE.md at the root of the repository is the index, kept deliberately small, and each file below is sectioned so a question is a heading. Read the one covering what you are changing.

Working onRead
the running order, briefs, committing, track audiodocs/internals/director.md
which records get chosen, ratings, the search toolprogramming.md
what a break says, facts, bulletins, the format clockbreaks.md
personas, notebooks, stories, auditionspersonas.md
phone-ins, beats, casting, stitchingproductions.md
podcasts the station carriespodcasts.md
books and columns it reads outnarrations.md
speech engines, voices, cues, pads, segmentsrender.md
the mount, the audience gate, why it is quietplayout.md
the model, the gate, the tool loopllm.md
the Dockerfile, the image variants, CIdeployment.md
lifecycle, config, settings, DI, connectionsapps/api/CLAUDE.md
plugins and host capabilitiespackages/plugin-sdk/CLAUDE.md

Four rules that apply everywhere​

  • Generated output is never hand-edited, per the table above.
  • A setting is a string. Every layer of AppConfig holds text, so an on/off setting is read through settingIsOn and a number through the resolvers in setting.numbers.ts. config.get(key, false) answers the string 'false', which is truthy.
  • In plugin code, undefined means "not set", never null.
  • What is stored or sent is JSON-safe: no Date, no class instances, no functions, durations as integer milliseconds, dates as ISO-8601 strings. Host methods are exempt.