Build a plugin
Moosic plugins are WebAssembly modules; write them in any language with an Extism PDK (Rust, Go, JavaScript, Python, C, Zig, .NET…). Plugins integrate external APIs (REST or anything speaking HTTP), add search results, buttons and modals, react to playback, and run background watchers, all sandboxed, hot-reloaded on every change, no Moosic restart ever.
Quickstart
The same minimal plugin, one search suggestion plus a now-playing notification, in your language of choice. A hook is just an exported function: JSON in, JSON out.
Setupcargo new --lib myplugin && cd myplugin
cargo add extism-pdk serde serde_json
# Cargo.toml: [lib] crate-type = ["cdylib"]
rustup target add wasm32-unknown-unknownCodeuse extism_pdk::*;
#[host_fn]
extern "ExtismHost" {
fn notify(text: String);
}
#[derive(serde::Deserialize)]
struct Track { title: Option<String>, artist: Option<String> }
#[plugin_fn]
pub fn on_track_change(payload: String) -> FnResult<()> {
let t: Track = serde_json::from_str(&payload)?;
unsafe { notify(format!("Now: {} · {}",
t.artist.unwrap_or_default(), t.title.unwrap_or_default()))? };
Ok(())
}
#[derive(serde::Deserialize)]
struct Search { query: String }
#[plugin_fn]
pub fn on_search(payload: String) -> FnResult<String> {
let s: Search = serde_json::from_str(&payload)?;
Ok(serde_json::json!({ "items": [{
"title": format!("You searched: {}", s.query),
"subtitle": "my first plugin",
}]}).to_string())
}Buildcargo build --release --target wasm32-unknown-unknownInstall & hot reload
One folder per plugin: a manifest next to your wasm module.
Folder layout<plugins dir>/
myplugin/
plugin.toml # manifest (see below)
plugin.wasm # your module The plugins dir is dir from the [plugins] section of Moosic's config.toml, or the default <config dir>/plugins; Moosic logs plugin dir: … on startup. Pointing dir straight at a single plugin's folder also works.
Hot reloadMoosic watches the plugins dir. Copy a new wasm or edit
plugin.toml and the plugin reloads in place within ~300 ms;
watch the log for plugin myplugin reloaded. A build
that fails to load keeps the previous version running. Your dev
loop is just cargo build … && cp … while
Moosic runs.
plugin.toml reference
Everything Moosic reads from the manifest.
name string default: requiredDisplay name shown in toasts, search sections (FROM <NAME>) and logs.
version string default: "0"Your plugin version, shown in the load log.
api int default: 1Plugin API version this plugin targets. Moosic refuses to load a plugin declaring an API it does not speak.
wasm string default: "plugin.wasm"Wasm module file name, relative to the plugin folder.
allowed_hosts string[] default: [] (no HTTP)Hosts the plugin may reach over HTTP: the permission surface a user reviews. Glob patterns supported. Empty or missing means every HTTP request is refused.
allowed_hosts = ["api.example.com", "192.168.1.50"]timeout_ms int default: 5000Wall-clock budget per hook call; the runtime kills the call when exceeded. Size generously for hooks that do slow network work (e.g. a slow upstream API).
poll_interval_secs int default: offWhen set, moosic calls the plugin’s poll export on this interval (clamped to ≥ 10s). Background watchers: download queues, feeds.
poll_interval_secs = 60[config] table default: {}Free-form key/value table handed to the plugin through the Extism config API. Service URLs, API keys. Only this plugin’s instance can read it.
[config]
url = "http://myservice.local:8686"
api_key = "..."[[menu]] table[] default: []Context-menu items this plugin adds. label is shown; clicking calls on_menu_click. context picks the surface: "song" = song rows (playlist, queue, search tracks).
[[menu]]
label = "Look this song up"
context = "song"Show a complete plugin.toml
name = "My Plugin"
version = "0.1.0"
api = 1
wasm = "plugin.wasm"
allowed_hosts = [] # add hosts before doing HTTP
timeout_ms = 5000
# poll_interval_secs = 60 # uncomment for a background poll hook
# [config] # free-form, read via the Extism config API
# url = "http://myservice.local:8686"
# api_key = "..."
# [[menu]] # context-menu items on song rows
# label = "Do something with this song"
# context = "song"Hooks · functions you export
Every hook is optional: export it and Moosic calls it, skip it and
nothing happens. Payloads are UTF-8 JSON strings. A hook that
errors or exceeds timeout_ms is logged and dropped;
it can never take Moosic down.
init exportSet up state, greet the log. Runs again after each hot reload.
Fires: Once after every (re)load of the plugin.
→ input: "{}"
← output: ignoredon_track_change exportNow-playing side effects: scrobble elsewhere, lighting, status.
Fires: A new track starts playing.
→ input: {"id": "...", "title": "...", "artist": "...", "album": "...", "duration": 253, "genre": "...", "year": 1994}
← output: ignoredon_playback_state exportReact to play/pause/stop without tracking tracks.
Fires: Playback state transitions.
→ input: {"state": "playing" | "paused" | "stopped"}
← output: ignoredon_track_finished exportFires on every transition regardless of moosic’s scrobble threshold; apply your own rules from played_secs.
Fires: A track ends (the next one starts, or playback stops).
→ input: {"id": "...", "played_secs": 181, "duration_secs": 253}
← output: ignoredon_search exportReturn suggestion rows. title required; subtitle and url optional (url opens in the browser on row click). primary / secondary add action buttons; clicking one calls on_action with your action string.
Fires: User presses Enter in the search field, a deliberate “search wider” ask. Library results stay on screen; your rows render underneath as FROM <NAME>.
→ input: {"query": "selected ambient works"}
← output: {"items": [{"title": "...", "subtitle": "...", "url": "...", "primary": {"label": "Add", "action": "add:123"}, "secondary": {"label": "Pick…", "action": "pick:123"}}]}on_action exportYour action strings are opaque to moosic; encode whatever you need. Return empty to finish (use notify to report), or return a modal: a titled list of choices. Rows without an action are informational; selecting a row calls on_action again, so flows chain (pick album → pick release → grab).
Fires: User clicks one of your action buttons, or a row in one of your modals.
→ input: {"action": "pick:123", "item": {"title": "...", "subtitle": "...", "url": "..."}}
← output: "", or {"modal": {"title": "...", "items": [{"title": "...", "subtitle": "...", "action": "grab:xyz"}]}}on_menu_click exportitem is the label you declared (one handler serves several menu entries).
Fires: User clicks a [[menu]] item you declared, on a song row.
→ input: {"item": "Look this song up", "song": {"id": "...", "title": "...", "artist": "...", "album": "...", "duration": 253, "genre": "...", "year": 1994}}
← output: ignored; use notify / on_action-style follow-upson_result_click exportFires alongside the default behavior (moosic opens url if present).
Fires: User clicks the body of one of your suggestion rows (not a button).
→ input: {"title": "...", "subtitle": "...", "url": "..."}
← output: ignoredpoll exportBackground heartbeat. Check a download queue, then notify + refresh_library when something landed. Needs poll_interval_secs in the manifest.
Fires: Every poll_interval_secs, while moosic runs.
→ input: "{}"
← output: ignoredHost functions · what you can call
The complete capability surface of a plugin. Import them through your PDK's host-function mechanism (see the quickstart examples).
notify(text: string) importUser-visible notification: toast in the modern UI, marquee in retro. Use sparingly; it interrupts.
log(msg: string) importLine in moosic’s log (RUST_LOG=info), prefixed with your plugin name. Free; use for debugging.
kv_get(key: string) → string importRead from your plugin’s persistent key/value store. Returns "" for missing keys. Survives hot reloads and restarts.
kv_set(key: string, value: string) importWrite to the store (setting "" deletes the key). Backed by one JSON file per plugin, namespaced by plugin id; no plugin can read another’s data.
refresh_library() importAsk moosic to re-fetch the current library view; call after your side effects landed server-side (a grab imported, a purchase synced).
HTTP (built in) importExtism’s http_request host function, exposed by every PDK as its native HTTP helper. Requests are only allowed to manifest allowed_hosts; anything else errors.
Security model
What a plugin can and cannot touch. Verified against the runtime source.
No filesystemThe wasm sandbox gets zero filesystem access: Moosic never grants
Extism allowed_paths, so WASI has no pre-opened
directories. Persistent state goes through kv_get/kv_set, backed by one file per
plugin that only Moosic touches.
No environment, no credentialsNo host environment variables are passed into the sandbox. Your
server passwords, API keys and the Moosic license never cross
into any plugin: hook payloads carry song metadata and search
text only, never URLs containing auth tokens. A plugin sees
exactly one secret source: its own [config] table,
which the user wrote for it.
HTTP is deny-by-defaultEvery HTTP request is checked against the manifest's allowed_hosts. An empty or missing list means no
network at all. This makes the manifest the permission surface a
user can review before installing: the plugin can talk to those
hosts, and nothing else.
Isolation between pluginsEach plugin runs in its own wasm instance with its own config and
its own kv namespace (keyed by plugin id). Plugins cannot read
each other's data, config, or memory. Memory and call time are
bounded per plugin (timeout_ms).