# Custom skills 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//` and are **seeded as full, editable skills** — both `SKILL.md` **and** `tool.mjs` — into `state/skills//` 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//SKILL.md` for 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; a `tool.mjs` data fetcher is still a disk-drop (see > [tool.mjs (optional)](#toolmjs-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.md` directly and hit Rescan.) This borrows the *format* of [Anthropic's skills](https://github.com/anthropics/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.) ## Layout ``` 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`](./examples/skills) — copy a folder into `state/skills/` and hit **Rescan** in the admin Skills page: - [`moon-phase`](./examples/skills/moon-phase) — the small end. No settings, no network, no memory: it works out the lunar phase from the date and returns it. - [`sunset`](./examples/skills/sunset) — the other end. Operator settings (`configFields`), a call out to a public API, and `state` so 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. ## SKILL.md ```yaml --- 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) 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. ### `context:` — what the segment is allowed to mention `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](https://github.com/perminder-klair/subwave/issues/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: ```yaml --- 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. 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](#editing-the-built-in-skills). Bad frontmatter is logged and skipped — it never crashes the controller. ## tool.mjs (optional) If present, the default export is wrapped as an [AI SDK](https://sdk.vercel.ai) 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. ```js 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: 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. ### `services` — the station facade 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.mjs` runs operator-supplied code inside the controller > container, and `services` lets 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`.) ## Editing the built-in 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//` and are **seeded** into `state/skills//` — 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 → → ↺ Reset to default` overwrites both `SKILL.md` and `tool.mjs` from the shipped template. This is the way back from a broken edit — and the way to pull in a newer image's `tool.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. ### News: swapping the feed 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: ```yaml --- 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** (``) feeds. **Atom** feeds > (``) 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. ## Lifecycle - **Discovered but disabled.** A freshly dropped skill shows up in `/admin/skills` toggled **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 to `SKILL.md` / `tool.mjs` without 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. ## Sharing skills Three ways to move a skill between stations, sorted from most-reviewed to most-direct. ### The community catalog `/admin/skills` has a **Community** button (next to **New skill**) that lists the [community catalog](community.md) — skills (plus personas and shows) contributed by other operators. It's fetched **live** from the [`getsubwave/community`](https://github.com/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. ### Sharing your own 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`](https://github.com/getsubwave/community) repo; a bot validates the slug, reserved names, and context fields, then opens a one-file PR adding `skills//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`](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 …"). ### Zip export / import 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 `.zip` can > include a `tool.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, only `SKILL.md` + > `tool.mjs` are extracted. Reserved names and re-imports are rejected.)