Reference · API version 1

Plugin API reference

The contract between a Multipass server and a plugin: what a plugin can declare, what the host will call, what it may ask permission to do, and what it can put on a screen.

Back to the guide

Stability. 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.

The guest ABI

WebAssembly. Any language that compiles to wasm works; Go's own `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared` needs no toolchain beyond Go.

ExportWhat it does
mp_alloc(size i32) -> i32Reserve `size` bytes inside the plugin's memory and return the offset. The host writes each call's input there.
mp_handle(ptr i32, len i32) -> i64Handle one call. The input is JSON at `ptr`. Return a packed `(ptr << 32) | len` pointing at the JSON reply, still inside your own memory.

[object Object]

Host functions

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.

FunctionNeedsWhat it does
log(level, ptr, len) nothing Write a line to the server log, prefixed with your plugin id. Costs nothing and grants nothing, so it is always available.
config() -> i64 nothing Your own settings as a JSON object, merged for whoever the current call is for: the owner's server-wide values, with that person's own overrides on top of any setting you marked `scope: profile`. Never another plugin's. Where there is nobody behind the call (a scan, a background describe) you get the server's view.
http(ptr, len) -> i64 http One outbound HTTPS request, as JSON in and JSON out. Only hosts your manifest lists, only https, only the default port.
storage(ptr, len) -> i64 storage get / set / delete in a key-value bucket that belongs to your plugin alone. This is how a plugin caches an API response between calls.
lookup(ptr, len) -> i64 library Map external ids from one provider onto local refs ({itemId} or {seriesId}). Anything absent from the answer is simply not in this library. At most 100 ids per call.

Capabilities

KeyTypeThe owner seesWhat it permits
http array of hostnames 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 boolean Can save its own settings and cached data A key-value bucket isolated by plugin id. Removing the plugin removes the bucket.
library boolean 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 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.

Families and their ops

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.

Your plugin id is stamped as provenance on everything it matches, so later refreshes come back to you. A library never silently falls back to the built-in provider: that is the whole point of naming a service.

OpInputReturnsNotes
match.movie { title, year } a meta object, or null Find a film by title. `year` may be 0.
match.series { name, year } a meta object, or null Find a series by name.
match.episode { seriesId, season, episode } a meta object, or null One episode of an already-matched series. Season and episode numbers are the HOST's, so a service that models seasons differently has to map them.
The host asks one episode at a time. Fetch the season once and cache it through the storage grant, or you will make one request per episode.
match.movieById { id } a meta object, or null Re-read a film you matched before, by your own id. Identity is sticky: once something is matched it is refetched by id and never re-searched.
match.seriesById { id } a meta object, or null Re-read a series by your own id.
match.search { kind, query, year } an array of { id, title, year, overview, poster } Candidates for the owner to choose from in the fix-match dialog. `kind` is `movie` or `series`.
match.images { kind, id } { posters: [], backdrops: [] }, or null Alternative artwork for the owner to pick from. URLs are fetched and re-hosted by the server, never by the viewer's device.
match.person { id } a person object, or null A cast or crew member's detail page: biography, photo, known-for credits.

Conventions that will bite you otherwise

  • `null` means no match and an error means a failure, and the host treats them completely differently: a no-match is recorded and stops being asked, a failure is retried later. Returning an error for "not found" makes a title retry forever.
  • Genre names are enough; the host derives the rest.
  • The host cross-checks runtime against the file and rejects a searched match that disagrees by roughly half. Do not invent a runtime you do not have.
  • Free data is dirty. Sanity-check what you get: a season that declares 12 episodes and hands back 25 titles is a data bug you should refuse, not pass on.

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.

The result is computed when an item is enriched and then STORED, so a detail page never calls plugin code. A slow plugin can make a section stale; it can never make a page slow.

OpInputReturnsNotes
describe.item { id, kind, title, year, series, season, episode, overview, metaId, metaProvider, runtimeMin, genres, locale } { fields: [{label, value}], blocks: [...] } What you have to say about one item. Return either or both, or nothing at all.
This needs NO capability. The host passes the item in and you return your contribution, so a plugin never reads the library and never writes to it.

Conventions that will bite you otherwise

  • The host re-asks when a setting changes, because what you say can depend on it.
  • Installing your plugin backfills an existing library in the background, so an owner does not have to re-scan.
  • A string of the form `@:some.key` is resolved server-side against your package's own `strings/<locale>.json`, so your text can be translated without clients knowing your catalog exists.

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.

This is the only family that carries information about a PERSON, so it is built the other way up from the rest: nothing is delivered until a profile links itself to your plugin. The owner installs and enables it; each person opts in by connecting their own account, and a profile that never links is never mentioned to you. There is deliberately no owner-side setting that could start it for somebody else.

OpInputReturnsNotes
link.begin { profile } { instructions, url, code, expiresIn, interval, state } Start connecting one person's account. Return somewhere to go and a short code to type: the device-code flow, which is what every service with a TV client already speaks.
`state` is yours and opaque to the host: whatever you need to finish the flow. The host stores it, never reads it, and never returns it through the API. The url must be https.
link.poll { profile, state } { pending } or { linked, account, state } Has the person finished on the other device? Answer `pending` until they have, then `linked` with the account name to show them and the state to keep.
Answer pending rather than failing when the far end says you are polling too fast. The host is already waiting on your declared interval.
link.revoke { profile, state } { } The person disconnected. Tell the far end if it wants to know.
The host forgets the link whatever you answer. A disconnect that depended on a third party succeeding would not be a disconnect.
event.deliver { event, at, profile, item, session, state } { state?, unlink?, note? } Something happened. `session` carries position, duration and a progress percentage the host computes so every plugin agrees on it.
Return `state` to replace what the host holds for this profile, which is how you persist a refreshed token. Return `unlink: true` when the far end has revoked the account, so a dead credential stops being retried instead of failing forever. Return `note` to say something in the server log without failing.

Conventions that will bite you otherwise

  • You are only ever called for a profile that connected itself, so there is no case where you must check whether you are allowed to act.
  • Nothing about playback waits on you. A slow or unreachable service is logged and moves on, so do not design around being retried.
  • Ordinary position updates are not events. You are told about edges (started, paused, resumed, stopped, finished), because a consumer that wanted a progress firehose could not use one anyway.
  • A stop and a finish are different. A stop is wherever they left it; a finish means the server counted it as watched, which is the one you can trust for a completed history entry.
  • A session that goes quiet is closed for you after a few minutes, so a client that was killed or lost power still produces a stop rather than an open scrobble forever.

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.

A page is DECLARED in your manifest and addressed by that id, so it survives a reload, deep-links, and comes back correctly with Back. An id you did not declare is a 404 and never reaches your code.

OpInputReturnsNotes
page.render { pageId, params, locale, subject } { title, blocks } Build one page. `params` is the small map the link carried; `subject` is that link's `id` param ALREADY RESOLVED by the host, so a page about a person arrives holding who they are and what they worked on here.
Return `title` to override the one your manifest declared, or leave it out to keep it. Blocks are bounded and sanitized exactly as a detail section is.

Conventions that will bite you otherwise

  • Declare `subject: "person"` or `subject: "item"` on a page and the host resolves its `id` param before calling you: a person arrives with their credits in this library, an item with its cast and crew. This is why a page about what somebody directed needs no permissions at all.
  • For anything the host cannot know, ask narrowly: the `library` grant maps external ids from your own service onto local refs. Put those refs in an itemShelf or itemGrid and the server draws the cards.
  • Your page's blocks are CACHED for `cacheSeconds` (default 900, at most a day), but the cards inside a container block are resolved for every viewer on every render. Never assume the person who sees a page is the one whose request built it.
  • A subject that names something the viewer may not see arrives as absent, not as an error. Handle a missing subject by rendering what you can, or nothing.
  • Link between your own pages with the `page` action. You cannot link into another plugin's pages, and a link to a page you did not declare is dropped.

Planned families

Declared in the contract, not yet callable.

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.

The display vocabulary

Everything a plugin can draw. It is a closed vocabulary of DATA, never code: plugin code never runs on a client, which is what lets one plugin render natively on a TV, a phone and the web at once.

TypeCarriesWhat it is for
heading text A small heading inside your section.
text text A paragraph. Supports bold, italic and line breaks, and nothing else.
factList rows[] of {label, value} Key/value rows. This is what plain fields become.
badges items[] of strings Short chips, for tags and states.
images images[] of {url, caption} A row of pictures. Each url is fetched and re-hosted by the server, so a viewer's device never connects to a host you chose and the picture survives that host going away.
links links[] of {label, action} Buttons that do one of the host's verbs.
itemShelf refs[] of {itemId} or {seriesId}, optional text A scrolling row of library titles, drawn as the ordinary cards the rest of the app uses. You supply refs; the server draws the cards.
itemGrid refs[] of {itemId} or {seriesId}, optional text The same cards, wrapped into a grid instead of one row. Use it when the list is the point of the page rather than a sidebar to it.
personList personIds[], optional text People, drawn the way a cast row is drawn, each opening their own page.

Link actions

KindCarriesWhat happens
url url Open a web address. https only. Not drawn on platforms with no browser, because a button that visibly does nothing is worse than one never offered.
item itemId Open another item in the library.
person personId Open a cast or crew member's page.
page pageId, params Open one of your own declared pages. The server stamps your plugin id on the link, so a link always leads somewhere the person installed; a link to a page you did not declare is dropped rather than drawn as a button that fails.

Bounds

Enforced server-side, so a plugin cannot make a page enormous and a client can size its renderer knowing the ceiling. A half-built block (a fact row with no value, a links block with no valid action, a container with no usable ids) is dropped rather than rendered broken. A whole page gets maxPageBlocks; a section on somebody else's page gets maxBlocks.

Blocks per section12
Rows in a fact list24
Items in a badge or image list24
Characters of text2000
Characters of a label120

Version skew. Two version gaps are handled separately. The SERVER drops block types it does not know, so a plugin built for a newer host cannot smuggle one through. CLIENTS skip types THEY do not know and render your `fallbackText` instead, so set it on anything exotic and a newer server degrades gracefully on an older app.

Every section names the plugin that wrote it, on every surface. Someone seeing unfamiliar text on their own library must always be able to tell where it came from.

Manifest fields

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.

KeyWhat it is
id required Reverse-DNS, e.g. `com.example.myplugin`. Permanent: it is the identity everything else hangs off.
name required What an owner sees.
version required Your version string. The registry refuses a duplicate.
apiVersion required The plugin API you were built against. A server refuses a package whose apiVersion it does not speak.
entry required The .wasm file inside the package.
description optional One or two sentences, shown in the registry and the hub.
author optional Who to credit.
homepage optional Where to read more.
provides optional The families you implement, e.g. `["matching"]`.
capabilities optional `{ "http": ["api.example.com"], "storage": true, "library": true }`. Absent means no powers at all. This is a CLOSED set: an unknown key fails the parse, because a grant the host cannot enforce is one the owner would be shown and we would not apply.
events optional The events you ask to be told about, e.g. ["playback.started", "playback.stopped"]. Declared rather than requested at runtime so the install screen can say in plain words what you will learn. An events plugin must name at least one, and naming any requires declaring the events family.
pages optional The pages you serve, e.g. `[{ "id": "director", "title": "Directed by", "subject": "person", "cacheSeconds": 3600 }]`. A page must be declared to be addressable: the server answers an undeclared id with a 404 and never starts your plugin. `subject` is `person`, `item`, or absent; `cacheSeconds` defaults to 900 and cannot exceed a day. Declaring any page requires the pages family, and a pages plugin must declare at least one.
settings optional Settings a person edits, rendered as rows in your card. `scope` decides whose they are: absent or `server` means the owner sets one value for the household, `profile` means each person sets their own and your default is the fallback. At most 32.
actions optional Buttons the host draws beside your settings, e.g. `[{ "id": "test", "label": "Test connection" }]`. Pressing one calls `settings.action` with that id and shows the sentence you return. `scope` works exactly as it does for a setting: `server` puts the button in the owner's card, `profile` puts it on each person's own preferences. At most 8.
limits optional `{ "memoryPages": 256, "timeoutMs": 5000 }`. Zero or absent means the default, never unlimited: there is no way to express unlimited.
files required Every packaged file with its sha256. `multipass plugins pack` writes this for you.

Setting types: text, password, number, toggle, select. A `password` setting is write-only to the API: an owner can replace it, never read it back out of the server they are configuring.

Settings and actions

Settings are the one way a person types anything at a plugin. Blocks are output and settings are input, which is why this contract grows and the block vocabulary does not.

ScopeWhat it means
server The owner sets one value for the whole household. This is what absent means, so every manifest written before scopes existed still means exactly what it meant.
profile Each person sets their own, on their own settings screen, and your manifest default is the fallback for anyone who has not. The owner does not set it: a second default nobody could see would be a worse answer than yours. A password may not be per-profile.

settings.action

{ action, locale, profile? }{ ok, message }

One declared button was pressed. `action` is the id from your manifest. `profile` is who pressed it and arrives ONLY for a profile-scoped action: a server action is the owner acting for the household, and you do not learn which person clicked. `message` is one sentence, shown as-is, and may be a string key from your catalog.

Read your settings the way you always do, through `config`: they are already merged for whoever pressed the button.

An action returns one sentence, never blocks. That bound is deliberate: blocks are OUTPUT and settings are INPUT, and letting a button paint a card would make the settings screen a second block renderer on every surface.

Caching. A page from a plugin that declares any profile-scoped setting is cached PER VIEWER, because what you return can now differ by person. A plugin that declares none caches exactly as before. Item text from `describe.item` is stored once per item and shared by the household, so it always sees the server view: a per-profile setting cannot change it.

Where a per-person value lives. A per-profile value is the host's to keep, not yours. It is stored beside the owner's settings, goes when the profile goes, and goes when the plugin is removed.

Limits

What bounds a plugin at runtime. Each has a default you get by saying nothing, and a ceiling you cannot exceed.

LimitDefaultCeilingNotes
memoryPages 256 64 KiB pages declared per plugin 256 pages is 16 MiB, which is room for a JS plugin with an interpreter shell.
timeoutMs 5000 30000 A call that runs past its deadline is killed by closing the module, because the runtime has no instruction metering. The next call rebuilds it, so a timeout costs latency, not a dead plugin.
Settings fields as many as you declare 32 One card, one list.
Actions as many as you declare 8 Pressed at most 10 times a minute per plugin. The press limit counts every profile together, because the thing it protects is whatever your button calls out to. A press past it is refused before your plugin is asked.
A per-person value characters 512 A preference is a choice. Anything longer is a credential, and those belong in the link flow.

Concurrency. One call at a time per plugin.

Faults. Five consecutive faults auto-disable a plugin, and the hub says why. A returned `error` is not a fault: that is the plugin working.

Packaging

Extension.mpp
FormatA zip holding manifest.json, manifest.sig, and every file the manifest names.
VerificationVerification happens entirely in memory. A package that fails leaves nothing on disk.
SigningRegistry 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.

The registry

The catalog servers browse and install from. Free to list, free to host.

Trust. The node that serves the catalog is dumb storage: it stores and serves and never signs. Every server verifies the catalog against keys compiled into its own binary, so whoever controls the host can serve a stale catalog or none at all, and nothing worse.

Consent. An owner is shown exactly what a plugin may do BEFORE anything is downloaded. A package whose manifest asks for more than the catalog advertised is refused, because that approval was not for this plugin. Asking for less is always fine.

Updates. A new version that wants a NEW power is flagged as a new decision rather than treated as consent to update.

Withdrawal. 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.

Sideloading. Developer mode (`MULTIPASS_PLUGIN_DEV`, set on the server itself and announced at every boot) installs unsigned packages from a file. It is how you test your own work. It is neither policed nor supported, and it never accepts a BADLY signed package: that is tampering or corruption, not a development flow.

Deliberate exclusions

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.