Extend it

Build the feature you want next.

The panel loads small plugins from your own server, and hands you a store to run them: install one in a press, fill in the settings it asks for, test it against the real webhook or inbox, and scope it to the stations you choose. React to what happens on air, add your own HTTP endpoints, log, keep state, send mail, call any API. Nothing here is required: with nothing installed the panel runs exactly as before.

The store, not a folder

Admin → Plugins is a page: install, settings, a station scope, Test, Pause, Uninstall. The plugins below come with the panel and are switched off until a host installs one — a folder on disk should not be able to start posting to a webhook by itself.

Hooks into the whole panel

One entry file declares which events it cares about. Dispatch never throws: a broken hook is logged and the panel keeps broadcasting.

events.tickEvery 60-second scheduler tick, after the panel's own work
events.streamA station's engine started or stopped
events.trackA track was broadcast, right after it hit the play history
events.contactA song request or tip arrived
requestAny request under /api/plugins/<your-plugin>/… — your own endpoints

The panel api, permission-gated

Each hook receives a panel object. A plugin may only call the capabilities its manifest declares — the api is the documented surface, so panel upgrades stay compatible with your plugins.

panel.log

Structured log lines, visible on the plugin's own page in the console

panel.state

Key-value state that survives restarts (JSONB in PostgreSQL)

panel.config

The settings a host saved for this plugin on its own page

panel.fetch

Outbound HTTP with a 10-second timeout, for webhooks and third-party APIs

panel.templates

{{name}} rendering and mail through the panel's configured SMTP

panel.events.on

Subscribe to custom topics published by the panel or other plugins

A plugin in three files

Drop a folder into plugins/, press Rescan in Admin → Plugins, and the hooks go live. Enable/disable per plugin from the console round-trips through the database — no restart for the toggle itself.

plugins/
  my-plugin/
    plugin.json    ← manifest (id, name, permissions…)
    catalog.json   ← what the store shows (category, tagline, settings)
    index.mjs      ← entry: DEFAULT-EXPORTS the definition object
{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "description": "What it does.",
  "author": "you",
  "panelApi": "1",
  "permissions": ["log", "state", "fetch", "templates", "events"],
  "entry": "index.mjs"
}
{
  "tagline": "One line for the store card.",
  "category": "Announce",
  "icon": "📣",
  "perStation": true,
  "hooks": ["track"],
  "what": "What it does, when it fires, and what it needs.",
  "endpoints": [
    { "method": "GET", "path": "status", "what": "What it is doing now." },
    { "method": "GET", "path": "test", "what": "Do the real thing once." }
  ],
  "fields": [
    { "key": "webhookUrl", "label": "Webhook URL", "type": "url", "required": true },
    { "key": "minSeconds", "label": "Minimum seconds between posts", "type": "number", "default": 30 }
  ]
}
// plugins/my-plugin/index.mjs — self-contained ES module, no imports needed.
// Every hook receives "panel", so the api is always in the payload, and the
// settings a host saved arrive through panel.config (permission: "config").
export default {
  events: {
    track: async ({ stationName, artist, title, panel }) => {
      const url = panel.config.get("webhookUrl", ""); // what the host saved
      if (!url) return;                               // nothing configured yet
      await panel.fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ content: `🎵 ${artist} — ${title}` }),
      });
      const seen = (await panel.state.get("tracks_seen")) ?? 0;
      await panel.state.set("tracks_seen", seen + 1);
    },
  },

  // The console's two buttons: /status says how it is doing, /test does the real
  // thing once — admin-only, because a test posts to a real channel.
  request: async (req) => {
    if (req.path !== "status" && req.path !== "test") {
      return { status: 404, body: { error: "Unknown path. Try GET /status or GET /test." } };
    }
    if (req.path === "test" && req.userRole !== "admin") {
      return { status: 403, body: { error: "Admin access required." } };
    }
    return { status: 200, body: { ok: true, plugin: req.panel.id, message: "…" } };
  },
};

What every plugin answers

Two endpoints turn a folder into something a host can run: /status says what the plugin is doing right now, and /test does its real work once — which is what the Test button in the console calls, so a green result means the plugin's own path works rather than that the panel thinks it would.

GET /api/plugins/<id>/status

A sentence a host can read, plus whatever counters make the plugin legible — with anything secret-ish masked, because a webhook URL in a screenshot is a way to post as this station.

GET /api/plugins/<id>/test

Posts the webhook, sends the mail, draws the document — once, admin-only, and without marking anything as already done, so pressing it twice is safe.

Safety & control

  • Loaded, not executed blind: the manifest's id must match its folder, and a module that never registers is reported with its error in Admin → Plugins — never half-applied.
  • In-process, by design: plugins run with the panel's privileges, so install one only from an author you trust — the same standard as a panel patch.
  • One press away: pause a plugin and it stops receiving events while keeping its settings, or uninstall it and the data it stored here goes with it. Either way the code stays on disk.
  • Bounded by default: request bodies through the proxy cap at 200 KB, outbound calls get a 10-second timeout, logs are trimmed to 200 lines per plugin.

Ship your first plugin today

Copy plugins/track-logger (the smallest complete plugin) or webhook-echo (one that owns its own endpoint), rename the id and the folder, and it will be on the shelf beside the rest. The full developer docs live beside them in plugins/README.md.