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 calls this MORE than you might expect: once for each call's input, and again for the reply of every host function you invoke (config, http, storage, lookup). YOU must release those buffers, or your memory only grows and a long run over a real library ends in an out-of-memory fatal that kills the instance mid-call. The examples free everything except the current input at the top of mp_handle, which is safe because the host has finished reading the previous reply by the time a new call arrives. Raising memoryPages does not fix a leak, it only postpones it.
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. This is also where you free what the last call held: see mp_alloc.

[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.
progress(ptr, len) nothing Say what you are doing while you are doing it: `{ label, pct, detail, done }` becomes one line in the owner's server activity view. Ungated for the same reason `log` is, and void for the same reason too: a report is not a question, so there is no answer to read. One row per plugin, so a later call replaces an earlier one; `label` and `detail` are clipped and single-lined, `pct` is clamped to 0 through 1, a row nobody refreshes goes stale and disappears, and `done: true` clears it at once. Reach for it in slow work, a first sync or a migration inside setup, so a plugin that is busy does not look hung.

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.
  • Declaring this family with no events at all is refused, because it would put a line on the install screen that nothing acts on. The one exception is a source whose libraries belong to a person: it declares this family for the connect flow, which is what creates their library, and may want no events.

Subtitle providers subtitles

Offer subtitles for a title and deliver the one the owner picks. Your track then sits in the subtitle list on every screen, indistinguishable from one that came with the file.

The owner searches and the owner chooses: nothing is downloaded automatically. That is deliberate, because subtitle releases are all plausibly correct and most are timed against a different cut, so picking for somebody hands them the wrong one silently. You never touch a file. You return text, the host writes it into its own data directory (never beside anyone's media) and owns it from there: removing your plugin leaves the tracks the owner chose in place, because they chose them.

OpInputReturnsNotes
subtitles.search { item, languages } { subtitles: [{ id, lang, title, format, release, forced, hearingImpaired, rating, downloads }] }, or null Candidates for a title. `item` is the same item facts the fields family gets; `languages` are the codes the owner asked for, best first, and empty means anything you have.
`id` is yours and opaque to the host: it is handed straight back to subtitles.fetch. `release` is the release name the file was timed against, and it is the most useful thing on the row, because it is how a person tells the extended cut's subtitles from the theatrical one's. A candidate with no id or no language is dropped, since neither could be acted on. At most 30 come back.
subtitles.fetch { id } { content, format } The text of one candidate you offered. `format` is srt, vtt, ass or ssa.
`content` is the subtitle text, and it must be valid UTF-8: decode whatever your source sent before you return it, because a server that guessed would serve mojibake under your name. At most 2 MB. Anything in another format, empty, oversized or not valid text is refused and the owner is told which plugin sent it.

Skip-segment providers segments

Supply the intro, recap, preview, credits and commercial markers behind the Skip button. The owner orders providers when more than one has an opinion.

You are asked ONCE per title, while the file is being analyzed, and what you return is stored. Nothing you write runs when somebody presses play, so a slow answer costs a marker and never a player. Your spans are stored under your own plugin id: you can only ever replace your own, and removing your plugin takes them with it. Where you sit in the order is fixed: below what the owner typed by hand and below the file's own chapters, above the host's lookup and its own detection. Markers a library has switched off (Settings, per library) stay stored and stop being served, which applies to yours the same way it applies to ours.

OpInputReturnsNotes
segments.forItem the same item facts the fields family gets { segments: [{ type, startMs, endMs }] }, or null What should be skippable in this title. `type` is one of intro, credits, recap, preview or commercial. Leave `endMs` out for a span that runs to the end of the file, which is what a credits marker usually is.
Return null, or an empty list, when you do not know the title: that is recorded as an answer and you are not asked about it again. A span the host cannot believe is dropped rather than repaired (an unknown type, a start past the end of the file, an end before its start, anything under a second), because a marker in the wrong place makes a viewer lose the scene. At most 20 spans per title. An empty list from a title you previously answered for WITHDRAWS your markers.

Theme packs theme

Ship a look. Your themes appear in everyone's theme picker beside the built-in ones, on every screen.

There are no ops, because there is nothing to ask you: a theme is a list of colours, so a pack declares them in its manifest and ships no wasm at all. Nothing runs, which makes this the one family that cannot spin, leak, call anywhere or be slow. Your theme ids are namespaced by your plugin id before anyone sees them, so they cannot collide with the built-ins or with another pack. The owner can switch any of them off, or make one the server default, exactly as they can with a theme they made themselves. Removing your plugin removes its themes; anybody using one falls back to the server default.

No ops: this family is declared, not called.

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 a metadata service (or, if you are a source, from your own namespace) 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.

Plugin libraries source

Be a library whose items are not files on disk. You supply a catalog and the server turns it into ordinary movies, episodes and books: they browse, search, sort, resume, count as watched, go in playlists, and sit under the household rating cap, because they are the same rows every screen already draws. Planned and ruled in D176; podcasts and audiobooks joined in D184.

You never see a local id. You name things with YOUR ids, and the host keeps the mapping, which is what makes a re-sync land on the row it made last time instead of a second copy: progress, watched state and playlists all survive. A library declares its shape (movie, show, podcast or audiobook, below), and a show-shaped library is a channel per series with its uploads as episodes. What you say about the media itself (a duration, a container) is a HINT and is stored as one: the server probes the stream at first play, and what it measures is what it acts on.

OpInputReturnsNotes
source.sync { library: { shape, config, key }, cursor, since } { series: [...], items: [...], next, complete } One page of your catalog. `config` is what the owner filled in when they added the library. Return `next` to be called again for the rest, and `complete: true` on the last page of a FULL enumeration. An item is `{ externalId, title, year, releaseDate, overview, genres, runtimeMin, poster, backdrop, still, series, season, episode, certs, meta, durationS }`; a series is the same minus the episode fields. In a podcast or audiobook library an item and a series also carry the audio fields listed under Audio sources below. `externalId` is yours and must be stable, because it is the identity of the row. `library.key` says WHICH of your libraries this is, and it is present only for a channel (D177): a plugin with a single `source` block is never sent one and needs no change.
`complete: true` is the ONLY thing that authorizes removing what you did not list, and a page that also carries `next` is not complete whatever it claims. That is deliberate: a sync that failed halfway has seen part of a catalog, and a service being briefly unreachable must never empty somebody's library. At most 200 series and 200 items per page, and no limit on how many a library ends up holding. A row with no `externalId` or no `title` is dropped rather than repaired.
source.resolve { library: { shape, config, key }, item: { externalId, series, season, episode, title } } { url, headers, expiresAt, hint } The stream behind one of your items, asked when somebody presses Play. `url` is fetched by the SERVER, which probes it and serves the viewer the same HLS session it serves for a file on disk: no URL, header or token of yours ever reaches a device. `headers` ride that fetch; `expiresAt` is unix seconds and says how long the URL is good for; `hint` is advisory. `library.key` says which of your libraries the item came out of, spelled exactly as it is on a sync, and is absent for a single-source plugin. An audio item that declared its media in the sync (`enclosureUrl`, or a book's `parts`) is never resolved: the server plays what you declared, under these same two rules.
This is the second op that runs while a person is waiting, and it has a 10 second budget. A failure becomes an honest message on screen, never a spinner. Two rules on what you may return, and both are what the owner agreed to at install: the host must be one your manifest asked permission for, and the address must be https (a loopback address is the one exception, because there is no network in between). The resolution is cached until it expires, so a resume costs you nothing; say when it expires or it is reused for 15 minutes.

Conventions that will bite you otherwise

  • Name a match with `meta: { provider, id }` and the title arrives with posters and cast, because the host refetches by that id. Leave it out and nothing is ever looked up: searching a metadata service for the title of a channel upload finds something, and something is worse than nothing.
  • Declare `certs` (a country to certification map) if your service knows one. It is ranked exactly as a certification from anywhere else, so the household's rating cap gates your items in SQL. No certs means unrated, and each profile's allow-unrated rule decides.
  • A library that needs somebody's account declares `account: "profile"`. It is created when that person presses Connect and removed when they Disconnect, it is private to them (the owner sees it too, as they see every private library), and its syncs run as them, so what you read from the link is theirs.
  • A source library has no files, so everything keyed to a file is correctly absent: no downloads, no trick-play, no skip markers, no editions, no Optimize. Nothing is refusing you; there is simply no file to work on.
  • Pictures you name are fetched by the server and re-served from here, the same door every plugin image goes through, so a viewer's device never connects to a host you chose.
  • Your address may redirect, and that is expected: return the address your service publishes rather than hunting for the file server behind it. The server settles the chain itself before it fetches, so ffmpeg is handed the address that actually serves the bytes. The GRANT binds only the address YOU returned. Where the granted host sends the server next is that host's business and is not held to your manifest, so you never have to name a CDN nobody could have judged at install; every hop is still held to https and to a destination outside the household network, and a hop that fails either is refused, with Play saying so rather than fetching it anyway.
  • A duration or container you declare is a HINT and is stored as one. The server probes the stream at first play and acts on what it measured, because you cannot know what this server will have to do to something it has not seen. Playback is never direct: a remote stream is remuxed at minimum, so a device only ever talks to this server.
  • Your own plugin id is a lookup namespace. With the `library` grant, asking `lookup` for your own id maps the external ids you filed things under onto local items, which is how a search result knows whether to open an item somebody already has or one of your pages. Two limits, and both are about whose library it is: you are answered only in your OWN namespace, and a call with nobody behind it (a search, whose one answer is cached for the whole house) is answered only about libraries everybody can see. So a library that belongs to one person never turns up in an answer everybody gets, and a row pointing at something the viewer cannot open is dropped before they see it.
  • An audio library's items are audio to every screen, and that is gated: a client that predates audio (it does not send `X-Kind-Vocab: 2`) never sees a podcast or audiobook library, yours included, exactly as it never sees one made from folders. Nothing for you to do; it is why your library can be new without breaking an old app.
  • A podcast library of yours is yours: nobody can subscribe a feed into it, remove one of your shows, or refresh one on its own. Refresh on one of your shows syncs your library.

Library shapes

ShapeNeedsWhat it makes
movie any release Items are films. `series`, `season` and `episode` are ignored.
show any release Your `series` are shows and your items are their episodes; an item must name its show's `externalId` in `series`, or it is dropped.
podcast 0.74.0 Your `series` are podcast shows and your items are their episodes, bound as in a show library. Episodes carry the podcast fields below; a show carries `author`, `feedUrl`, `categories`, `language` and `explicit`. Clients list it as a podcast library and play it in the audio player (a video enclosure plays in the video player).
audiobook 0.74.0 Your items are books, each one timeline however many files it is (`parts`). There are no series rows: a book's `series` is the NAME of the book series it belongs to, beside `seriesSeq`, and a page's `series` array is ignored.

Audio sources

What a podcast or audiobook library adds (D184). Every field is optional and is read ONLY in those two shapes: in a movie or show library the server empties them before anything sees them, so a video source behaves exactly as it always has. Each field is cleaned rather than trusted: an address that breaks the rule, a malformed ISBN or an unreadable date is dropped, a long text is clipped, and a list past its bound is cut; a book with a part the server could not play (a bad address, or more parts than the limit) is dropped whole, because a book with a hole in it is worse than no book.

Minimum host. A plugin that declares a podcast or audiobook library must name `minHostVersion` 0.74.0 or later. An older server does not know the shapes and refuses the install either way; naming the release makes it refuse by version, with a message an owner can act on.

Where the bytes come from. Where the bytes come from. An item that DECLARES its media, a single file as `enclosureUrl` or a book in several files as `parts` (in timeline order), is played from those addresses and the server never asks `source.resolve` for it. Each address is held to the rule a resolved address is: inside your `http` grant, and https unless it is loopback. Leave both out (for an address that is signed or expires) and the server asks `source.resolve` when somebody presses Play, exactly as for any item. Either way the server fetches, probes and serves: a device plays audio direct THROUGH this server (the bytes are relayed with the listener's Range, never your address), or as an audio-only HLS stream, and a video enclosure keeps the remux floor. `durationS` on a part and on the item is a hint that places the part until the first play measures it; send it, because a long book whose parts are still being measured is placed by what you declared.

What a complete sync removes. Removing what you stopped listing follows the D176 rule (only a `complete` sync removes anything), with one amendment for the podcast shape only (D184 ruling 15): an episode you no longer list is removed only if nobody touched it. Played or started, in play history, bookmarked, downloaded to a device, in a playlist, a play queue or a collection, or rated: any of those keeps it, and it stays listed as out of the catalog. A show you stopped listing goes once it holds no episode. An audiobook library prunes exactly as a movie library does.

An item (episode or book)Read inNotes
authors audiobook Who wrote the book, in credit order. Up to the people limit, each clipped; repeats in any case are dropped.
narrators audiobook Who reads it, as `authors`.
seriesSeq audiobook The book's place in the series named by `series`: a number from 0 to the series limit (fractions allowed, 0 is a real book 0). Absent means not in a series or not known.
publisher audiobook The publisher, clipped.
isbn audiobook ISBN-10 or ISBN-13; hyphens and spaces are removed, anything else that is not ten or thirteen digits (an X may end an ISBN-10) is dropped.
asin audiobook An Audible ASIN: ten letters or digits, upper-cased.
abridged audiobook true or false; absent means you do not know.
explicit podcast audiobook Marks explicit content. It is a label, not a rating: the household rating cap still reads `certs`.
guid podcast The episode's guid as published, shown to clients as the episode's `guid`. Your `externalId` stands in when you send none; your `externalId` is always the identity.
publishedAt podcast audiobook When it was published: RFC 3339 or a plain YYYY-MM-DD, stored in UTC. It is the release date when you send no `releaseDate`, which is what every date sort reads.
enclosureUrl podcast audiobook The one file of an episode or a single-file book: see Where the bytes come from. A book with `parts` ignores it.
enclosureBytes podcast audiobook Its size in bytes, for an offline download's progress. Leave it out and the server asks the host once, when somebody downloads it.
enclosureType podcast audiobook Its media type, `audio/*` or `video/*`; anything else is dropped. A client reads it to choose the player before the file is measured (a `video/*` episode opens in the video player). A book is never video.
transcriptUrl podcast A transcript the listener can read along with (VTT, SRT, the Podcasting 2.0 JSON, HTML or plain text). It must be inside your grant and https, as a stream is; the server fetches and parses it the first time somebody opens it.
transcriptType podcast The transcript's media type, a hint: the body is sniffed before the label is believed.
transcriptLang podcast Its language, a BCP 47 tag such as `en` or `pt-BR`.
chapters podcast audiobook `[{ title, startMs, endMs, img }]` on the item's own timeline (a book's is the stitched one): sorted by start, a chapter with a bad start dropped, a missing or bad end run on to the next start. Send none and the first play reads the file's own chapters, or for a book in several files makes one chapter per file.
cover podcast audiobook Square art. It becomes the poster, fetched and re-served by the server like every picture you name.
parts audiobook A book in several files, in timeline order: `[{ url, durationS, title, bytes }]`. The listener hears one book with one position, and seeks across the files. At most the parts limit, and a book with a part that breaks the address rule is dropped whole.
A series (a podcast show)Notes
author Who makes the show.
feedUrl The show's public feed, if it has one (http or https). Informational: the server never fetches it for your show, and it appears only in the owner's OPML export.
categories The show's categories, up to the categories limit.
language A BCP 47 tag.
explicit Marks an explicit show.
One part of a bookNotes
url Required: the file, held to the address rule.
durationS How long it is, a hint until the first play measures it.
title The part's own title, used for its chapter when the book sends none; else its file name is.
bytes Its size, for an offline download.
One chapterNotes
title What the chapter is called.
startMs Where it starts, in milliseconds on the item's timeline.
endMs Where it ends; absent means where the next one starts.
img Chapter art. Accepted and kept for a later release: no surface draws chapter art yet, so nothing changes when you send it.

Channels channel

Several libraries behind one card, with a page of your own. A channel is one entry on the household's Channels shelf: open it and you get the page you shipped, made of your libraries. Its libraries are ordinary plugin libraries (they sync and play through the source ops, so a channel declares the source family too), with one difference: they do not appear on the home page or in the library list, because the channel is where people browse them. Everything else still finds them, so a title of yours turns up in search, in Keep Watching, in a playlist and in the queue, under the household rating cap like anything else. Ruled in D177.

Your page is a LAYOUT, not a render. You describe rows and the server draws them out of what it already has: a hero and a shelf from one of your libraries, a heading, and a `slot` that is yours to render. That is why a channel page needs nothing new on any client, and why the household can rearrange it. The owner reorders, hides, retunes and adds host-drawn rows; your default stays underneath, so an update that adds a row shows it at the bottom without moving anything a person arranged. A `slot` is the one row only you can supply, which also means an owner may hide one but never add one.

OpInputReturnsNotes
channel.render { slot, locale } { blocks: [...] } One `slot` row of your channel page, in the ordinary block vocabulary. `slot` is the id of a slot block in the layout you shipped; a slot you did not declare is never asked for. Return whatever a page returns, up to 24 blocks.
Cached under the slot's `cacheSeconds` (900 by default, a day at most), per locale, and per viewer when you declare a per-profile setting. The refs and tiles inside are still resolved for the person looking on every render, so a cached slot never carries somebody else's watched state and never shows a title above their rating cap. There is no viewer id in the call: who is asking reaches you only as whose settings you read.

Conventions that will bite you otherwise

  • Give every library a `key` (lowercase letters, digits and dashes) and a `name`. The key is how a sync and a resolve say which library they are about, and how a layout row points at one, so it has to be stable: changing it in an update is a new library.
  • `account` belongs to the channel, not to each library. A channel with `account: "profile"` is created whole when somebody presses Connect and removed whole when they Disconnect, and its card is on the shelf only for them; a library entry declaring an account of its own is refused.
  • `art` names a .jpg or .png inside your own package, so it is hash-verified like every other file you ship and is served from this server. It is the card, so a 16:9 picture is the shape to draw.
  • Layout ids are the merge key between your default and the household's edits. Keep them stable across versions, or an update loses whatever was arranged on that row and nobody will have touched it.
  • Ask for the sorts and shapes in the channelLayout vocabulary and nothing else. They are the library view's own orderings, so a row of yours costs the server no more than one of ours.
  • Declare `theme` on your channel and the whole channel looks like itself: your colours paint your page, the detail page of any title from one of your libraries however somebody reached it (search, Keep Watching, a playlist), and the player while one of them is playing. It follows your CONTENT rather than the person's navigation, which is what makes a channel feel like an app somebody stepped into. Cards in mixed rows are deliberately never themed. The field is the same shape a theme pack declares, minus `id` and `name`, so there is no second colour vocabulary to learn. Leave it out and nothing anywhere changes colour.

Planned families

Declared in the contract, not yet callable.

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 / more 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.
tiles tiles[] of {title, subtitle, badge, text, mark, progress, watched, art, action, links}, optional layout / shape / rotateSeconds / text / more A row, list, grid or hero of cards, composed from slots rather than from a shape we chose for you. This is how you draw something the vocabulary has no block for: pick the arrangement, the card, the art, the text and the state mark, and the host draws it natively on every surface.
stats stats[] of {value, label, tint}, optional text Figures worth reading at a glance: the value drawn large, its caption underneath. Use it for the number that is the point of your section, rather than burying it in a fact row.
progress bars[] of {label, value, text}, optional text Labelled bars, for how far through something is: a season, a collection, a download.
notice text, tone, links[] of one {label, action} One line of state with the obvious response attached: "Connected as kelly" and a Disconnect button.
columns columns[] of {blocks[]}, optional split Two or three columns of blocks, side by side on a wide screen and stacked in column order on a phone and on both TV skins.

Tile slots

The closed sets a tile's slots may name. A value outside a set is dropped rather than passed on, because a client that does not know a name draws nothing at all. A tile's `art` is not a set but a precedence: `personId`, `itemId`, `seriesId`, `wideUrl`, `url`, then `monogram` or `glyph` in a `tint`, then the tile's own initials.

SlotValuesNotes
layout rowlistgridhero row: A strip that scrolls sideways, the way the cast row does. The default, and what an unknown layout falls back to. list: One tile per line, symbol left and text right. The honest default for search results, which are read down the page. grid: Wraps onto as many lines as it needs, the library view's own shape. Use it when the list is the point of the page rather than a strip beside it. hero: One tile fills the width and dots page through the rest. The only layout that keeps rotateSeconds, a tile's text and a tile's links, and the only one with no shape: it is the shape.
shape circlewidepostersquare circle: The cast row's disc, and the default. Art is cropped to a circle, so a face or a monogram reads and a poster does not. wide: The 16:9 shelf card. Prefers art.wideUrl (or the host's backdrop) over the portrait picture. poster: The 2:3 card the library draws films and series as. square: 1:1, for album art, podcast covers and anything else that ships square.
mark nonecheckringplaylock none: No overlay. check: A filled check. Use it for a state that is settled and positive: won, earned, verified. ring: An open ring. The counterpart to a check: nominated, pending, in progress. play: A play badge, for a tile that starts something. lock: A padlock, for a tile the viewer cannot open yet.
art.glyph trophylaurelstarmedalfilmtvmusicglobebookgamepodcastheartclockcalendarpersonfolderlinkplay Deliberately generic. We draw these ourselves in each platform's own idiom, so do not expect a ceremony's or a service's own logo: a real logo is a trademark, and a plugin cannot licence one on our behalf. A monogram beside a generic glyph is how you say which body an award came from. A monogram and a glyph are drawn on the `circle` shape only. Every other shape falls back to that card's own empty state, which is the title set large on a tinted panel, so on a wide, poster or square tile set `tint` and expect the title rather than letters.
art.tint goldsilverbronzeaccentredgreenbluegray Named rather than free-form, because the viewer picks the theme and you cannot know it. A tint you do not set draws the neutral disc.
art.monogram 5 characters Drawn in a tinted disc when there is no picture to show.
badge 24 characters The chip on the art of a wide, poster or square card, and the eyebrow above a hero's title. A circle has nowhere to put it, so it is ignored there.
text 300 characters A hero tile's description line. Cleared on every other layout.

The hero gallery

One card across the width with dots paging through the rest. It is the only layout that draws a tile's `text` and its `links`, and the only one that rotates on its own.

Tiles in one hero10
Buttons on a hero tile2
rotateSeconds 0 is off, anything else is clamped to 4 to 60

Notice tones

ToneWhat it says
info The neutral statement, and what an unknown tone reads as.
success Something worked, or is connected.
warning Something needs attention. Not an error dialog: a notice is a line, and the person is still in charge of when to act on it.

Column splits

SplitWhat it does
equal Equal widths. Always used for three columns, because 2:1:1 is the beginning of a layout language and this is not one.
wideLeft 2:1, for a main column with a sidebar after it.
wideRight 1:2, the same thing the other way round.

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.
series seriesId Open a show. Resolved for the person looking, exactly as `item` is: a series they may not see is dropped, and a tile whose only action was that series is dropped with it rather than drawn as a card that opens nothing.
library libraryId Open a library's own browse view. Use it for the trailing see-all on a row you had to cut short. A library hidden from the person looking is dropped, and there is no rating cap to apply here: the cap applies to what the browse view then draws, the same way it does when they reach it from the home screen.
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
Figures in a stats block8
Characters of a figure16
Columns in a columns block3
Blocks in one column8

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.

The vocabulary handshake

Which display vocabulary a client draws. Every leaf block degrades on its own (an app that does not know a type shows your fallbackText), so leaf types never move this number. `columns` cannot: what it holds is blocks, so an app that skipped it would lose everything inside. Clients send this header on every call and the server flattens a columns block into its blocks, in column order, for anyone below the current version. An absent header reads as 1, which is the safe reading of silence.

Current version2
Header clients sendX-Block-Vocab

What it means for you: a columns block is safe to use today. An app too old to draw one never receives it, and gets the blocks inside it in column order instead, which is the same rendering every surface uses on a phone. Every other new block type is a leaf, so an app that has not heard of it shows your fallbackText and draws the rest of the page correctly. Set one on anything the vocabulary gained recently.

Every section on a detail page is headed by your plugin's name, on every surface, and you cannot change that heading. Someone seeing unfamiliar content on their own library must always be able to tell where it came from, and a heading they can read says it better than a line of small print under one.

The channel layout

The channel page document (D177). It is NOT part of the block vocabulary and sits beside it for that reason: a plugin never sends one of these rows, it DESCRIBES a row and the server draws it out of the library. Two people write this document, you as the `layout` in your manifest and the household through the layout editor, and both go through the same validation, so a document naming a kind, a sort, a shape or a library key outside these sets is refused with the field named.

The merge. Ids are the merge key. The stored document holds only the household's edits: an order, a hidden list, per-block overrides and rows they added. A page is resolved from your current default with those applied, so an update that adds a row shows it at the bottom, one that removes a row takes it away with any edits on it, and nothing a person arranged moves.

Row kinds

KindWhat it draws, and what it takes
hero The big card at the top, fed from one of your libraries. Takes `library`, `sort`, `limit` (1 to 10) and `rotateSeconds` (0 for off, else 4 to 60). It is drawn as a tiles hero, so it looks like every other hero in the app.
libraryShelf A scrolling row from one of your libraries. Takes `library`, `sort`, `shape`, `limit` (4 to 24) and `title`. It is drawn as the app's ordinary shelf, with a see-all that opens the library.
collectionShelf A row from a collection in this household. Takes `collection` (its id) and `title`. Owner-added only: you cannot know a household's collection ids, and it is the one row a person adds that is theirs rather than yours.
heading A line of text between rows. Takes `text`.
slot Your own row: whatever channel.render returns for this id. Takes `title` and `cacheSeconds`. It is the only kind an owner cannot add, since the host has nothing to fill it with, and the only one they may hide but not edit.

Sorts

sortWhat the row is ordered by
recent Newest first, by when it arrived. What a hero means by new.
title A to Z.
rating Best rated first.
release Newest released first.
unwatched Newest first, with anything the viewer has finished left out. It is the one name here that is a filter as well as an ordering, and it is applied for the person looking, like everything else on the page.

Shapes

shapeThe card a row draws
wide The 16:9 card.
poster The 2:3 card.

Two shapes, not the tile vocabulary's four: a channel row holds titles, and a title is a card or a poster. A circle or a square would be a row drawing something the library itself never draws.

Layout bounds

Libraries in one channel 10
Rows in one layout 16
Titles in a hero 1 to 10
Titles in a shelf 4 to 24
rotateSeconds on a hero 0 is off, anything else is 4 to 60
cacheSeconds on a slot 900 by default, at most 86400

A limit of 0 means the row's default (20 for a shelf, 5 for a hero) and is never an error. Anything else outside its range is refused rather than clamped, so a number you typed is the number that draws. The block ceiling applies AFTER the household's edits are merged, so an owner adding rows is bounded by the same 16 you are.

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.
minHostVersion optional The oldest Multipass release that can draw everything you send (major.minor.patch). apiVersion says the contract is the same one; this says the build is new enough for the parts of it you use, which is the question every additive block type raises. A server older than this refuses the install and names the version it needs, instead of running your plugin and silently dropping what it cannot draw.
entry optional The .wasm file inside the package. Required for every family except a theme pack, which is data and has no code to point at.
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.
themes optional What a theme pack ships: up to 12 looks, each `{ id, name, accent, focus, bg, gradient: [from, to], panel, text, dim, cardRadius, bgImage }`. Colours are `#rrggbb` and the gradient is exactly two of them. `bgImage` names a .jpg or .png inside your own package, so it is hash-verified like everything else. Every field is checked at install: signing says who wrote a pack, never that its colours are colours.
channel optional A channel (D177): several libraries behind one card, with a page of your own. `{ "name": "Star Wars", "label": "Every film and series, in order", "art": "channel.jpg", "account": "none", "libraries": [...], "layout": [...] }`. `libraries` is up to 10 entries, each an ordinary source declaration plus a `key` (lowercase letters, digits and dashes, unique here) and a `name`; the key rides on every sync and resolve so you know which one you are being asked about. `account` is the channel's, not each library's, and `profile` requires the events family exactly as a source's does. `art` is a .jpg or .png inside your package. `layout` is the default page, up to 16 blocks, in the channelLayout vocabulary. Requires the channel family AND the source family, since the libraries sync and play through the source ops. A manifest carries `source` or `channel`, never both: a channel's libraries are its sources. `theme` is optional and is what your channel LOOKS like (D180): `{ accent, focus, bg, gradient: [from, to], panel, text, dim, cardRadius, bgImage }`, the same tokens a theme pack declares, so every client draws it with no new code and there is no second colour vocabulary to learn. It applies wherever your content goes, not just on your page, and unlike `layout` it is not something the household rearranges. A theme nobody could read is refused at install: see limits.channelTheme.
source optional What kind of library you are, e.g. `{ "shape": "show", "account": "profile", "refreshMinutes": 360, "settings": [...] }`. `shape` is `movie` or `show` and becomes the library's type. `account` is `none` (the owner adds it like a folder and the household shares it) or `profile` (it is created by pressing Connect and belongs to that person, which requires the events family). `refreshMinutes` defaults to 360 and cannot be under 15. `settings` are the LIBRARY's fields, filled in once when it is added, so two libraries from one plugin have their own; they use the same vocabulary as your plugin settings and cannot be per-profile. A source with `account: "profile"` cannot declare them at all: its libraries are created by pressing Connect, so there is no form anybody would ever fill in. Use your own plugin settings for those. Requires the source family, and a source plugin must declare one.
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.
setup optional `true` asks the host to call `plugin.setup` once after this plugin is installed, upgraded or switched back on. It is not a family: a family is something a plugin IS, and setup is a moment in its life, so any plugin may declare it. Absent means false, and a plugin that does not declare it is never called. Declaring it means shipping an `entry`, because setup is code the host runs. The lifecycle section says what runs, in what order, and what a failure leaves behind.
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.

Setting up

What happens the moment a plugin is installed, upgraded or switched back on. Setting up is two steps, and the first one runs no code of yours at all: the host creates what your manifest already declares, then calls `plugin.setup` if you asked to be called. Neither step blocks the install, because a first sync of several libraries takes minutes.

What the host creates for you

A channel with `account: "none"` already names its libraries in the signed manifest, so installing it CREATES the ones the server does not have, with your names and each library's defaults, and starts their first sync. This needs no manifest field and no code: the declaration is the instruction. It is create-if-missing and never a reconcile, so a library that is already there keeps every setting the owner changed, and an upgrade or a re-enable never undoes their work.

What is not createdWhy
A channel with `account: "profile"` Its libraries belong to one person and are created when that person presses Connect (D176). An install knows nothing about whose they would be, so nothing is created and the flow is unchanged.
A plain `source` library It is not a channel. Its settings have no fixed answer, a feed URL is not something a manifest can guess, and one plugin can back several libraries at once. The owner still adds it under Libraries and fills the form in.

plugin.setup

{ reason, previousVersion?, locale } → { ok, message }

Your own half of setting up, called once when the manifest says `setup: true`. It runs with the capabilities you already declared and nothing more: setup grants no new powers. `message` is one sentence the owner is shown beside your plugin, and it may be a string key from your catalog. Answering nothing at all is a success: a handler that did its work and had nothing to say is the simplest correct one there is.

Make it idempotent. It fires again on every upgrade and on a re-enable somebody may have done for an unrelated reason, so it has to be safe to run twice. Use it to seed your storage, warm a cache, or migrate what an older version of yourself wrote, and use `progress` if any of that is slow. Do not sync a catalog here: that is `source.sync`.

ReasonWhen
install A fresh install of your plugin. `previousVersion` is absent.
upgrade A newer version replaced an older one, and `previousVersion` names the version it replaced. That field is the whole reason the upgrade case exists: a plugin changing its own storage format has no other moment to migrate it.
enable Somebody switched your plugin back on. Usually there is nothing to do, which is why everything setup does is written to be safe to repeat.

If you do not implement it. A plugin that declares `setup` and does not answer the op replies `unsupported op`, and the host treats that as nothing to do plus a line in the log. Forgetting to write the handler is never a broken install.

If it fails. An error, or `ok: false`, is a failed setup. The plugin stays INSTALLED, is shown as not set up with what went wrong, and the owner can run setup again. Nothing is rolled back and nothing is auto-disabled: throwing away a working install over a transient blip would be the worse answer, and so would hiding the problem behind a second thing to notice.

Saying what you are doing. While setup, a sync, or any other slow call is running, the `progress` host function puts one line per plugin into the owner's server activity view. It is how a plugin that is busy for a minute says so instead of looking hung.

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.
One sync page as many rows as you send 200 A page is a round trip on your side and a batch of writes on ours, so it is bounded: a bigger catalog says so with `next`. A LIBRARY is not bounded by item count, and there is no ceiling to design around. What bounds one sync is wall clock (an hour for the whole pass) and the rule that your cursor has to advance: hand back the cursor you were given and the sync stops and says so, rather than re-reading one page until the clock runs out.
A source library items no limit An externalId is at most 200 characters.
Resolving a stream seconds 10 A resolved address is reused for 15 minutes unless you say otherwise, and never longer than 12 hours. At most 8 headers, each under 512 characters.
An audio item as many as you send 500 parts, 500 chapters, 20 authors and narrators each, 12 categories An audio item's bounds: parts per book, chapters per item, authors and narrators each, a show's categories, the length of any address, an episode's guid, the longest one part or chapter start may be (a week), the highest series position, and one file's size (a TiB). Addresses at most 2048 bytes, a guid 512 characters, a part or chapter start 604800 seconds, a series position 100000, a file 1099511627776 bytes.
refreshMinutes 360 no less than 15 A catalog that changes faster than that is a live channel, which is a different contract.

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 for you, on approval, by the developer portal's publish key. You never need a key to submit, and you never hold one: the key that signs what the catalog serves is not yours and is not on your server.

The registry

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

Submitting. Submitting is the developer portal at developer.multipass.theater, signed in with your ordinary Multipass account. You create a listing, upload the package and the source it was built from, invite up to 10 testers if you want a build tried on real servers first, and submit when you are ready. Review is a person reading it: a first submission gets a full pass, and an update is read as a diff against the last approved version, so an update that changes no grants is a light review. On approval the portal signs and publishes, either straight away or when you choose to release.

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.

Rich listings. A published catalog entry can carry listing fields beyond the v1 set, all written by the store from your listing rather than by your manifest: category (one key from the list below), ageTier (your self-declared tier, review-verified), developer (your public developer name), releaseNotes (the current version's notes), purposes (your one-line reason per grant, keyed http, storage and library, shown on the store's privacy card beside the grant sentences), images (each with a kind of icon, banner or shot, an id that is the sha256 of the bytes, and a registry-relative path), installs and trending (distinct linked servers all-time and over the past 30 days), events (the events your manifest declares, so the pre-install screen can say what the plugin can be told), and libraries (the libraries a household channel will create when it is installed, so the install screen names them before anybody presses the button rather than after). Every field is optional and additive. Servers fetch and verify each image against its pinned sha256 and proxy it to their own clients, so a tampered image can never render and a viewer never contacts the registry.

Categories. metadata-artwork ratings-awards watch-sync discovery subtitles skipping media-types channels-streams live-tv-dvr appearance tools

Age tiers. everyone teen mature adult

Beta builds. Testing builds reach real servers through a per-account beta catalog: a linked server asks the cloud, with its own server credential, for the beta builds its account may see, meaning the account is the plugin's developer, an invited tester (each plugin can invite up to 10, by email that must match a Multipass account), or a reviewer looking at submitted builds. The beta catalog and its packages are signed with the same keys as everything published, so install, consent screen and kill switch are identical to production; the entries simply appear in the owner's hub marked Beta. A testing build drops out 90 days after upload. An unlinked server, or one whose account was invited to nothing, sees no trace of any of this.

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.