SUB/WAVE's skills are the things the AI DJ does between tracks — a weather
check, a headline, a dig on the song playing. The built-in ones ship as
read-only templates under controller/src/skills/builtins/<kind>/ and are
seeded as full, editable skills — both SKILL.md and tool.mjs — into
state/skills/<kind>/ on first boot. From then on state/skills/ is the single
place skills load from: a built-in is just a pre-installed skill, no different
from one you add yourself, so you can change what it says, which feed it reads,
and how it fetches its data — without touching the codebase. Add entirely new
skills the same way: from the admin UI, or by dropping a folder into
state/skills/.
TL;DR — want a brand-new segment? Open /admin/skills → New skill, fill in a name, a brief, and a cooldown, then Create skill. It writes
state/skills/<slug>/SKILL.mdfor you (and arrives disabled — enable it when you're happy). Custom skills can also be edited and deleted from the same page. The form is prompt-only; atool.mjsdata fetcher is still a disk-drop (see tool.mjs (optional) below).
TL;DR — News reads UK/BBC and you want something local? Open /admin/skills → News → Edit, paste your own RSS feed URL and rewrite the brief, then Save. (Or edit
state/skills/news/SKILL.mddirectly and hit Rescan.)
This borrows the format of Anthropic's skills
— a SKILL.md with YAML frontmatter and a markdown body, plus optional code —
but not their meaning. A SUB/WAVE skill is exactly one thing: a between-track
spoken segment. (You can't drop in anthropics/skills/pdf and have it do
anything — those manipulate documents.)
state/skills/
moon-phase/
SKILL.md # frontmatter (→ metadata) + body (→ the DJ's brief)
tool.mjs # OPTIONAL: a data fetcher, wrapped as a tool the DJ can call
Two copy-ready examples live in docs/examples/skills — copy
a folder into state/skills/ and hit Rescan in the admin Skills page:
moon-phase— the small end. No settings, no network, no memory: it works out the lunar phase from the date and returns it.sunset— the other end. Operator settings (configFields), a call out to a public API, andstateso it marks the sunset once a day rather than every time it fires. Fill in its coordinates in the edit sheet before it will say anything.
---
name: moon-phase # the slug / "kind" (defaults to the folder name)
label: Moon phase # human label in /admin/skills (defaults to title-cased name)
cooldown: 6h # hard min gap between autonomous firings — "90m" | "6h" | "2d" | "45" (bare = minutes)
cron: 0 * * * * # OPTIONAL: fire on a fixed schedule instead of/alongside the cooldown gate (see below)
cronOnly: true # OPTIONAL: with a cron: set, withhold this skill from random autonomous picks entirely
window: any # "any" (default) | "commute" — only offered during commute hours
context: time, festival # OPTIONAL: which "right now" fields this segment may mention (see below)
requiresKey: SOME_API_KEY # OPTIONAL: env var the skill needs; if unset, the skill stays inert
toolDescription: ... # OPTIONAL: how the DJ-facing tool is described (only matters with tool.mjs)
---
The markdown body is the DJ's brief for this segment. Keep it tight: what to
say, in what tone, and — importantly — when to stay silent. The agent reads
this verbatim. One short sentence on air is the norm.Only a non-empty body is required; every frontmatter key has a default. The
body becomes the per-segment briefing the DJ agent follows (the same role the
inline desc: strings play for built-in skills) and the description shown in the
admin UI.
The block is parsed as real YAML, so the ordinary things work: inline #
comments like the ones above, quoted values (label: "Tonight: the moon"), and
lists written either way —
tags: [late-night, factual]
# ...is the same as...
tags:
- late-night
- factual
# ...is the same as...
tags: late-night, factualValues are read as text whatever their YAML type, so feedMaxItems: 6 and
feedMaxItems: "6" are identical. Nested maps have no meaning here and are
ignored. A block that isn't valid YAML — most often an unquoted colon in a
value — still loads, read with the old line-by-line parser, and logs a warning
naming the file.
context: is a comma-separated allow-list of the "right now" fields the DJ may
weave into this segment. Valid fields:
| field | what it surfaces |
|---|---|
date |
day of week, date, season |
clock |
local clock time, plus weekend / late-night / commute tags |
time |
the daypart and its vibe (e.g. "morning, productive") |
weather |
current condition, temperature, location |
festival |
the named festival, if today is one |
show |
the scheduled show on air, if any |
listeners |
how many people are tuned in |
Leave context: off and the segment gets the default profile: everything
except weather. This is deliberate — ambient weather stapled to every
break made the DJ comically weather-heavy (#471).
Weather now reaches air through the dedicated weather skill, which is
cooldown- and change-gated, rather than as filler everywhere.
Tick weather back on for a skill where it's genuinely topical — e.g. a
commute-conditions segment:
---
name: commute-conditions
label: Commute conditions
window: commute
context: time, clock, weather
---
A quick word on what the drive looks like right now — lean on the weather and
the hour. One sentence; skip it if nothing's notable.You can also set this from the admin UI: /admin/skills → Edit shows a tick-box per field. An empty selection resets the skill to the default profile.
cron: is an OPTIONAL standard 5-field cron expression ("0 * * * *" = top of
every hour, "*/30 * * * *" = every 30 minutes); a leading seconds field is
accepted too ("0 0 8 * * *"). When set, the scheduler registers a dedicated
timer for that skill and fires it the moment the expression matches — bypassing
the cooldown and the DJ's frequency floor. Leave it blank (the default) and the
skill only airs through the normal cooldown / frequency-gated segment tick.
The expression runs in the station's own timezone (Settings → Station), so a
cron: 0 8 * * * fires at 8am local time wherever the station is configured to
be, not the container's UTC clock. Changing the station timezone re-registers
every skill cron immediately — no restart, no rescan. A skill can carry both a
cooldown: and a cron: — the cooldown still applies to any autonomous
firing from the segment tick, while the cron timer ignores it.
An expression saved through the admin UI (or installed from the community
catalog) is refused if it isn't one the scheduler can run, with the error on
the field. One typed into SKILL.md by hand is logged and skipped instead, so a
typo costs the skill its timer rather than stopping it loading — the admin
editor flags it the next time you open that skill.
Set it from the admin UI too: /admin/skills → Edit → Cron timer.
A cron speaks whenever it has something to speak from. This is the thing to get right before adding one. The autonomous segment tick asks the agent whether to air at all, and silence is a first-class answer it takes whenever the data is dull or unchanged. A cron takes the same path as Run now, which is forced: the segment is required to produce a line, and the model is not offered a "stay silent" option.
The one exception is the case where there is nothing to write from. If your
skill has a tool.mjs and it returns { available: false } or fails, the
forced run stands down rather than ordering a line anyway: nothing airs, and
the reason is logged (and returned to Run now as aired: false). Without
that, a skill handed no facts and told it must speak can only invent them — which
is exactly what the web-search skill did when a search came back empty
(#1412). Opt out with
export const requiresData = false when your skill writes its own material and
{ available: false } merely means "no external item this time" — that is what
the built-in curiosity does.
On the autonomous pool-mode tick, grounded { available: false } results are
skipped before the LLM call and logged with the selected skill. The scheduler
then backs that skill off in memory for the shorter of its configured cooldown
or 15 minutes, so an empty source does not consume retrieval work again on the
next five-minute tick. Successful-air cooldowns remain separate.
So a cron suits a skill that is worth hearing at a fixed moment every time — a
morning bulletin, a sign-off, a running joke tied to a particular hour. It still
suits a skill that speaks only when something is notable less well: it fires
on the clock rather than on the news, so it will keep asking at 8am whether
there is anything to say. It just no longer makes something up when the answer
is no. Both example skills in
docs/examples/skills are in that second group and
deliberately carry no cron: — moon-phase is meant to skip an unremarkable
gibbous, and sunset tracks a time that moves through the year, so pinning it to
a fixed clock reading would be wrong in a different way. Leave those on
cooldown: and let the director decide.
Daylight saving. A normal daily cron survives a clock change: cron: 0 8 * * *
fires once at 08:00 local on the spring-forward day, the autumn day, and every
ordinary day, which is the whole point of running in the station zone. Two edge
cases fall out of the scheduler and are worth knowing about:
- On the autumn change, the hour that repeats (in the UK, 01:00–01:59) is
skipped: a
*/10cron fires 19 times that day instead of 25, and an hourly one misses a single beat. Nothing fires twice, which is the safer direction — the same guard that drops the hour is what stops the duplicate. - On the spring change, a cron pointed at a time that does not exist that day (again in the UK, anything in 01:00–01:59) simply does not fire, silently.
So avoid pinning a skill to the small hours if it genuinely has to run every day,
and expect a once-a-year gap otherwise. This is node-cron 3.x behaviour rather
than anything SUB/WAVE decides, and it applies to every cron in the scheduler,
not only skill timers.
A cron timer is not the same override as "Run now". Pressing Run now is
an explicit operator action and fires whatever it names. A timer firing on its
own is autonomous, so it stands down exactly where the normal segment tick
does: the station voice switch is off (tts.enabled: false, "music only"), a
programme episode is on air, nobody is listening, the daily LLM token budget is
spent — and, because they're rules about the skill itself, if the skill is
disabled or isn't assigned to the on-air DJ. That last pair matters for
an imported skill: a zip import or a community install arrives disabled pending
your review, and a cron: line in it does not air anything until you enable it.
Each stand-down is written to the booth log with its reason, since a silent
timer is otherwise undiagnosable.
A cron: timer is a second trigger, not a replacement one. By default the
skill stays eligible for the normal autonomous segment tick too, off-cooldown,
same as any other skill — so a skill written around a specific moment (e.g. a
running joke tied to 7:10) can still fire at a random moment in between. Set
cronOnly: true to withhold it from that random selection entirely; it then
airs ONLY when its cron timer ticks (or via Run now, which always bypasses
every gate). cronOnly on its own, without a cron: expression, means the
skill never fires autonomously at all.
For a new skill the name must be a lowercase slug that isn't a built-in kind
(weather, news, now-playing-dig, curiosity, album-anniversary, library-deep-cut,
web-search). Naming a folder after a built-in kind instead edits that built-in —
see Editing the built-in skills. Bad frontmatter is
logged and skipped — it never crashes the controller.
If present, the default export is wrapped as an AI SDK
tool the segment director can call before writing the line. This is the
exact same mechanism the built-ins use — the seven shipped skills are just
directories with a SKILL.md and a tool.mjs, loaded the same way as yours.
export default async function (ctx, state, services, config, input) {
// ctx — the moment: { time, weather, festival, dominantMood, clock }
// state — cross-tick dedup memory (persists between firings)
// services — the curated station facade (see below)
// config — this skill's own SKILL.md frontmatter (e.g. a custom `feed:`)
// input — the agent's values for your declared `inputs` (see below); {}
// when you declare none
// Return any JSON-serialisable object. The `{ available: false }` convention
// tells the agent there's nothing worth airing right now.
return { available: true, foo: 'bar' };
}
// OPTIONAL: a richer tool description shown to the agent (else a generic one).
export const description = 'Fetch X for the … segment.';
// OPTIONAL: gate the whole skill on a runtime condition — when this returns
// false the skill is never even offered (e.g. no search provider configured).
export const ready = (services) => services.searchReady();
// OPTIONAL: opt out of the grounding rule. By default a skill with a tool
// STANDS DOWN on a forced run (Run now, cron, programme feature) when this tool
// returns `{ available: false }` or throws — no data, no segment, rather than
// an invented one. Set this to false when your skill writes its own material
// and `{ available: false }` just means "nothing external this time".
// An operator can settle it per install with a `requiresData:` frontmatter line,
// which wins over this.
export const requiresData = false;
// OPTIONAL: agent-steerable parameters — a flat { name: description } object of
// string params. The agent may pass a value or null for each; handle null by
// falling back to your own default (see the web-search built-in's `query`).
// Without this export the tool is zero-arg, which small models handle best —
// only declare inputs the agent genuinely benefits from steering.
export const inputs = { query: 'what to search for; null for the default dig' };
// OPTIONAL: operator knobs — the settings this skill gets its own fields for in
// /admin/skills. Values are stored in this skill's OWN SKILL.md frontmatter and
// arrive back as `config`, so there's nothing else to wire up. Declaring them
// HERE (rather than in the controller) is what makes a copy of the skill keep
// its settings: a duplicate copies tool.mjs verbatim, name and all.
export const configFields = {
feed: { type: 'url', label: 'News feed · RSS 2.0', placeholder: 'https://…/rss.xml' },
feedMaxItems: { type: 'number', label: 'Max items', min: 1, max: 50, integer: true },
};configFields reference. A flat { key: { … } } map, up to 8 entries per
skill. Each entry takes:
| field | meaning |
|---|---|
type |
text (default), url (http/https only), or number |
label |
the form label; derived from the key when omitted |
placeholder / hint |
optional form affordances |
min / max / integer |
number only — bounds, and whether fractions are refused |
Keys must be letters, digits, _ starting with a letter, and can't shadow a key
the editor already owns (name, label, cooldown, context, window,
requiresKey, tags, toolDescription, brief). A malformed declaration is
narrowed away rather than breaking the skill — the skill still loads and airs, it
just shows no settings. A bad value is the opposite: the save fails loudly with
a 400 rather than dropping the knob you just set.
You don't have to declare a knob to use one — a tool can read any frontmatter key
off config. Declaring it is what gets you a form field instead of a hand edit.
Either way the editor preserves keys it doesn't own, so a hand-authored line
survives a save from the admin form.
The one way a tool reaches the world, so built-in and custom skills run on identical footing. It's read-mostly (no settings writes, no secrets):
| call | what it does |
|---|---|
services.searchWeb(query, opts?) |
web search via the configured provider (DuckDuckGo / Tavily / Brave / SearXNG) |
services.searchReady() |
true when a search provider is usable |
services.nowPlaying() |
the track on air — { artist, title, album, year, id } or null |
services.recentPlays(hours) |
play-log dedup sets { ids, keys } over the last hours |
services.library.getArtist(id) / .getAlbum(id) / .searchArtists(name, opts?) |
Navidrome/Subsonic reads |
services.onThisDay() |
Wikipedia "on this day" events for today |
services.fetchHeadlines({ feedUrl?, maxItems? }) |
fetch + parse an RSS feed |
services.recall.seen(key) / .remember(key) |
durable, cross-restart dedup ledger |
services.log(msg) |
append a line to the station event log |
Every skill's tool.mjs is timeout-guarded (8 s) and any throw degrades
cleanly to "no data" — a slow or broken skill can never hang the between-track
tick. This applies to the seeded built-ins too (their network calls — search,
RSS, on-this-day — must finish within 8 s or that tick simply yields no segment).
With no tool.mjs, the skill is pure generation: the DJ writes from the brief
alone.
Security. A
tool.mjsruns operator-supplied code inside the controller container, andserviceslets it spend your search-provider quota and read your library — the same trust model as a locally-installed Claude Code skill. Only drop in code you've read and trust. (Skills you add stay disabled until you enable them in/admin/skills.)
The 7 built-ins — weather, news, now-playing-dig, curiosity, album-anniversary,
library-deep-cut, web-search — ship as read-only templates under
controller/src/skills/builtins/<kind>/ and are seeded into
state/skills/<kind>/ — both SKILL.md and tool.mjs — the first time the
controller boots. After that they're ordinary editable skills: edit the brief /
cooldown / label / context: in /admin/skills, and edit the tool.mjs on
disk + Rescan exactly as you would for a skill you wrote. The seeded files carry
a context: line showing each built-in's current fields — weather ships with
weather ticked on, the rest with the default (no-weather) profile.
How a built-in still differs from a skill you add:
- Enabled by default. A built-in airs out of the box; a new skill starts in the discovered-but-disabled state until you enable it.
- Can't be deleted, only disabled. Toggle it off to silence it. If you delete its folder on disk, the seeder restores it (both files) on the next boot.
- Reset to default.
/admin/skills → <built-in> → ↺ Reset to defaultoverwrites bothSKILL.mdandtool.mjsfrom the shipped template. This is the way back from a broken edit — and the way to pull in a newer image'stool.mjs(the seeder never overwrites a file that already exists, so a shipped fix only reaches an existing install when you reset).
The seeder never clobbers a file that already exists, so your edits survive a restart and an upgrade. Only Reset to default (or deleting the file on disk) brings the shipped version back.
The news skill's tool.mjs declares two knobs (configFields, above), so
/admin/skills → News → Edit carries a feed field and a max-items field. They
are stored as two extra frontmatter keys, editable on disk just as well:
---
name: news
label: News headlines
cooldown: 45m
feed: https://www.npr.org/rss/rss.php?id=1001 # any RSS 2.0 feed
feedMaxItems: 10
---
Read one fresh headline in a single sentence — keep it conversational, in the
station's voice. Skip a headline that is dull or stale; silence is fine.Heads-up. The parser handles RSS 2.0 (
<item>) feeds. Atom feeds (<entry>) return zero items today — use an RSS URL.
NEWS_FEED_URL / NEWS_MAX_ITEMS in .env only seed this file on the very first
boot. Once state/skills/news/SKILL.md exists, the file wins — change the feed
there (or in /admin/skills), not in .env.
Running a second news source is just a copy: export the skill, rename it in
both the .md and the .zip, re-import, and point its feed somewhere else. The
knobs ride in tool.mjs, so the copy gets its own feed field under its own name.
- Discovered but disabled. A freshly dropped skill shows up in
/admin/skillstoggled off. It cannot air — autonomously or otherwise — until you enable it there. Merely dropping a folder never puts unreviewed content (or code) on air. - Loaded at boot, and on demand via the Rescan state/skills button on
the admin Skills page (
POST /api/dj/skills/rescan). Rescan picks up new folders and edits toSKILL.md/tool.mjswithout a controller restart. - Persona ownership still applies. Like built-in skills, a custom skill only fires autonomously when it's enabled and assigned to the persona on air (Personas page). Run now is an operator override that bypasses the toggle, the persona assignment, the frequency gate, and the cooldown.
Three ways to move a skill between stations, sorted from most-reviewed to most-direct.
/admin/skills has a Community button (next to New skill) that lists the
community catalog — skills (plus personas and shows) contributed
by other operators. It's fetched live from the
getsubwave/community
repo, so it isn't tied to your controller version. Install copies one into
state/skills/ as an ordinary custom skill — disabled on arrival, for you to
read before it airs. The catalog is prompt-only by contract: no tool.mjs is
ever shipped or written, so installing from it never runs third-party code.
Any prompt-only custom skill (no tool.mjs) shows a Share to community
button. It opens a prefilled GitHub Issue Form in the
getsubwave/community
repo; a bot validates the slug, reserved names, and context fields, then opens a
one-file PR adding skills/<slug>/SKILL.md to the catalog — no fork, no code.
Once a maintainer merges it, the catalog rebuilds and it goes live on every
station shortly after — no release, and no image pull needed, because every
station fetches the catalog live (see docs/community.md). A skill
that carries a tool.mjs can't be shared this way; use a zip.
The bot also stamps provenance into the frontmatter it writes:
submittedBy (the GitHub login that filed the issue), dateAdded (when it first
entered the catalog), and dateModified (each time the PR is refreshed).
dateAdded is preserved across issue edits — only dateModified moves — so an
approved skill keeps its original credit line. The Community modal shows this
under each entry ("by @who · added … · updated …").
For a direct operator-to-operator handoff, the skill edit sheet has ↓ Export
(GET /api/dj/skills/:slug/export) that streams a .zip of SKILL.md plus
tool.mjs if present. The Import .zip button in the Community modal
(POST /api/dj/skills/import) takes it back in, deriving the slug from the
bundle's name:.
A zip may carry code. Unlike the reviewed catalog, an imported
.zipcan include atool.mjs— a direct action on your own box, the same trust as dropping a folder in by hand or restoring a backup. Imports arrive disabled; when the bundle has a tool, the response flags it so the UI can warn you before you enable it. (Hardened against zip-slip; 5 MB upload cap, onlySKILL.md+tool.mjsare extracted. Reserved names and re-imports are rejected.)