For developers
Build a plugin
A plugin adds something to somebody's media server: a metadata source for a collection nothing else identifies, extra detail on a film's page, a service we have not thought of. This is how to write one, package it, and get it listed.
The model, in one page
A plugin is a WebAssembly module that the server loads into its own process and calls. It has no filesystem, no ambient network, no way to browse the library, and no way to reach another plugin. It gets exactly the powers its manifest asks for and the owner approves, and nothing else. Even the one grant that touches the library only answers "do you have these ids", never "what do you have".
The way that is enforced is worth understanding early, because it changes how you debug: a permission is not a check, it is whether the function exists. A capability is not a permission check, it is whether the function exists. Ungranted host functions are never added to your module, so calling one fails at load with a link error rather than at runtime with a refusal. There is no permission-check path for the host to forget.
Plugin code never runs on a client. A TV has no browser engine to run it in, and per-client plugin code is the thing that killed the last generation of media-server plugins. You return data; the web app, the phone apps and the TV apps render it natively. That is why one plugin works on all seven surfaces without you writing anything for any of them.
The contract is frozen-additive within an API version. Frozen-additive within an apiVersion. New families, ops, block types and manifest fields may appear; existing ones will not change meaning or disappear. A breaking change would ship as apiVersion 2, and a server refuses a package whose apiVersion it does not speak, so an old plugin never half-works.
Pick what you are building
A plugin declares one or more families. A family is a job the server has and will hand to you.
Available now
- Matching services
matching - Be a library's metadata source. The owner picks your plugin per library, and from then on the scanner, the fix-match dialog, artwork picking and every background refresh ask you instead of the built-in provider.
- Fields and detail sections
fields - Put your own information on a detail page, on all seven surfaces. Return plain fields and they become a fact list for free; return blocks when you want more control.
- Event consumers
events - Be told what someone watched, as it happens. A scrobbler is the shape this exists for: start, pause, resume and stop, with a progress percentage, so a service can keep a watch history in step rather than guessing from a single write at the end.
- Plugin pages
pages - Whole pages of your own, built from the same blocks plus container blocks whose ids the server turns into ordinary item cards, so your page renders library-native cells on every surface. Reached from a link in a detail section.
On the roadmap
Declared in the contract and not yet callable. Listed so you can see where this is going, and so nobody builds a plugin against a family that does not answer yet.
- Search hooks
search - Contribute an attributed section to search results. A slow plugin is dropped from that query rather than allowed to hold up results.
- Subtitle providers
subtitles - Offer subtitles for an item and deliver the chosen track.
- Skip-segment providers
segments - Supply intro, recap and credits markers. The owner orders providers when more than one has an opinion.
- Theme packs
theme - Ship a look. Data only, with no wasm at all.
- Library types
source - Be a source of items that are not files on disk: a scanner that produces virtual items and a resolver that turns one into a playable URL at play time.
Write one
Any language that compiles to WebAssembly works. Go needs no toolchain beyond Go itself, so that is what the examples use. Your module exports two functions and answers JSON:
//go:build wasip1
package main
import "encoding/json"
//go:wasmexport mp_alloc
func mpAlloc(size int32) int32 { /* reserve size bytes, return the offset */ }
//go:wasmexport mp_handle
func mpHandle(ptr, length int32) int64 {
var call struct {
Op string `json:"op"`
Input json.RawMessage `json:"input"`
}
json.Unmarshal(read(ptr, length), &call)
switch call.Op {
case "match.movie":
return reply(map[string]any{"data": findFilm(call.Input)})
}
return reply(map[string]any{"data": nil}) // null means "no match"
} Build it, and you have a plugin:
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
Seven worked examples exist, each one built and run by our own test suite on
every change, so none of them can quietly rot:
hello (the ABI and nothing else),
matchdemo (a matching service with no network),
anilist (a real matching service against a public API, with
caching and settings),
eventdemo (an event consumer with no network),
trakt (a real scrobbler, with per-person account linking),
pagedemo (two pages and no permissions at all), and
awards (the fullest one: a field hydrated from Wikidata, a
page per film and per person, and the library grant used to show which
award-winning titles you already own).
Start by copying whichever is closest.
If you only read one, read awards. It is the shape most real
plugins take: identify something by an id the server already has, fetch
a fact about it from somewhere public, cache the answer, and put it on a
detail page and a page of its own.
They live at github.com/kammcs/multipass-plugins, under a licence that lets you take any of them as a starting point.
Returning an error is not failing. A reply of
{"error": "..."} is your plugin working: it is logged and shown,
and it never counts toward the fault budget that auto-disables a broken plugin.
What counts is crashing, hanging, or refusing to load.
Describe it
manifest.json sits at the root of your package and is the document
that gets signed. manifest.json sits at the root of your package and is the signed document. It names every other file with its hash, so a signed manifest fully determines the package: an unlisted extra, a missing file, a size mismatch or a hash mismatch all refuse the package.
{
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "1.0.0",
"apiVersion": 1,
"entry": "plugin.wasm",
"description": "One or two sentences an owner will read before installing.",
"author": "You",
"provides": ["matching"],
"capabilities": { "http": ["api.example.com"], "storage": true },
"settings": [
{ "key": "apiKey", "label": "API key", "type": "password",
"help": "From your account page at example.com." },
{ "key": "spoilers", "label": "Show spoilers", "type": "toggle",
"default": "off", "scope": "profile" }
],
"actions": [
{ "id": "test", "label": "Test connection" }
]
}
You do not write the files table. multipass plugins pack hashes
every file and writes it for you.
Settings, and whose they are
A setting is the household's unless you say otherwise: the owner fills it in
once, on your plugin's card. Mark one "scope": "profile" and it
becomes each person's own instead, set on their own settings screen, with your
default as the fallback for anyone who has not. The owner never
sets it on somebody's behalf.
Read them the way you always do, through config(). What comes back
is already merged for whoever the call is for, so nothing in your code asks
whose values these are, and nothing can.
One thing to know before you reach for it: a page from a plugin with a
per-profile setting is cached per person, so you are trading cache hits
for the choice. And item text from describe.item is stored once for
the whole house, so a per-profile setting cannot change it.
An action is a button the host draws beside your settings.
Pressing one calls settings.action with that id, and you return one
sentence. Not blocks: the settings screen is not a place to paint. Give it
"scope": "profile" to put it on each person's own screen instead,
which is also the only way you learn who pressed it.
Where they are drawn: the owner's half lives in the server's plugin card, in a browser, because installing and configuring a server is an owner-on-web job. The per-person half is on all seven surfaces natively, under Settings → Plugins in the apps, beside the accounts people have connected. You write one manifest and it renders everywhere, the same way your blocks do.
Ask for as little as possible
Every capability you declare becomes a line on the install screen, in plain language, before anyone decides. There are 3:
| Capability | The owner sees | What it permits |
|---|---|---|
http | Can contact example.com | Outbound HTTPS to the hosts you name. One leading `*.` wildcard label is allowed. Ports and schemes are not part of the grant: the host function only ever speaks https on the default port. Private, loopback and link-local addresses are refused AFTER DNS resolution, and every redirect hop is re-checked. An approved hostname is not a route into the owner's home network. |
storage | Can save its own settings and cached data | A key-value bucket isolated by plugin id. Removing the plugin removes the bucket. |
library | Can check whether specific titles are in your library, and never what else is there | The narrow lookup, and nothing else: you hand over external metadata ids (tmdb, imdb, tvdb, anilist, whatever your service uses) and get back local refs for the ones that exist here. There is deliberately no search, no listing and no filter behind this. You can ask whether titles you already know about are present; you cannot find out what else is. A full library-read grant was weighed for plugin pages and refused, because no page we wanted needed it. |
Implementing a family is not a capability. Implementing a family is declared through `provides`, not through a capability. Capabilities are ambient powers an owner weighs; implementing a host contract is not one, and a grant that grants nothing is noise on the install screen. A plugin that only transforms what it is handed asks for nothing at all, and the hub says so.
Review checks that what you ask for is proportionate to what you do, so a plugin that lists hosts it has no reason to contact comes back. It is also simply better for you: a plugin that asks for nothing installs without anyone hesitating.
Test it on a real server
Developer mode installs unsigned packages from a file, so you can run your own work
before anyone has signed it. Set MULTIPASS_PLUGIN_DEV=1 on the server,
restart it, and an Install from file control appears in
Settings → Plugins. The server announces developer mode at every
boot, because a server that will run unsigned code should never do so quietly.
multipass plugins pack ./myplugin -out myplugin.mpp # no key: unsigned
multipass plugins install myplugin.mpp # or use the hub
multipass plugins call com.example.myplugin match.movie -input '{"title":"Akira","year":1988}' plugins call is the fastest loop: it runs one op against your real
package and prints exactly what you returned.
Developer mode accepts an unsigned package. It never accepts a badly signed one: that is tampering or corruption, not a development flow.
Package it
A plugin ships as a .mpp: A zip holding manifest.json, manifest.sig, and every file the manifest names. Verification happens entirely in memory. A package that fails leaves nothing on disk.
multipass plugins pack ./myplugin -out myplugin.mpp Registry packages are signed with the Multipass registry key, which lives on one machine and never on the server or the node. Signing happens on acceptance, so you do not need a key to submit.
Submit it
Listing is free and hosting is free. Send us:
- the built
.mpp; - the source it was built from, and how to build it;
- one line on why it needs each capability it asks for.
Email support@kammcs.com with plugin submission in the subject. Review is a person reading it, so expect a conversation rather than a verdict.
What the registry will and will not list
- Grants must be proportional to purpose. A plugin that asks to contact hosts it has no reason to contact is sent back.
- Sources are reviewed for legitimacy. A plugin whose purpose is to make infringing material easy to obtain is refused, whatever it is called.
- It has to work: the package must install, load and answer its declared ops.
- Source is required at submission. A binary alone cannot be reviewed.
On acceptance we sign the package and add it to the catalog, and it appears in every server's Settings → Plugins under Available. Nothing is pushed to anyone: an owner still chooses to install it.
Updates and withdrawal
Send a new version the same way. A new version that wants a NEW power is flagged as a new decision rather than treated as consent to update. That is worth designing around: if you can avoid adding a capability in version 2, the update is one click for everyone who already has you installed.
A signed kill switch can withdraw a plugin. Servers honor it on a daily check and whenever an owner opens their plugins page. A withdrawn plugin is switched off with its reason shown, cannot be re-enabled, and cannot be reinstalled. It is never deleted: the owner needs to see what happened, and a plugin's data outliving a bad release is worth more than a tidy directory.
We would only reach for that in the case it exists for: a plugin doing something to people's servers that it should not. It is not an editorial tool.
What plugins deliberately cannot do
Deliberately outside the plugin surface, recorded so nobody proposes them twice.
- Authentication providers
- Identity belongs to the accounts service. A plugin in the credential path would reopen exactly what sign-in enforcement closed.
- Transcoder hooks
- Plugin-supplied ffmpeg arguments are command injection with extra steps.
- Client-side plugin code
- A TV has no browser engine to run it in, and a per-client plugin UI contract is the thing that killed the last generation of media-server plugins. Plugins ship data; the apps render it.
Next
The full API reference How owners install and manage plugins