Files
docker-infrastructure/.claude/skills/subwave/SKILL.md
T
poprhythm 39fdc4276f Add subwave skill for SUB/WAVE admin-API interactions
Wraps show/persona/schedule/trigger calls that were previously done by
hand with raw docker exec curl, and documents the gotchas hit tonight
(whole-object PUT/POST semantics, the frequency enum, the silent
default-persona trap, and the port-not-published-to-host setup).

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-19 23:40:00 +00:00

5.2 KiB

name, description
name description
subwave Manage SUB/WAVE (self-hosted AI radio station) shows, DJ personas, the weekly schedule grid, and live overrides. Use for any SUB/WAVE admin-API work — creating a new anchor show, minting a DJ persona, scheduling a slot, or triggering a show live.

SUB/WAVE admin API

A CLI wrapper (subwave_api.py, in this skill's directory) around the sub-wave-controller admin REST API, distilled from a long session of building ~30 anchor-playlist shows and DJ personas by hand with raw docker exec curl calls. Use it instead of re-deriving the request shapes each time.

Setup

Auth comes from subwave/.env (ADMIN_USER/ADMIN_PASS) at the repo root — no flags needed. The admin API (port 7701) is not published to the host — docker ps shows sub-wave-controller with only 7701/tcp (internal), not 0.0.0.0:7701->7701. Every call must go through docker exec sub-wave-controller curl ..., which is exactly what this script does. Don't waste a round trip re-checking this — it's a deliberate container-internal-only setup, not a misconfiguration.

Run it as: python3 .claude/skills/subwave/subwave_api.py <command> ...

Commands

persona list
persona create --name N --voice V --tagline T --soul S
               [--frequency silent|quiet|moderate|chatty|aggressive]
               [--humour 0-10] [--warmth 0-10] [--local-colour 0-10]

show list
show get --id ID
show create --name N --topic T --playlist-id ID
            [--genres a,b,c] [--moods a,b] [--energies a,b]
            [--eras 1990-2000,2005-2010] [--persona-id ID]
show set-persona --id ID --persona-id ID
show set-name --id ID --name N
show set-topic --id ID --topic T

schedule grid                                    # full 7x24 weekly grid, show names
schedule open-slots                              # every null (day, hour) pair
schedule set --show-id ID --day 0-6 --hour 0-23  # day 0 = Sunday
schedule set-random --show-id ID                 # pick a random open slot

trigger --show-id ID [--minutes 60]              # live override, starts immediately

Playlists themselves (search/curate/create anchor playlists in Navidrome) are a separate concern — use the navidrome skill for that, then pass the resulting playlist id to show create --playlist-id.

Gotchas learned the hard way

  • POST /shows needs the show wrapped in {"show": {...}} — posting the show object bare fails with a schema error (expected object, received undefined). The CLI handles this.
  • New shows need a full show object, not a partial patch — there's no PATCH endpoint. To change one field (persona, name, topic), read the full current show from schedule.json, mutate the one field, and re-POST the whole thing. show set-persona/set-name/set-topic do exactly this.
  • PUT /schedule takes the entire weekly grid, not a single slot — read the current grid, mutate one [day][hour] cell, PUT the whole thing back. schedule set/set-random do this for you. The response's dropped count should always be 0; a nonzero value means a show id in the grid doesn't exist anymore (a bug elsewhere, not addressed here).
  • POST /settings for a new persona is also whole-array append, not a single-persona-create endpoint — there is no such endpoint. Read personas, append one object, POST {"personas": [...]}.
  • Persona frequency is a strict enum: silent | quiet | moderate | chatty | aggressive. Passing "normal" (an easy guess) fails with must be one of: silent, quiet, moderate, chatty, aggressive — the CLI defaults to moderate to sidestep this.
  • A show with no explicit personaId inherits whatever p_default0 is (one of the original 3 built-in personas) — and if that persona has djMode: false, the show runs with zero DJ talk breaks and sounds silent/DJ-less. Always set a real persona explicitly once you know what voice/character you want; don't assume a blank field means "no host," it means "the accidental default host."
  • /schedule/override (the live-trigger endpoint) starts immediately — startedAt: Date.now(). There's no way to schedule a future start time through this endpoint; "play this at 9pm" only works if it's already ~9pm, or by placing the show in the recurring weekly grid at that hour instead.
  • Two shows can't cleanly share one weekly slot — if you want two moods to alternate in the same hour across the week (e.g. show A on Sun/Tue/Thu/Sat, show B on Mon/Wed/Fri), that's just setting the same [hour] column to different show ids on different day rows via repeated schedule set calls — there's no "alternate" primitive, just per-day-row assignment.
  • Kokoro voice pool has ~28 usable English voices (af_*/am_* American, bf_*/bm_* British) sharing one 27MB voices-v1.0.bin file — installing/using more costs nothing. PERSONA_LIMIT = 48 is the only hard ceiling (/app/src/schemas/persona.ts inside the controller container).
  • A library rescan can surface artists that "don't exist" on a first check — Navidrome's index lags real disk state. If a user swears an artist should be there, navidrome_api.py-equivalent rescan (startScan/getScanStatus via the Subsonic API) before concluding it's actually missing.