npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@deadair/plugin-sdk

v0.98.1

Published

The contract a deadair plugin is written against: the manifest, the capabilities, the host it is handed, and a fake host for its tests.

Readme

@deadair/plugin-sdk

Everything you need to write a deadair plugin, and nothing else. This package is the only thing a plugin imports from deadair: no database, no DI container, no HTTP framework. zod is a peer dependency, so you and the host share one copy; the one dependency of its own is the XML parser behind parseFeed.

A plugin extends deadair by declaring capabilities:

| capability | what it lets you do | | ------------ | -------------------------------------------------------------- | | catalog | supply music: list playlists, list their tracks | | stream | get the station the audio to play (resolveStreamUrl) | | steer | own your audio output and let deadair only tell you what to do | | profile | say what the connected account listens to: a window in, artists out, and optionally its latest plays | | enrichment | supply facts about a track: year, genre, label, trivia, prose | | speech | say something out loud: text in, audio out | | llm | produce words: a conversation in, text out | | analysis | measure a track's audio: bytes in, cue points and loudness out | | mixer | make one piece of audio out of several: parts in, audio out | | transcode | make a small copy of some audio to share: audio in, audio out | | charts | say what is popular: a chart id in, ranked names out | | similarity | say what sounds like this: an artist, a record or a style's tags in, names out | | news | say what happened outside the station: a feed in, entries out | | podcast | carry somebody else's programme: a show in, episodes out | | search | ask the open web a question: words in, pages out | | weather | say what it is like outside: a place in, measurements out | | almanac | say what happened on a date: a month and a day in, entries out | | concerts | say when an artist is next playing nearby: an artist and a place in, dates out | | scrobble | report what the station played to somebody else's service | | messaging | talk to people on a chat platform: messages in, messages out | | output | play the station on a speaker: a mount's URL in, a device plays | | oauth | hold operator tokens, obtained through the host's redirect |

There is no second axis. capabilities is the whole declaration, and the host checks it on every call together with whether you actually implemented the methods, because a plugin that claims a capability and forgets the method is a TypeError in the middle of a request rather than an honest "not supported".

The shape of a plugin

A plugin package default-exports the result of definePlugin(manifest, factory) and points at that entry file from its own package.json:

{
    "name": "deadair-plugin-discogs",
    "version": "1.0.0",
    "type": "module",
    "main": "./dist/index.js",
    "deadair": {
        "plugin": "./dist/index.js"
    },
    "peerDependencies": {
        "@deadair/plugin-sdk": ">=0.1.0",
        "zod": "^4.0.0"
    }
}

The deadair.plugin field is how the host finds your entry point. Without it, your package is just a package.

The SDK and zod are PEERS, and that is the one part of this file that is not a matter of taste: the station hands your plugin its own copy of each, linked into the plugins directory beside it, so a class you extend is the class the host checks against. Ship neither. The range that is enforced is your manifest's apiVersion (see Versioning); the package range is advice to whoever installs your devDependencies, which is why it is a floor rather than a caret. How to build, package and install one from outside this repository is on the website, at https://deadair.radio/docs/plugin-development.

The rules

  1. No ambient I/O. Get at the world through the PluginHost. host.fetch() is your egress, and it will refuse any hostname you did not declare in permissions.network.

    This is a rule, not a cage, and it is never going to be one. The host imports you into its own process, permanently, so global fetch and fs are within reach. Use them and you opt out of the rate limiting, timeouts, redirect checks and audit logging the host does on your behalf, and you make your manifest a lie to the operator who installed you.

  2. Payloads that get stored or sent are JSON-safe. No Date, no class instances, no functions on anything in capabilities/. Durations are integers in milliseconds; dates are ISO-8601 strings. Not because the boundary is a wire (it is a function call) but because those values end up in Postgres and in the console's JSON, and a Date comes back out of a jsonb column as a string either way.

    The host's own methods are under no such rule: host.fetch hands you a real Response, host.signal a real AbortSignal, and speak() returns a real ReadableStream.

  3. Ask for what you need and no more. permissions is shown to the operator before they enable you.

  4. undefined, never null, for "not set".

  5. Do no work in the factory. Build the object, put setup in onLoad(), and register the undo for anything you start.

  6. Secrets are write-only. A secret config field is encrypted at rest and never read back into the settings UI. Read it with host.secrets.get().

  7. A URL you hand back to be stored must be stable. artworkUrl is kept, and the host's art cache is keyed by the URL string itself, so the same image has to mint the same URL every time you are asked about it. If yours carries credentials, fix the varying part — a salt, a nonce, a timestamp — once in init() and reuse it for art. Vary it per call and every mention becomes a new row and another download of identical bytes; nothing errors, it just never caches. resolveStreamUrl is under no such rule, because nothing stores what it returns.

  8. A URL in an enrichment's unknown keys never reaches a response. A key the SDK has no field for is kept as your extra and shown in the console, but any string in it holding a URL is dropped when it is read, because the host cannot tell what such a URL is. Put a picture in the typed image field (artworkUrl, or imageUrl on an artist), which the station serves through its own cache rather than handing your URL out, and a page in links.

A complete minimal plugin

An enrichment plugin that looks a track up by ISRC and returns its release year and genres.

import {
    definePlugin,
    type EnrichmentPluginInstance,
    jsonBody,
    Plugin,
    type PluginManifest,
    type TrackEnrichment,
    type TrackRef,
} from '@deadair/plugin-sdk';
import { z } from 'zod';

const configSchema = z.object({
    apiKey: z.string().min(1),
    includeGenres: z.boolean().default(true),
});

const manifest: PluginManifest = {
    id: 'example.recordbin',
    name: 'Record Bin',
    version: '1.0.0',
    capabilities: ['enrichment'],
    apiVersion: '^1.0.0',
    description: 'Release years and genres from the Record Bin catalogue.',
    homepage: 'https://example.com/recordbin',
    permissions: {
        network: ['api.recordbin.example.com'],
        storage: false,
        oauth: false,
    },
    configFields: [
        {
            key: 'apiKey',
            label: 'API key',
            type: 'secret',
            required: true,
            help: 'Create one under Account → Developers.',
        },
        {
            key: 'includeGenres',
            label: 'Include genres',
            type: 'boolean',
            default: true,
        },
    ],
    configSchema,
};

class RecordBinPlugin extends Plugin implements EnrichmentPluginInstance {
    priority = 500;
    matchKeys: EnrichmentPluginInstance['matchKeys'] = ['isrc'];

    private apiKey?: string;
    private includeGenres = true;

    protected async onLoad(): Promise<void> {
        this.apiKey = await this.host.secrets.get('apiKey');
        const config = await this.host.config.get();
        this.includeGenres = config.includeGenres !== false;
        this.host.logger.info('record bin ready');
    }

    async testConnection(): Promise<{ ok: boolean; message?: string }> {
        const response = await this.request('/health');
        return response.ok ? { ok: true, message: 'Connected.' } : { ok: false, message: `HTTP ${response.status}` };
    }

    async enrichTrack(ref: TrackRef): Promise<Partial<TrackEnrichment>> {
        if (!ref.isrc) return {};

        const response = await this.request(`/recordings/${encodeURIComponent(ref.isrc)}`);
        if (!response.ok) {
            await response.body?.cancel().catch(() => {});
            this.host.logger.warn('lookup failed', { isrc: ref.isrc, status: response.status });
            return {};
        }

        const body = await jsonBody<{ year?: number; genres?: string[]; label?: string }>(response);

        return {
            year: body.year,
            label: body.label,
            genres: this.includeGenres ? body.genres : undefined,
            links: [{ label: 'Record Bin', url: `https://example.com/recordbin/${ref.isrc}` }],
        };
    }

    private async request(path: string): Promise<Response> {
        return await this.host.fetch(`https://api.recordbin.example.com${path}`, {
            headers: { authorization: `Bearer ${this.apiKey ?? ''}` },
            timeoutMs: 5_000,
        });
    }
}

export default definePlugin(manifest, () => new RecordBinPlugin());

What host.fetch gives back

A real Response. Not a copy, not a POJO: await response.json() is how you read JSON, response.body is how you stream audio, and you can hand it to any library that takes one. That is what lets the Spotify SDK's fetch hook be wired straight through instead of adapted in both directions.

Two things about it are the host's doing rather than the platform's, and both are part of the contract:

  • url is the last hop of the redirect chain, not necessarily what you asked for, so relative links in the body resolve against it. redirected tells you whether it moved. The host follows redirects by hand to re-check your allowlist on every hop, so it sets both itself.
  • The body is bounded. timeoutMs covers getting the response (connect, headers, the whole redirect chain) and stops there, because a large body legitimately outlives the call that asked for it. Reading it is bounded separately by an idle deadline between chunks, a total byte cap and a lifetime cap. Exceeding any of them fails the read rather than truncating it, so bytes you get are always bytes the server sent.

A body you are not going to read is one you should cancel(). The host force-cancels whatever you still hold when your plugin is disposed, and the lifetime cap catches the rest, but neither is prompt. On an error path, let it go yourself:

if (!response.ok) {
    await response.body?.cancel().catch(() => {});
    throw new PluginError(`upstream said ${response.status}`).withCode('upstream');
}

jsonBody(response) parses the body and throws with the status, the URL and the start of the body when it will not parse, which beats Unexpected token < in JSON at position 0 when an API answers a 200 with an HTML error page. tryJsonBody(response) returns undefined instead of throwing. Both are async, and both are ordinary functions rather than methods, so the response you hold stays the platform's own.

The host sets a User-Agent for you (<your plugin id>/<version> (deadair)) when you do not set one yourself, because a number of APIs refuse the default one Node sends. Set the header yourself when the upstream's policy asks for more than that: MusicBrainz wants a contact address, which usually means a contact config field the operator fills in. Yours always wins.

An upstream that answers is not a failure: a 404 or a 500 comes back as an ordinary Response with ok: false, and what it means is yours to decide. host.fetch only rejects when there is no response to give you, and when it does it rejects with a PluginError carrying the same PluginErrorCode vocabulary your own failures use, so you can branch on it:

  • upstream — the request never completed, or the server misbehaved (an unreachable host, a redirect chain past the cap, a redirect somewhere your manifest does not allow, a body over the size cap).
  • timeout — the host abandoned the call at its deadline, or the body went quiet for longer than the idle deadline allows.
  • rate_limited — you are over the host's fetch quota by more time than the call had left. retryAfterMs says how long the wait would have been.
  • forbidden — the hostname is not in your permissions.network.
  • config — the URL did not parse, or was not http(s). Usually an operator setting you built it from.

The body's own failures arrive the same way, on the read rather than on the fetch, because that is when they happen.

Let these propagate unless you can do something better with them. The host maps each one to a status and error code for the operator console, and swallowing them turns a precise answer into a silent empty result.

When the upstream answers with a failure of its own, describe it on the error rather than in a field you invent. withUpstreamStatus takes the HTTP status and withUpstreamCode takes the service's own code, as it sent it, a number or a string (Last.fm's 6, Spotify's reason). Both reach the host's logs through the invoker's wrapping, and neither is ever sent to a client. A subclass that branches on the code reads this.upstreamCode; it must not declare a field of that name, because the base class already owns it:

if (body.error !== undefined) {
    throw new PluginError(body.message).withCode('not_found').withUpstreamStatus(response.status).withUpstreamCode(body.error);
}

When the body should not arrive whole

Nothing special. response.body is a ReadableStream<Uint8Array> and you read it, or pass it on, or pipe it through something:

const response = await host.fetch(`${this.baseUrl}/audio/speech`, { method: 'POST', body });
if (!response.ok || response.body === null) {
    await response.body?.cancel().catch(() => {});
    throw new PluginError(`engine said ${response.status}`).withCode('upstream');
}

return { mime: 'audio/mpeg', audio: response.body };

There was once a second egress here, host.streams, with handles, sequence numbers, base64 chunks and an idempotent close(), because a live object could not cross the boundary. All of it is gone: a ReadableStream is already a pull-based stream with backpressure and a cancel, and the boundary is a function call. See CLAUDE.md § "Trust and egress".

What survives is the bounds, and they are the reason it was ever thought about: a body is read outside the deadline that fetched it, so an idle deadline, a lifetime cap and a byte cap are what stop it being an unbounded socket.

Declaring the upstreams you reach

permissions.network is a list of bare hostnames, and a leading *. is a wildcard subdomain that does not match the apex:

network: ['api.spotify.com', 'accounts.spotify.com'];

Use the object form when the upstream publishes a rate limit. host.fetch then paces you at it, parking each call until there is headroom instead of failing it, and you write no pacer at all:

network: [
    { host: 'musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
    { host: '*.musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
    'coverartarchive.org',
];

bucket is what makes those first two entries share one allowance. Published limits are usually per service rather than per hostname, and two entries without a shared bucket are two allowances, which is how a plugin ends up at twice the rate it declared and the station gets blocked. Entries with different buckets never pace each other, so a slow upstream does not hold up a fast one.

When the operator picks the address (a mirror, a self-hosted server), name the config field it lives in instead of a hostname:

configFields: [{ key: 'baseUrl', label: 'Server URL', type: 'url' }],
permissions: {
    network: [
        { host: 'musicbrainz.org', ratePerSecond: 1, bucket: 'musicbrainz' },
        { fromConfig: 'baseUrl', ratePerSecond: 10 },
    ],
    ...
}

The host reads the hostname out of that setting when your plugin is initialized, so changing it takes effect on the reinitialization the save triggers. A setting that is blank or unparseable contributes no entry, so an unconfigured plugin is refused exactly as if the host were undeclared, and a wildcard is never accepted from a setting: those come only from a manifest an operator could read before installing.

Order it after the hostname you know, as above. First match wins, so an operator who points baseUrl back at the canonical service still gets the strict rate rather than the mirror's.

A setting holding SEVERAL addresses contributes one entry each, which is how a plugin pointed at a list the operator pasted — a reader of feeds — declares upstreams it cannot know at authoring time. One address per line (a text field), or the JSON array a multiselect stores, and where a line carries more than the address the address is its last |-separated field:

configFields: [{ key: 'feeds', label: 'Feeds', type: 'text' }],
permissions: {
    network: [{ fromConfig: 'feeds', ratePerSecond: 1, bucket: 'rss' }],
    ...
}
https://example.com/rss.xml
world|World news|https://example.com/world.xml

Every rule above is applied per address rather than to the value as a whole, so one mistyped line costs its own upstream and not the rest. Repeats collapse into one entry, or two feeds at one publisher would install a second limiter and quietly double the rate you asked to be paced at. A shared bucket is usually right here, because what is being paced is your own outbound rate rather than any one publisher's published limit.

The rate is a floor on the interval, not a promise of throughput: the host caps every bucket at its own ceiling, so asking to go faster than the host allows does nothing. Parking is spent from the call's budget, which is the other half of this: see below.

Knowing how much time you have

Every call into your code has a deadline, and it is not a constant: a background job may run on a far longer budget than a request someone is waiting on, and the same method of yours gets called both ways. Two things tell you about it, and they answer different questions.

host.signal is an AbortSignal that fires when the host gives up on the call. It is the host's own signal, not a copy, so honouring it and being abandoned are the same moment rather than two clocks that nearly agree. host.fetch watches it for you; pass it on to anything else of yours that takes one. Outside any host call (from a timer you set yourself) it is a signal that never aborts, because nothing is waiting on that work.

host.remainingMs() is the number behind it, for deciding whether to START something rather than for being interrupted during it. Reach for it when the work is a sequence whose later steps are optional, which is the usual shape for anything paced against a rate-limited API:

const core = await this.lookup(ref);
if (host.remainingMs() < 2_000) return core; // good enough, out of time
return { ...core, ...(await this.enrich(core)) };

What it saves you from is hardcoding a guess at the host's deadline, which means either quitting early on a budget you actually had or being killed halfway through with nothing to return.

It is the host's clock, not an allowance: everything the call does spends from it, including the time host.fetch parks waiting for rate-limit headroom, and host.fetch caps its own timeout by it. Read a small number as advice to wrap up, not as permission to run that long.

Knowing whether the station is on air

host.air says whether the station is on air: it has a programme to play and somebody to hear it, or the operator has told it to air regardless. It goes off when the running order runs out, when the operator stops the station, and once the last listener has been gone for a few minutes. A listener dropping out for a moment does not move it.

It is for a plugin holding something expensive that is only worth holding while the station airs, a speech model on a graphics card being the case it was written for. The tempting alternative is a timer since you were last called, and that is a guess at this fact: a station can be on air with nothing to say for longer than any idle window, and off air a second after your last call.

protected async onLoad(): Promise<void> {
    const host = this.host;
    this.holdModel(host.air.isOn());
    this.register(host.air.onChange(on => this.holdModel(on)));
}

isOn() reads the current state, which is off until the station first decides, so read it at load rather than waiting to be told. onChange hears each change once, with the new state. You are told just after the change rather than inside whatever made it, and outside any host call, so host.signal never aborts in there. A listener that throws or rejects is logged and never counted against your plugin. Disposing your plugin drops any listener it still holds; return the unsubscribe from a register disposer anyway, as above.

When your platform talks over a socket

Some platforms only deliver over a WebSocket you hold open: Slack's Socket Mode and the Discord Gateway, where button presses and slash commands arrive and nowhere else does without a public URL. host.socket opens one, under the same policy as host.fetch:

const socket = await host.socket(url);               // wss: only, host in permissions.network
socket.onMessage(text => this.handle(JSON.parse(text)));
socket.onClose(code => this.reconnectLater(code));
socket.send(JSON.stringify({ op: 1, d: null }));

Requires the sockets permission, and the socket's host must be in permissions.network like any other upstream. A connect costs one token from that host's rate bucket. Text frames only; a binary frame is dropped.

The socket outlives the call that opened it, so no invocation deadline applies to it. The host bounds it instead: a frame over a few MiB closes it, a plugin may hold only a handful at once, and disposing the plugin closes every one it still has, without calling your close listeners (there is nobody left to reconnect). Stop your own client in a register disposer anyway; the host's close is the backstop.

send after a close does nothing rather than throwing, since the likeliest sender is a heartbeat timer, and a listener that throws is logged rather than allowed to reach the event loop. The socket is the shape ServerKit's Socket Mode and Gateway clients call SocketLike, so connect: url => host.socket(url) is all either needs.

When your device speaks its own protocol

Some devices are neither HTTP nor a WebSocket: a Chromecast is driven over a TLS connection to port 8009 carrying its own length-prefixed messages. host.tls opens one, under the same policy again:

const conn = await host.tls('tls://192.168.1.20:8009', { verifyCertificate: false });
conn.onData(bytes => this.reader.push(bytes));       // bytes, not frames: reassemble your own
conn.onClose(reason => this.reconnectLater(reason));
conn.send(frame);

Requires the tls permission. The URL is tls://host:port and the port is required, since tls: has no default. The host is checked against permissions.network as the https: URL on the same host and port, and a connect costs one token from that host's rate bucket.

verifyCertificate: false is for a device on the operator's own network that presents a certificate no public root signs. It is honoured only for an address that came from your settings (a { fromConfig } entry), because that is the operator saying "this device is mine". For a host your manifest names, or one reached through network.open, it is refused with forbidden.

Like a socket, the connection outlives the call that opened it: a plugin may hold only a limited number at once (more than sockets, since a speaker plugin holds one per device), and disposing the plugin closes every one without calling your close listeners. send after a close does nothing, and a listener that throws is logged.

When you need to find devices on the network

A plugin that drives devices on the operator's network can look for them rather than asking for every address. host.discover runs an mDNS or SSDP query from the host, which owns the multicast sockets so no plugin binds those ports itself:

const casts = await host.discover({ protocol: 'mdns', service: '_googlecast._tcp' });
// [{ name, address: '192.168.1.148', port: 8009, txt: { fn: 'Kitchen speaker', md: 'Google Home Mini' } }]
const renderers = await host.discover({ protocol: 'ssdp', searchTarget: 'urn:schemas-upnp-org:device:MediaRenderer:1' });
// [{ name: 'uuid:...', address: '192.168.1.176', location: 'http://192.168.1.176:44667/description.xml', server: '...' }]

Each query must be listed exactly in permissions.discovery. Every address that answers (and the host of an SSDP location) is then reachable by fetch, socket and tls as though the operator had typed it in, including verifyCertificate: false, until the plugin is reinitialised.

An empty answer is normal: a station in a container on a bridge network cannot see multicast at all. Keep working from the addresses you were given, and do not report "nothing found" as a fault.

When your audio needs a helper to fetch it

Almost every provider answers resolveStreamUrl out of its own head: it knows a URL, it signs one, it hands it back. host.trackFetcher is for the one shape that cannot — audio that is reachable, but only to a process speaking a protocol you do not.

Spotify is the case it exists for. Its tracks come off the CDN encrypted, so there is no URL to mint at all; a separate binary runs beside the audio player, speaks Spotify's own protocol, and re-serves the track as plain audio over HTTP. You lend it a login, and get back the URL you were going to return anyway:

async resolveStreamUrl(trackId: string): Promise<ProviderStream | undefined> {
    const session = await this.currentSession();          // your account, your refresh
    if (!session) return undefined;                       // not connected yet

    return host.trackFetcher.serve({ trackId, session });
}

The login goes to the fetcher and nowhere else: it is not stored, not written to config, and not readable back out of the host. serve resolves to undefined when the operator's station has no fetcher configured, which you pass straight through — an item nobody can resolve is skipped, not an error.

Requires the trackFetcher permission. Do not reach for it when your audio can simply be fetched. Mint the URL yourself and keep your credentials to yourself, which is both simpler and narrower.

Declaring the permission is also what puts the console's playback authorization card on your plugin's page, where the operator authorizes the fetcher itself. Declaring stream does not: a plugin that mints its own URLs has nothing there to authorize.

When your own API will not hand over a playlist

The fetcher holds a login of its own, and that login is sometimes allowed to read what your API refuses you. Spotify is the case again: since February 2026 its Web API returns a playlist's items only to the account that owns them, so a playlist the account merely follows lists fine and answers 403 for its contents — while the fetcher, which speaks the streaming client's protocol, can still read it.

async getPlaylistTracks(playlistId: string, options?: GetPlaylistTracksOptions): Promise<ProviderTrack[]> {
    try {
        return await this.readFromYourApi(playlistId, options);   // always ask your own API first
    } catch (error) {
        if (!isRefusal(error)) throw error;
        const tracks = await host.trackFetcher.playlistTracks({ playlistId, ...options });
        if (!tracks) throw error;                                 // no fetcher here: answer as you did before
        return tracks;
    }
}

The two failures mean different things. undefined is "this station has no fetcher", the same as serve, and your plugin should answer whatever it would have answered without this method. A throw is the fetcher having tried and failed, which is a provider failure like any other.

Nothing is left out of a page, so a short page means the end of the playlist and you can walk one exactly as you walk your own API's. Ask your API first. This is a second protocol on a session the station also airs from, and spending it to learn what you could have been told is the way to make a listing cost a broadcast.

Enriching artists and albums, not just tracks

enrichTrack is the only method an enrichment plugin must write. enrichArtist and enrichAlbum are optional, and worth writing for anything that belongs to the artist or the record rather than to one recording:

async enrichArtist(ref: ArtistRef): Promise<Partial<ArtistEnrichment>> {
    // `mbid` is the id that crosses providers; `providerRef` is your own id
    // from the last time you answered about this artist. Both may be absent
    // the first time anything asks, in which case you have a name.
    const id = ref.providerRef ?? (await this.search(ref.name));
    if (!id) return {};

    const body = jsonBody<{ bio?: string; image?: string }>(await this.request(`/artists/${id}`));
    return { providerRef: id, biography: body.bio, imageUrl: body.image, externalIds: [{ source: 'recordbin', id }] };
}

The reason to split them is cost, not tidiness. The host asks once per artist and once per album, so an artist who appears on forty tracks is one request rather than forty, and the answer is stored against the artist where the console and the DJ can both read it. Anything you return from enrichTrack is still stored against the track, so a single with no album, or a compilation whose tracks were licensed separately, is still described correctly by TrackEnrichment.label.

The providerRef you return is remembered as the id that answer was fetched under, and handed back to you — and only to you — as ref.providerRef next time. That is what turns a second pass into a lookup instead of another search, so state it whenever you have one.

It is deliberately separate from externalIds, which answers a different question: what this thing is called elsewhere. List those in whatever order you like, including ids that are not yours. A local library that reads a MusicBrainz id out of a file's tags should absolutely report it, and doing so must not cost it its own ref.

Facts, and the prose facts are extracted from

facts and documents are both things to say about a record, and the difference between them is who wrote the sentence.

A fact is a line you composed and are willing to have read out on air unchanged. Keep them short and independently speakable, because that is what happens to them: a talk break is shown a couple of them and the DJ works one in. Compose them only out of things you actually know — a fact assembled from a field you guessed at is a station saying something untrue in a confident voice.

A document is somebody else's prose, verbatim: an encyclopaedia article, a song description, a set of sleeve notes. Nothing reads one aloud and nothing renders one on a page. The host extracts claims from it, checks each claim against the text it came from, and keeps the citation. So:

return {
    documents: [{ url: article.url, title: article.title, text: article.extract, retrievedAt: new Date().toISOString() }],
};

Three rules make that worth doing.

Hand over the prose, not your summary of it. The host stores the document, so a better extraction later costs your upstream nothing, and a claim's quoted span has to occur in the text you supplied or the claim is dropped. A summary you wrote is a span nobody can check.

Plain text. Strip the furniture a reader ignores anyway: navigation, licence footers, reference markers, markup. What is left reaches a language model, and eventually a mouth.

url is a citation, not a link. It is the address an operator opens to check whether the station is telling the truth about a record, so it has to be somewhere a person can actually read the text you sent. A document whose URL is not http(s) is dropped whole.

Searching the open web

A search plugin has one method, and the caller supplies the subject:

async search(query: SearchQuery): Promise<SearchResult[]> {
    const hits = await this.engine.run(query.query, query.limit, query.recency);
    return hits.map(hit => ({ title: hit.title, snippet: plainText(hit.description), url: hit.url, site: hit.profile }));
}

Three things about it are easy to get wrong.

A snippet is plain text. Search APIs are the worst offenders in the SDK for this: Brave wraps every matched query term in <strong> unless you pass text_decorations=0, and descriptions carry HTML entities either way. Run them through plainText from html.text.ts. What is left reaches a language model, and possibly a mouth.

There is nowhere to put a synthesized answer, deliberately. Several engines sell one — Tavily's answer, SearXNG's first infobox — and it is a paragraph somebody else's model wrote about pages this station never sees. Nothing can check it against a source, so nothing here can carry it. If your plugin has prose worth extracting claims from, declare enrichment too and return it as a documents entry, where the provenance and the quote check already live.

An empty array is an answer. An unconfigured plugin, an engine that is down, a rate limit and a query nothing matched are one outcome to every caller. Throw only for something the operator has to go and fix.

A subject that is not a record goes through enrichment, too. When an operator asks the station to research a programme's subject, the host asks every enrichment plugin that implements the optional enrichTopic({ query }) for documents about it, and reads them for claims exactly as it reads an artist's. Hand back only pages your plugin has the operator's leave to quote; the web search plugin answers from its trusted sites and nowhere else.

Note what search is not. Looking for something to PLAY is searchTracks on the catalog capability, which answers with provider ids the station can resolve into audio. Nothing a search plugin returns can be scheduled.

Reading a page somebody named

A news plugin may implement one optional method more, readPage, and the station asks it when an operator gives a production a web page to make a programme about:

async readPage(url: string): Promise<SourceDocument | undefined> {
    return await readDocument(this.host, url, { timeoutMs: 15_000 });
}

readDocument in article.parse.ts is the whole of a typical implementation: it fetches the page through host.fetch, keeps its prose the way fetchArticle does but far more of it (DOCUMENT_MAX_CHARS), and takes the title the page gives itself (extractTitle: its og:title, then <title>, then the first <h1>).

It belongs on a plugin that can already open whatever page a feed links to, because that is the only kind with a network grant wide enough: the address is on no allowlist a manifest could write down, so the plugin needs network.open, which the operator answers. Answer undefined for a page with no prose on it. Throw for one that could not be read, and the station says which site it was when it fails the production.

Carrying podcasts

A podcast plugin says what programmes the station subscribes to and what each has published. Two methods are required and one is optional:

async listShows(): Promise<PodcastShow[]> {
    return this.subscriptions.map(row => ({ id: row.id, title: row.name, feedUrl: row.url }));
}

async listEpisodes({ showId, limit, since }: PodcastEpisodesQuery): Promise<PodcastEpisode[]> {
    const feed = await fetchFeed(this.host, this.feedFor(showId));
    return feed.items
        .filter(item => item.enclosure !== undefined)                 // no audio, no episode
        .map(item => ({ id: item.id, showId, showTitle: feed.title ?? '', title: item.title,
                        durationMs: item.durationMs, audio: { url: item.enclosure!.url } }))
        .slice(0, limit);
}

parseFeed reads a podcast feed's enclosure and itunes:duration for you, so most of a feed-reading podcast plugin is the mapping above. Four things about it are easy to get wrong.

You never fetch the audio. audio.url is an address the HOST fetches, once, hours before the episode is due on air, into the station's own store. An episode is far larger than any body a plugin is allowed to read through host.fetch, and when it is wanted is a station decision you cannot see. So the address has to carry whatever it needs, like resolveStreamUrl's: it is fetched with no headers from you.

An id is a promise. The station remembers which episodes it has fetched and aired by PodcastEpisode.id, so the same episode must have the same id on every call, or it is fetched and aired twice. FeedItem.id already keeps that promise.

Leave out what cannot be carried. An entry with no enclosure, or one at an address nothing can fetch, is not an episode the station can air; handing it over only makes the host drop it. And durationMs is the publisher's claim, passed on as one. Do not estimate a length from a file size.

An empty array is an answer, on the terms search and news set: a show whose feed is down, an id you no longer recognise and a show with nothing published are one outcome to the host, and one show's bad day must not cost the others.

searchShows is a directory, for an operator looking for something to subscribe to, and it answers with PodcastDirectoryEntry rather than PodcastShow: its id is the directory's own and its feedUrl is what subscribing needs. What a subscription IS stays yours, which is why there is no method for it.

Note what this capability is not: a catalog. An episode is not a record, and every rule the station applies to a record, from rotation and artist spacing to crossfading and scrobbling, is wrong for an hour of somebody else's programme. The station carries an episode the way it carries a programme it made itself: one item, aired whole, as speech, when a clock band names the show.

Narrating

A narration plugin offers text the station reads out, verbatim, as a programme: a book by the chapter, a newsletter by the issue, a queue of long reads. Three methods, all required:

async listSeries(): Promise<NarrationSeries[]> {
    return this.books.map(book => ({ id: book.id, title: book.title, order: 'serial',
                                     author: book.author, language: book.language }));
}

async listPieces({ seriesId, limit }: NarrationPiecesQuery): Promise<NarrationPiece[]> {
    const book = await this.open(seriesId);                           // [] for an id you do not know
    return book.chapters.slice(0, limit).map((chapter, ordinal) => ({
        id: `${seriesId}:${chapter.key}`, seriesId, seriesTitle: book.title,
        title: chapter.title, ordinal, wordCount: chapter.words,
    }));
}

async getText({ seriesId, pieceId }: NarrationTextQuery): Promise<NarrationText | undefined> {
    const chapter = await this.chapter(seriesId, pieceId);
    if (chapter === undefined) return undefined;
    return { parts: chapter.paragraphs.map(text => ({ text })) };     // one part per paragraph
}

Five things about it are easy to get wrong.

You never make the audio. The station speaks it, on whichever engine the operator chose, which is what gets your text the pronunciation lexicon, the performance cues, the presenter's voice and a real loudness measurement. A plugin that synthesised would arrive having skipped all of it, and would be fighting the presenter for the one engine slot the station has.

Say which ORDER the series is in. serial starts at the beginning and works through, once each; latest airs the newest piece and nothing once it has aired. Nothing can infer it, and the two failures are a book read back to front and a newsletter stuck on issue one forever.

An ordinal describes the series, not your answer. Chapter four is 3 whether or not the first three were in the page you returned. The station is the one keeping its place; listPieces is the table of contents, not a cursor, so answer a serial from its START and let the host work out what it has aired.

Strip the furniture, and mind the brackets. [Illustration: …], [Footnote 12], page numbers and running heads are not the author's words and a listener cannot place them. Square brackets matter twice over, because that is how a script marks a sound effect and a performance cue: bracketed text that reaches the speech path is read as an instruction rather than as words, so at best it disappears and at worst the presenter coughs mid-sentence.

An id is a promise, on the terms podcast sets and for a dearer reason. The station remembers what it has rendered and aired by NarrationPiece.id, and rendering a chapter costs real time on the station's own engine, so a plugin that renumbers pays to speak the same chapter twice and then airs it twice.

Note what this capability is not: news. Both hand over somebody else's words, and the line between them is what the station does with them. A news item is a fact to MENTION, and what airs is a sentence a model wrote about it; a narration piece is read as it stands, and reading it out is the whole programme. A source whose text would need summarising is a news source even if it publishes books.

Saying when an artist is next in town

A concerts plugin has one method, and the caller supplies the artist and the place:

async getConcerts(query: ConcertQuery): Promise<Concert[]> {
    const listings = await this.search(query.artist, query.place);
    return listings.filter(show => show.performers.includes(query.artist)).map(toConcert);
}

Match the artist exactly. Ticket services search on keywords, and what you return is said on air as a fact about that artist, so a tribute night or a similarly named band must be dropped, not ranked lower. Answer only with what is on. Leave out anything cancelled, postponed or undated. An empty list is the ordinary answer. A service that refused throws, for a weather plugin's reason.

Saying what it is like outside

A weather plugin has one method, and the caller supplies the place:

async getWeather(query: WeatherQuery): Promise<WeatherReading | undefined> {
    const point = await this.resolve(query.place);          // your service, your geocoder
    if (point === undefined) return undefined;              // nowhere of that name

    const forecast = await this.forecast(point, query.days ?? 0);
    return { place: point.name, observedAt: forecast.time, current: forecast.now, days: forecast.days };
}

Three things about it are easy to get wrong.

Everything is metric. Celsius, km/h, and no unit field anywhere. What a station SAYS is a station's own decision, settled where the words are made, beside every other decision about how that station talks. A plugin converting would make the units on the wire depend on which plugin was installed, which is the one thing a capability must not let happen. It is ConfigField.unit's rule one layer down.

You resolve the place, and the host never geocodes. query.place is a name somebody typed. Turning it into coordinates is exactly the per-service quirk this boundary exists to absorb — one service ships a geocoder, one takes coordinates only, one wants its own city ids — and a host that geocoded would have to pick one service to geocode with. Answer with the place as YOUR service resolved it, because that is the part a presenter says out loud and the only evidence anybody has that the right town was found.

A condition is a closed vocabulary. WeatherCondition is ten arms. WMO code 73, an icon string of snow and a numeric condition id are three spellings of one thing, and mapping them is yours. Your service's own word survives beside it as description, which nothing deterministic reads and a model may use.

An hourly outlook is a span, and each row says when it starts. query.hours asks for the next that-many hours, and reading.hours answers with whatever rows your service has inside them, an hour or three hours apart. Put the place's offset on every at: a caller says "by midnight" from it, and a bare UTC instant cannot say what the clock reads there.

undefined is an answer, on the same terms as a search plugin's empty array: an unconfigured plugin, a place nothing could resolve and a service that is down are one outcome to every caller. Throw only for something the operator must fix.

Saying what happened on a date

An almanac plugin has one method, and the caller supplies the day:

async getDay(query: AlmanacQuery): Promise<AlmanacDay | undefined> {
    const day = await this.fetchDay(query.month, query.day);   // your service
    if (day === undefined) return undefined;                   // nothing for that date

    return { date: `${pad(query.month)}-${pad(query.day)}`, entries: day.entries.map(toEntry) };
}

Three things about it are easy to get wrong.

The caller decides which day it is, and you never ask. The query carries a month and a day because "today" is the wrong question: the station's day is the day the break AIRS, in the station's own timezone, and a break written at ten to midnight is about tomorrow. A plugin reading its own clock would be a server in one timezone deciding what day a station in another is having. Echo the date back on AlmanacDay.date so a caller can tell your answer from that mistake.

An entry is read verbatim, so compose nothing. AlmanacEntry.text is the source's own sentence, and the station's floor writer takes it whole — that is what makes the claim and its evidence the same span, the rule the fact store keeps. Rewriting an entry into something more broadcastable hands the station a sentence with no source, in the voice it uses for things that are true. Send the address it came from on url, which is what an operator opens when something sounds wrong on air.

Pass the descriptions on. A caller leaning toward its own subject matter — a music station reaching for the guitarist before the general — decides that from AlmanacSubject.description, the source's one-line "Portuguese guitarist". There is deliberately no way to ask you for the musicians: what a station says is the station's business, and a plugin that filtered would make its character depend on which plugin was installed. Dropping the descriptions makes every entry you send look like it is about nothing in particular.

undefined is an answer, on a weather plugin's terms: an unconfigured plugin, a service that is down and a date with nothing on it are one outcome to the caller. A day that has events and no births is NOT one of those and is answered — whether that is enough to say anything is the station's decision.

Talking on a chat platform

A messaging plugin connects the station to Telegram, Slack, Discord or anything else people chat on. It has two methods and two optional ones:

async receive(query: MessagingReceiveQuery): Promise<MessagingReceiveResult> {
    const updates = await this.poll(query.cursor, query.waitMs);   // your platform's long poll
    return {
        messages: updates.filter(u => this.listensIn(u.chat)).map(toInboundMessage),
        cursor: updates.length > 0 ? String(updates.at(-1).id + 1) : undefined,
    };
}

async send(message: OutboundMessage): Promise<MessagingSendResult> {
    const answer = await this.post(message.chatId, escapeFor(this.platform, message.text));
    return answer.ok ? { delivered: true } : { delivered: false, reason: answer.summary, retryable: answer.status >= 500 };
}

Four things about it are easy to get wrong.

The host asks, and you never push. A plugin has no inbound HTTP and no way to call the station, so the host calls receive in a loop of its own, one call at a time, and stores the cursor you return. Hold the call open for up to waitMs where your platform has a long poll. Advance the cursor past everything the platform delivered, including what you filtered out, or the same messages come back forever. No cursor at all means "from now", never "replay everything".

A platform that pushes holds a socket instead. Where messages, slash commands and button presses arrive over a WebSocket (Discord's Gateway, Slack's Socket Mode), open it with host.socket, queue what it hears, and have receive drain the queue, waiting on it for up to waitMs. Return no cursor. Start the session on the first receive rather than in onLoad, so a failure to connect is the poll's failure and shows on your plugin's page. plugins/discord is the worked example, including acknowledging an interaction the moment it arrives, since the host answers when its turn comes.

Which chats count is yours to decide. Chat ids are your platform's vocabulary, so the list of chats the station listens in is your configuration, and anything you return from receive is something the host acts on. Drop messages from bots, including your own.

What people write is untrusted. Pass text through verbatim and let the host treat it as data. Never reshape it into something that reads like the station's own words.

Outbound text is plain, and escaping is yours. Only you know which characters your platform treats as formatting. Classify refusals on retryable exactly as a scrobble plugin does: a rate limit or a 5xx is worth another go, a chat the bot was removed from is not, and when unsure say no.

Buttons come back as actions. An OutboundMessage may carry buttons ({ id, label, value? }). A press comes back from receive as a message with action: { id, value } and empty text, exactly as they were sent; how they travel through your platform is yours to encode and decode. A platform with no buttons renders them as text or drops them.

announceTargets() says which chats want which announcements (today only nowPlaying); leave it out and the station only ever answers when spoken to. accepting() is the operator's off switch, as it is for scrobbling. commands(list) is told the station's commands ({ name, description, takesArgs }) each time the host starts listening on your plugin, for a platform that lists commands in its own interface; overwrite, don't append. Leave it out and people type the commands as they always could.

Finding the words of a record

A lyrics plugin answers one question, the words of one record and the timing of its lines:

async lyricsFor(ref: TrackRef): Promise<TrackLyrics> {
    if (ref.durationMs === undefined) return {};          // no length, no strict match
    const found = await this.lookup(ref.artist, ref.title, ref.album, ref.durationMs);
    if (!found) return {};
    if (found.instrumental) return { instrumental: true, providerRef: found.id };
    return { plain: found.text, synced: found.lines, providerRef: found.id };   // lines: { atMs, text }
}

Four things are easy to get wrong.

The host reads them and never says them. A lyric is somebody's copyrighted text in full, so the host keeps it in a table of its own, never serves it, never extracts a fact from it and never shows it in the console. What the station uses is when the singing starts and stops, and later labels derived from the words. That is why this is not an enrichment field: enrichment is merged, served and shown.

Match strictly, and answer a near miss with {}. A lyric matched to the wrong recording does not fail; it gives the station a confident wrong timing. Send the record's length wherever your source keys on one, and do not fall back to a looser search.

A refusal is a throw, not {}. A source that refuses your client and reads as a miss is a station whose lyrics count stays at zero with nothing in the log saying why.

Leave endMs unset unless your source states it. The host derives a line's end from the next line's start, and a blank timed line (which LRC uses to mark where singing stops) is kept as one. An instrumental answer is an answer: the host stops asking and treats the record as one nobody sings on.

Set words on a line only when your source times its words. Each piece is a word or syllable with the time it starts being sung, in order, and the pieces' texts joined must be the line's text exactly, spaces included: a reader lights a prefix of the line, so the host drops a line's words whole when they do not join up, go back in time, or number more than 200, and shows it as a line.

Playing the station on a speaker

An output plugin puts the station on a Chromecast, a Sonos, a BluOS player or anything else that can be told a URL and will fetch it. Five methods:

async listDevices(): Promise<OutputDevice[]> {
    return this.configured.map(row => ({
        id: row.address.toLowerCase(), name: row.name, address: row.address,
        accepts: ['audio/mpeg', 'audio/aac'], followsMetadata: true,
    }));
}

async play({ deviceId, url, contentType, metadata }: OutputPlayRequest): Promise<void> {
    await this.device(deviceId).load(url, contentType, metadata);   // resolves when accepted
}

updateMetadata, stop and status complete it. Four things are easy to get wrong.

The speaker fetches the stream. You are handed the mount's URL and its content type and pass them on. Never build a station URL and never pick a format: the host chooses the mount from what your device accepts, so list only what it really plays (a Sonos takes MP3 and AAC for a radio URL and nothing else).

The host owns retrying. It remembers every cast it started, asks status how each is doing, and plays one again when a device drops it. So play resolves when the device ACCEPTED the command, not when sound comes out, and you never reconnect a stream on your own.

Warming up is not failing. A live stream takes seconds to start. Report it as opening or buffering, and a device that does not answer as unreachable from status rather than a throw. List a configured device that does not answer anyway: the operator asked for it by name.

Stop means stop. A speaker playing the station is a listener, and one left playing holds the station's audience open. stop is idempotent: answer it for a device that was not playing ours.

A device that only speaks a protocol where the sender streams the audio itself (AirPlay audio, Bluetooth) cannot be an output: there is no URL to hand it, and the station encodes nothing in its own process.

Music providers

A music-provider declares any subset of five sub-capabilities and lists the ones it implements in manifest.capabilities:

  • catalog — searchTracks, getTrack, listPlaylists, getPlaylistTracks. Being browsable, and nothing more: a provider that can be searched but whose audio deadair cannot get at is a legitimate thing to be, and it declares this alone.

    SearchTracksOptions carries genre, yearFrom and yearTo beside limit and offset, structured and provider-neutral: the caller's query is free text going at a title and an artist name, and a style is a different axis. Express them however your upstream does — the host never learns one service's filter dialect, exactly as it never learns a speech engine's knobs.

    Two rules here are load-bearing, and both are about not lying quietly. A filter you cannot apply means you have nothing to offer for that search, so answer []; never ignore it and answer as though it had not been asked for, because the caller merges several providers into one list and nothing marks which rows honoured it. And limit is a TOTAL rather than a page size: page internally if your upstream's own ceiling is lower, since a short answer is indistinguishable from a genuinely thin search.

    listPlaylists follows the same rule from the other side: never leave a playlist out of a page. The host reads a short page as the end of the list, so one playlist dropped takes every playlist after it along. Report what you know about each one instead (permissions for what the source will let the account do, madeByProvider for a playlist the service made itself, such as an editorial list or Discover Weekly) and the host decides what to show.

    playlistIdFromUrl is optional, and it is how an operator imports a playlist the account does not list: they paste a link, the host asks every catalog provider whose it is, and the first to claim it is asked for its tracks with the id it answered. Keep it a pure parse of the text, with no network and no host, and answer undefined for anything you are not sure is yours, because the first claim wins.

  • stream — resolveStreamUrl: hand back a complete URL the audio consumer can fetch directly, carrying its own authentication, because it is fetched with no headers from us. A provider that cannot answer it plays its own audio and declares steer instead.

    How you get that URL is your business. Most providers mint one out of their own head. If your audio is reachable only to a process speaking a protocol you do not (Spotify's, whose tracks come off the CDN encrypted) lend the station's fetcher a login and return the URL it gives you back; see When your audio needs a helper to fetch it above.

    If fetching that URL takes longer than a download, say so with fetchBudgetMs. The host allows 90 seconds, which suits every provider whose audio transfers — the Spotify shim measures a 320kbps Ogg at 664ms. It does not suit a source whose audio is PERFORMED rather than transferred: a record captured in real time takes its own duration to arrive, and cut off at 90 seconds it looks like an upstream that hung. Send the worst honest case, in integer milliseconds; the host clamps it, never shortens its own floor, and a fetch that finishes early costs nothing. Do not reach for it because a provider is merely slow — a large number there only delays the retry that would have fixed it.

    This capability is a URL, not bytes. Audio that reaches the station as bytes through Node is the exceptional path and always was, even now that response.body makes it trivial: the audio consumer is Liquidsoap in a sibling container, so a URL it can fetch is zero copies and a stream through here is two.

    reportPlayback is the optional other half, for a service that expects the client that fetched a record to say it is being played. The host calls it with started when one of your records goes to air and ended, with the elapsedMs heard, when the next item does, from a job: late, retried on a thrown PluginError, and not guaranteed in order. Leave it out unless your upstream wants it. It is not a scrobble, which reports to somebody's listening history by title and artist; this carries the id you served.

    A refusal that belongs to the ACCOUNT rather than the record, such as a stream already playing on another device or a spent quota, should be thrown as unavailable or rate_limited (with retryAfterMs when you know it). The host then puts that fetch off without counting it against the copy, where any other failure counts toward benching it.

  • steer — enqueue, play, pause, skip, getPlaybackState. The provider owns the audio output and deadair only tells it what to do. Named from the plugin's side on purpose: deadair's own playout module is the opposite end of this, the one that owns the running order.

  • profile — getTopArtists(timeRange, options): the artists the connected account plays most over a short, medium or long window, most played first. Artists only: a ranking of tracks goes through listPlaylists, where it reaches the catalog. The host reads this rarely and keeps the answer, so a service with a tight quota is not asked on every refill. What it does with the answer is steer a model, never enforce anything, and a failed read costs the steer and nothing else. getRecentPlays(options) is optional: the account's latest plays, newest first, each with an ISO-8601 playedAt. Report them as the provider has them. The host removes the station's own airings, so a plugin never has to.

  • oauth — getAuthorizeUrl(state) and handleCallback(params). The host owns the redirect endpoint (host.oauth.getRedirectUri()) and the token vault (saveTokens / getTokens); you only build the authorize URL and exchange the code.

import { definePlugin, type MusicProviderPluginInstance, Plugin, type ProviderTrack } from '@deadair/plugin-sdk';

class LibraryPlugin extends Plugin implements MusicProviderPluginInstance {
    async searchTracks(query: string): Promise<ProviderTrack[]> {
        // ...
        return [];
    }
}

Speaking

A plugin that declares speech turns a line of text into audio. The result is a stream rather than a value, and the usual implementation is to hand back the engine's own response body:

class KokoroPlugin extends Plugin implements SpeechPluginInstance {
    async speak({ text, voice }: SpeechRequest): Promise<SpeechHandle> {
        const response = await this.host.fetch(`${this.baseUrl}/audio/speech`, {
            method: 'POST',
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify({ input: text, voice: this.voices[voice ?? ''] ?? this.defaultVoice }),
        });

        if (!response.ok || response.body === null) {
            await response.body?.cancel().catch(() => {});
            throw new PluginError(`TTS answered ${response.status}`).withCode('upstream');
        }

        return { mime: 'audio/mpeg', audio: response.body };
    }
}

That is the whole thing. The audio is never held whole on either side, the host reads it or cancels it, and cancelling reaches the socket without this plugin forwarding anything.

Three things that are easy to get wrong:

  • Let go of a refusal's body. speak throws instead of handing it over, so that cancel() is the only chance anything has to release it.
  • Check the size at the END, not on the first chunk. A server can dribble a short JSON error out in several pieces, so "was any of that plausibly audio" is only answerable once the stream stops. plausibleAudio is that check, a TransformStream that counts and throws an upstream error in flush when the body ends under MIN_PLAUSIBLE_AUDIO_BYTES; failing there fails the render loudly instead of storing a click. Pipe the body through it rather than writing your own: response.body.pipeThrough(plausibleAudio({ engine: 'kokoro', asked: `voice "${voice}"` })).
  • mime is the answer, not the request. SpeechRequest.format is a hint you may ignore; what you return in SpeechHandle.mime is what the station stores and later serves, and both consumers of station audio pick their behaviour from that header rather than from the bytes.

Voices

SpeechRequest.voice is an opaque id the operator chose (host, newsreader). Map it to whatever your engine takes, out of your own config, and fall back to your default for an id you do not know — a missing voice is worth a logger.warn and a rendered line, not a silent station.

The host never interprets that string, and that is deliberate. It is what lets one station voice be a named preset on one engine and a cloned reference clip on another, so swapping engines does not rewrite every persona. Engine-specific tuning belongs in your config, not in the request: the host should not be carrying knobs only one implementation understands. The one thing about a reading it does carry is a delivery, and that is a word rather than a knob; see Cues and deliveries.

Implement listVoices() if you have more than one, so the console can draw a list and preview them. It is optional, and a single-voice plugin is a legitimate thing to be.

Set SpeechVoice.spec on each one. It is an opaque token the host does not read — whatever identifies a RENDERING to you, like [email protected] — and its only job is keying the cached preview at GET /voices/{id}/sample. Without it that cache is keyed on the station voice ID, which is exactly the part that does not change when the operator remaps it u