Manifest & Settings
Every plugin ships a JSON manifest declaring its identity, the events it
subscribes to, the capabilities it wants, and any operator-editable settings.
This page is a practical reference, using the bundled webhook notifier
(priv/plugins/webhook_notifier.json) as the worked example.
A complete manifest
{
"slug": "webhook-notifier",
"name": "Webhook Notifier",
"version": "1.1.0",
"description": "Posts a notification when media is added or a download completes.",
"author": "Mydia",
"entrypoint": "handle",
"delivery": "durable",
"capabilities": {
"events:subscribe": ["media_item.added", "download.completed"],
"net:http": ["discord.com"],
"data:read": ["media_item"]
},
"settings_schema": [
{ "key": "target", "type": "enum", "label": "Target service", "required": true,
"options": ["discord", "ntfy", "custom"] },
{ "key": "webhook_url", "type": "url", "label": "Webhook / server URL",
"required": true, "grants_host": true }
]
}
Top-level fields
| Field | Required | Notes |
|---|---|---|
slug |
yes | Stable identifier, hyphenated (e.g. webhook-notifier). Also the override filename stem. |
name |
yes | Human-readable name shown in the admin UI. |
version |
yes | Semantic version of the plugin. |
description |
no | One-line summary shown in the UI. |
author |
no | Plugin author. |
entrypoint |
no | Exported handler name. Defaults to the SDK handler; leave unset unless you know you need it. |
delivery |
no | durable (enqueues an Oban job, retried, at-least-once) or inline (runs synchronously, not retried). Defaults to inline. Any other value, including a typo, silently becomes inline. |
min_host_version |
no | Lowest Mydia version that can run this plugin. See below. |
capabilities |
yes | What the plugin subscribes to and is allowed to do. See below. |
settings_schema |
no | Operator-editable configuration fields. See below. |
Capabilities
Capabilities are deny-by-default and enforced server-side on every host call. The manifest declares what the plugin wants; the operator approves it at install time. A plugin can never widen its own grant at runtime.
| Capability | Meaning |
|---|---|
events:subscribe |
The event types the plugin reacts to. Required. Each must be in the catalog. |
net:http |
The exact hostnames the plugin may contact. No wildcards. |
data:read |
Read namespaces the plugin may query (media_item, playback_progress). Returns a curated, read-only projection. |
surfaces:write |
Curated write surfaces. Value vocabulary: playback:watched (the ensure-watched host function). |
state:kv |
A small per-plugin key/value store that survives across invocations (watermarks, cursors, dedupe sets). |
users:connections |
Per-user third-party connections the host holds on the plugin's behalf. Cross-user: see below. |
schedule:interval |
Lets the plugin run on a fixed interval via on-schedule. Paired with the schedule descriptor. |
The event catalog for events:subscribe:
media_item.addedmedia_item.updatedmedia_item.removedmedia_file.importeddownload.completeddownload.failedplayback.startedplayback.progressed(sampled): the host emits at most one per 5% completion bucket, so a burst of position updates yields a single event.playback.paused: in the catalog; not yet emitted (no player pause signal).playback.finished: the unwatched→watched edge (the 90% auto-mark, an explicit mark-watched, or a sync write).
Every playback.* event carries an origin in its metadata: player (a real client write), sync:<provider> (a media-server or Trakt import), or plugin:<slug> (a plugin write-back). The dispatcher never delivers an event back to the plugin that originated it, so a plugin's own ensure-watched writes do not echo to itself.
users:connections and data:read playback_progress are cross-user
These are the platform's first cross-user capabilities. The approval line
states plainly that the plugin can read connected users' linked accounts and
watch history and mark items watched on their behalf. Access is consent-
scoped: a user is only visible to the plugin after they click Connect on
their profile. data-list playback_progress returns rows only for connected
users, and ensure-watched is rejected for a user without an active
connection.
A manifest revision needs a re-approval
Declaring a new capability class, a new net:http host, or a new subscribed
event in a revised manifest does not grant it. The stored grant is left
untouched, the plugin stays approved and enabled on its old grant, and calls
against anything newly declared come back Denied.
Mydia flags the state rather than leaving it quiet: the plugin is badged needs re-approval in Configuration > Plugins, its row names the capabilities it is asking for beyond what was approved, and Review & re-approve grants the currently declared set. The host also logs a warning naming them when such a plugin starts. Until you re-approve, nothing widens.
net:http is an exact-host allowlist
List each host you contact (discord.com, api.example.com). Wildcard
subdomains are rejected because they would be a data-exfiltration channel.
For services where the operator brings their own host (a self-hosted ntfy,
a personal webhook), use a host-granting setting instead (see grants_host
below) so you do not have to know the host in advance.
Settings schema
settings_schema is an array of field definitions. Mydia renders them as a form
in the admin UI, and the operator's values arrive at runtime inside the event's
metadata_json under the config key (see
Read operator settings).
Field types
type |
UI | Use for |
|---|---|---|
string |
single-line text | short values, IDs, comma-separated lists |
text |
multi-line text | templates, long bodies |
url |
URL input | endpoints; pair with grants_host |
secret |
masked input | tokens, passwords (never logged, never in plugin bytes) |
enum |
select | a fixed set of choices via options |
Field attributes
| Attribute | Applies to | Meaning |
|---|---|---|
key |
all | The config key your handler reads. |
label |
all | Form label shown to the operator. |
required |
all | Not implemented. Accepted in the manifest and then ignored: the form does not mark the field, and an empty value is not rejected. Validate in your handler instead. |
options |
enum |
The allowed choices (array of strings). |
grants_host |
url |
The host of the operator's value is added to the plugin's net:http allowlist at config time. |
visible_when |
all | Show the field only when another field has a given value. |
Host-granting URL fields
A url field with "grants_host": true is how a plugin contacts a host the
operator chooses without hard-coding it. When the operator saves the value, the
host parses out its hostname and adds it to the plugin's effective net:http
allowlist. The plugin computes nothing; Mydia stays target-agnostic.
{ "key": "webhook_url", "type": "url", "label": "Webhook / server URL",
"required": true, "grants_host": true }
This is why the notifier can POST to any ntfy server or custom webhook the
operator points it at, while still declaring only discord.com statically.
Conditional visibility
visible_when gates a field on another field's value, so the form only shows
what is relevant to the current selection:
{ "key": "ntfy_priority", "type": "string", "label": "Priority (1-5)",
"visible_when": { "target": "ntfy" } }
Here ntfy_priority only appears when the operator has set target to ntfy.
Scheduled plugins
A plugin that needs a clock declares a schedule and the schedule:interval
capability. The host invokes its on-schedule export on a fixed interval:
interval_minutesis floored at 5 minutes; a smaller value is rejected at parse.- A schedule with no
schedule:intervalcapability is rejected: the admin always sees the schedule at approval. - Ticks are non-reentrant: if a previous run (scheduled, reactive, or inline) is still in flight the tick is a no-op, so work never piles up.
on-scheduleruns under a larger timeout budget thanon-event(default 60s). A run that fails backs off exponentially; a success resets the counter.- A scheduled run may return a
connections_invalidarray in its JSON result; the host marks those users' connectionserror(only users who actually hold an active connection: a guest can't mass-error state).
Connection descriptor
A plugin that links a per-user third-party account declares a connection
descriptor. The host runs the OAuth device (PIN) flow end to end from a
generic card on the user's profile; the guest never executes during connect and
never sees the token.
"connection": {
"type": "oauth_device",
"code_url": "https://api.example.com/oauth/pin?client_id={client_id}",
"poll_url": "https://api.example.com/oauth/pin/{user_code}?client_id={client_id}",
"verification_url": "https://example.com/pin",
"client_id": "your-public-embeddable-client-id"
}
code_url,poll_url, andverification_urlmust all sit on a host declared innet:http, so the verification URL rendered in trusted UI can never be a phishing surface.{client_id}and{user_code}are substituted by the host. The embeddedclient_idis the public/embeddable id; an operator can override it via aclient_idsetting.- The plugin reaches the connected account with
connection-request, which attaches the bearer token host-side (see the Reference).
Host-version floor
min_host_version (optional, a semantic version) declares the lowest Mydia host
your plugin supports. If you rely on a capability, event, or contract feature
added in a specific release, set the floor to that release. Mydia refuses to
activate a plugin whose floor exceeds the running host, with a clear
requires mydia >= X message. Omit it if you have no floor.
The plugin contract evolves additively: new functions, records, variant cases, and optional fields are added without breaking existing plugins. Only a removal or a signature change bumps the major ABI version. For the full contract and versioning rules, see the Reference.