Extensions
Plug Jellybox into any media source beyond Jellyfin. Extensions are small HTTP services that implement a fixed contract — Jellybox calls them whenever a tag points at content from that source. Anyone can build one in any language; the reference implementation is a single Node script.
No third-party extensions ship with Jellybox today. The framework is the surface — what you do with it is up to you.
On this page
What is an extension?
An extension is an out-of-process HTTP service that handles a single media source — anything you can reach over an API. It implements a small set of routes that Jellybox calls server-to-server: search the library, list playback clients, play an item.
Extensions are system-wide. An admin registers an extension once; every user on that Jellybox can then connect their own account to it. The extension owns provider credentials (API tokens, OAuth refresh tokens, etc.) end-to-end — Jellybox stores only an opaque accountIdthe extension hands back. That separation keeps third-party tokens out of Jellybox's threat model.
From the device's point of view, nothing changes. POST /api/playworks identically whether the tag is backed by Jellyfin or by an extension — Jellybox routes the request to the right backend.
Using extensions
As an admin: register an extension
Set the ADMINS environment variable to a comma-separated list of admin email addresses. Without it, no one can register extensions — fail-closed by default.
# .env (or your hosting provider's dashboard) ADMINS=you@example.com,partner@example.com
Then sign in, open Settings → Extensions, paste the extension's base URL, and click Register. Jellybox fetches /manifest, generates a one-time bearer secret, and persists the row. Copy the secret into the extension's own configuration so it can verify Jellybox is allowed to call it.
As any user: connect your account
Open Settings → Extensions and you'll see every registered extension. For each:
- Connect— for credentials-based extensions, fill in the form fields the extension declared in its manifest. For OAuth, you'll be redirected to the provider, and back to Jellybox once you authorise.
- Load clients — fetches the list of playback targets the extension knows about. Pick one as your default.
- Disconnect — remove your connection without unregistering the extension for everyone else.
Once connected, the source dropdown on the tag form picks up the extension. Search, select, save — your tag now plays through that provider when scanned.
Building an extension
An extension is any HTTP server that implements the routes below. Pick whatever language and runtime you like — Node, Python, Go, Rust, a Lambda function, a Docker sidecar — Jellybox doesn't care.
Every protected route expects Authorization: Bearer <secret>, where <secret> is the value Jellybox showed at registration time. /manifest is the only public route — Jellybox needs to fetch it before a secret has been generated.
| Method | Path | Purpose |
|---|---|---|
| GET | /manifest | Describe the extension. Public. |
| POST | /authenticate/start | OAuth only. Return the provider URL. |
| POST | /authenticate/exchange | OAuth only. Swap code for accountId. |
| POST | /authenticate/complete | Credentials only. Validate fields, return accountId. |
| POST | /search | Find items in the user's library. |
| GET | /clients | List the user's playback targets. |
| GET | /image | Stream artwork bytes (proxied through Jellybox). |
| POST | /play | Trigger playback of an item on a client. |
The exact request/response types live in src/lib/extensions/types.ts in the Jellybox repo — copy them into your extension to keep yours in sync.
Manifest
GET /manifest tells Jellybox what your extension is and how to talk to it.
{
"name": "Reference Extension",
"version": "0.1.0",
"iconUrl": "https://my-ext.example.com/icon.png",
"authFlow": "credentials", // or "oauth"
"authFields": [ // shown in the connect form (credentials only)
{ "key": "demoToken", "label": "Demo token", "secret": true, "required": true }
],
"capabilities": {
"search": true,
"listClients": true,
"images": false
},
"itemTypes": ["film"] // free-form strings you'll return on items
}When admins click Refresh manifest on a registered extension, Jellybox re-fetches this and updates the cached copy. Bump your version when capabilities or auth fields change.
Authentication
Each extension declares one of two flows. Jellybox uses different routes for each.
Credentials (token, username/password, etc.)
Set authFlow: "credentials" and list the form fields you need in authFields. Jellybox renders the form, then POSTs the values to /authenticate/complete:
POST /authenticate/complete
Authorization: Bearer <jellybox secret>
{ "fields": { "demoToken": "abc123…" } }
→ 200 { "accountId": "opaque-id", "displayName": "Demo User" }Validate the fields with the provider, generate or look up a stable accountId for that user, and return it along with a human-readable display name. Jellybox stores only the accountId — the credentials never leave your extension.
OAuth
Set authFlow: "oauth". Jellybox handles the browser redirect so your extension never has to be publicly reachable — see the OAuth section below.
OAuth flow
Jellybox hosts the OAuth callback URL. The OAuth provider only ever talks to Jellybox; the extension is reached server-to-server. That means a self-hosted extension can stay on your private/Docker network without exposing any public ports.
Browser Jellybox Extension Provider
│ │ │ │
1 │ Connect ──────► │ │ │
│ │ /authenticate/start ──► │ │
│ │ { state, callbackUrl } │ │
│ │ ◄─ { redirectUrl } │ │
2 │ ◄── 302 ─────────┤ │ │
│ (redirectUrl) │ │ │
3 │ ──────────────────────────────────────────────────────────────────────── │
│ │
4 │ ◄── 302 ── (callbackUrl?state=…&code=…) ──────────────────────────────── │
│ │ │ │
5 │ /oauth/complete │ │ │
│ │ /authenticate/exchange►│ │
│ │ { code, callbackUrl } │ │
│ │ ◄─ { accountId, name } │ (extension talks to │
│ │ │ provider here, server- │
│ │ │ to-server, with its │
│ │ │ own client_secret) │- User clicks Connect — Jellybox mints an encrypted
statetoken. - Jellybox calls
/authenticate/startwith the state and the Jellybox callback URL. - Extension returns a provider URL with
stateandredirect_uribaked in. Browser is redirected to it. - User authorises at the provider.
- Provider redirects back to Jellybox's callback page with
stateandcode. - Jellybox decodes state and calls
/authenticate/exchangeserver-to-server. - Extension swaps the code for tokens with the provider, persists them, returns
{ accountId, displayName }.
<your-jellybox>/dashboard/settings/extensions/oauth-callback. Register this exact URL with your OAuth provider — no per-extension subpath.Refresh tokens
Jellybox never sees access or refresh tokens — only the opaque accountId. That means refresh handling is entirely your extension's responsibility.
A typical pattern:
async function getAccessToken(accountId) {
const t = await store.get(accountId)
if (Date.now() < t.expires_at - 30_000) return t.access_token
const refreshed = await fetch('https://provider.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: t.refresh_token,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
}),
}).then(r => r.json())
await store.update(accountId, refreshed)
return refreshed.access_token
}When refresh itself fails (revoked, password change, etc.), return AUTH_ERROR from /play — or any 401 from another route — and Jellybox will surface a playback failure that the user can resolve by reconnecting.
Hosting
An extension is just an HTTP server. Two common shapes:
- Lambda / serverless function — simplest if your provider speaks public HTTP. Each route becomes one function; Jellybox calls them directly.
- Docker sidecar— run alongside Jellybox on the same Docker network. The extension only needs to be reachable from Jellybox; it doesn't have to expose any public ports. The OAuth callback lives on Jellybox, so even OAuth providers don't need to reach your extension.
The shared bearer secret authenticates Jellybox-the-server to your extension — not individual users. Treat it like any service-to-service credential: rotate by re-registering if leaked.
Reference implementation
The Jellybox repository ships a working reference extension at examples/extension-reference/server.mjs — a single Node script with no dependencies. It implements every contract route with canned data and supports both auth flows via an AUTH_FLOW=oauth env toggle (with a fake provider screen for local testing).
Run it:
cd examples/extension-reference node server.mjs # credentials mode (default) AUTH_FLOW=oauth node server.mjs # OAuth mode
Use it as a starter: copy server.mjs into a new project, replace the canned data with real provider calls, and you have an extension.