Files
ProdDash/docs/MODULE-GUIDE.md
T
Waters ChurchandClaude Fable 5.1 a7e40ee1a8 Layouts keep every tile they are given, and show what is on offer
The bug behind this: for seventeen days the server rebuilt each saved tile
from the fields it knew and dropped the rest — including `variant`, so a
layout of five different timers came back from the server as five "All
timers". The client had carried `variant` since multi-tile modules arrived;
the server was the one that lagged, and it is the part of ProdDash that
always will (a booth updates weeks after the dev machine). So the server
stops deciding which tile fields exist: it checks the shape of the ones it
knows, keeps any other small plain-JSON field a tile carries, drops only
retired ones, stamps the file `v: 2`, and refuses a layout over a hundred
tiles instead of quietly saving the first hundred. A test round-trips a
fully-populated tile plus one the server has never heard of, and fails if
anything is lost.

Then the rule the arrangement is loaded by. A layout keeps every tile it is
given; the board shows the ones whose module is installed and enabled and —
for a tile added from a picker entry — whose entry is offered right now. The
rest keep their place and draw nothing, and appear the moment the registry
offers them again: a Service timer waits for PCO Plan, a transcript for its
channel. Never the module's default in their place. Because a module's entry
list can change with no event to mark it (PCO Plan comes back and, seconds
later, the timers module is offering its timers again), the registry is
re-read every few seconds while anything is folded away, and not at all
otherwise. The timers module's placeholder card for a missing solo timer
goes with this: nothing is drawn, the dot says why.

Loading says what it did — "Loaded 'FOH': 9 tiles, 2 not shown — Service,
#ops aren't offered right now" — with Undo in the message. Saving over a
name asks first, and the server keeps the copy it replaced one back; Admin →
Saved layouts gets Restore previous, which swaps the two so a restore is one
restore from undone. The browser keeps one good copy of its own layout
behind the live one and falls back to it when the live one will not parse.

Shell 1.8.0, timers 1.4.1. The layouts already saved on the booth machines
had their variants stripped before this and have to be saved again from a
browser that still holds the right tiles — nothing here recovers what the
files no longer contain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 12:02:34 -04:00

40 KiB
Raw Blame History

ProdDash Module Guide

How to build, install and test a ProdDash module. Everything a module author needs is on this page — you never have to read the shell's source.

A module is one kind of dashboard tile: the live transcript, the Now/Next display, a clock. Users add any number of tiles of your module to their own dashboards, move and resize them, and give each tile its own per-tile settings. Your module renders inside the tile and talks to the outside world only through the small API the shell hands it.

Rules every module follows

These are not style preferences — the admin page, the installer and the people running the booth depend on them.

  1. All settings live in the ProdDash admin page. Server-wide settings go in configSchema, per-tile settings in instanceSchema. Never a separate setup page, wizard, config file the operator edits by hand, or environment variable.
  2. Dependencies ship inside the module folder. Zero dependencies is the norm (the shell has none). Anything you genuinely need is vendored into the module — or fetched by your own init() on first start, with the admin health line saying what is happening. No npm install, no installer, no "run this first".
  3. Everything the module does runs inside the module. No helper scripts, companion programs, daemons or services the operator has to start next to ProdDash. If your feature needs a listener, a decoder or a worker, your server entry owns it: start it in init(), stop it in stop(), report it in health(). A child process the module itself spawns and supervises is fine; a program someone launches by hand is not.
  4. Declare the ProdDash version you need ("proddash": ">=1.1.0" in the manifest) and bump your own version whenever you change the module. The admin page refuses to install a module whose requirement the running shell doesn't meet, and it offers updates by comparing versions.

Keeping the interface clean

A dashboard is read across a booth, at a glance, by someone doing three other things. These rules are what keep every tile looking like one product instead of six. They are as binding as the four above.

  1. No explanatory text in a tile's gear. No help on an instanceSchema field or an instanceGroups entry. Labels are three words at most, one or two preferred — "Slide count", "Item tags", "Running clock". A control that cannot be understood in three words is not a display choice; it belongs in configSchema, where help is welcome and expected. One exception: a template field may keep a single help line naming its placeholders, which nobody can guess.
  2. Server-side settings are admin-only. Anything the module's server acts on — a host, a credential, what to fetch, how often to poll — is configSchema. instanceSchema is display choices and nothing else: what this tile shows, and how big.
  3. A tile setting changes that tile and nothing else. Never another tile, never the module as a whole, never the server. If two tiles must agree on something, it is either admin config or page state the shell owns — not a setting each tile keeps its own copy of.
  4. Offer toggles for the elements you draw. Every tile should let the operator switch off the parts it does not need. One to four toggles: give them no group at all, so they sit unlabelled at the top of the gear. Five or more: one group named Show, collapsed: true, columns: 2. Nothing else goes in that group — colours, formats and text belong in groups of their own.
  5. Title-bar controls are icons. No label on addButton or addMenu; the tooltip carries the words, and docs above covers drawing the icon. A small state indicator may ride alongside an icon — ProdCom's channel button shows a count while a filter is on — but a control is never named in the bar.
  6. If a control needs words to explain it, it is not a title-bar control. Put it in the gear. The bar is for actions whose icon and tooltip say everything: refresh, clear, jump to latest, bigger text.
  7. One control per action. Whatever has a dedicated title-bar button is not offered again in the gear, and the reverse. Text size is A− / A+ up there, never a "Text size" field; a timestamps button in the bar means no timestamps switch below it.

The shape of a module

A module is one folder dropped into modules/:

modules/<id>/
├── module.json      manifest (required)
├── client.js        ES module, client entry (required)
├── server.js        server entry (optional — only if you need server code)
└── ... assets       (css, images…) served statically at /modules/<id>/

Installation is exactly this: in the admin page, open Available modules and press Install — the module is downloaded from the ProdDash repo into the server's data directory (<data dir>/modules/<id>/) and starts immediately. Developing locally? Drop the folder into modules/ (bundled with the checkout) or into the data directory's modules/ and restart. No shell code changes, ever. Modules bundled with a checkout and modules installed from the admin page live side by side; when both have the same id the newer version runs. Uninstall in the admin page removes an installed module's files (a bundled one is hidden instead, since its folder belongs to the app); its settings are kept for a later reinstall.

module.json

{
  "id": "my-module",
  "name": "My Module",
  "version": "1.0.0",
  "proddash": ">=1.1.0",
  "description": "One line shown in the Add-tile picker and the admin page",
  "permissions": [
    { "kind": "microphone", "reason": "Why this module needs it, in one line" }
  ],
  "client": "client.js",
  "server": "server.js",
  "style": "style.css",
  "minSize": { "w": 2, "h": 2 },
  "defaultSize": { "w": 4, "h": 3 },
  "configSchema": {
    "upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" },
    "apiKey":      { "type": "password", "label": "API key", "default": "" }
  },
  "instanceSchema": {
    "channel": { "type": "string", "label": "Channel filter", "default": "" }
  }
}
Field Required Meaning
id yes Must equal the folder name. Lowercase, dashes.
name yes Human name shown on the tile header and in menus.
version yes Your module's version (x.y.z). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one.
proddash yes The ProdDash versions this module works with: ">=1.1.0", "^1.1.0", ">=1.1.0 <2.0.0", or "*". A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog.
description no Shown in the admin page / picker.
permissions no What macOS must allow before this module can work — see Permissions macOS has to grant. Ignored by the shell; read by the macOS launcher.
provides no Capabilities other modules can consume — ["timers"] today. Forwarded in /api/modules so a consumer can find you. See Working with other modules.
client yes Client entry file, loaded as an ES module.
server no Server entry file (CommonJS), loaded with require().
style no A stylesheet the shell injects once per module.
minSize, defaultSize no Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3.
configSchema no Admin config — server-wide settings (URLs, ports, keys) edited once at /admin for everyone.
instanceSchema no Per-tile settings — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices.
tiles no Fixed multi-tile list for client-only modules; server modules export a dynamic tiles() instead. See Presenting multiple tiles.

Both schemas map field names to specs:

{ "type": "string" | "text" | "number" | "boolean" | "switch" | "select" | "multiselect" | "template" | "color" | "password" | "endpoint",
  "label": "Shown next to the field",
  "default": <value>,
  "group": "Section heading",                         // optional; see below
  "showWhen": "<other boolean/switch key>",           // optional; see below
  "help": "One line shown under the control",         // optional, admin config only
  "options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ],  // "select" only
  "optionsRoute": "/devices",                         // "select" only, admin config only
  "optionsDependsOn": "<other key>"                   // with optionsRoute; see below
}

boolean and switch are the same true/false value. In a tile's gear popover both render as a switch; in the admin page switch is the toggle and boolean the plainer checkbox (use switch for a feature on/off that other fields depend on). Label a switch with the noun of what it turns on — "Seconds", "Top bar", "Section headers" — never "Show seconds": on means shown or enabled, off means hidden or disabled, and the label shouldn't say so twice. color renders a swatch picker and stores a #rrggbb string (give it a hex default; the tile applies it as a CSS custom property — see pco-plan). multiselect is several picks from one list — the same options / optionsRoute as select, rendered as one switch per option and stored as an array of values. A saved value the list no longer offers stays checked and marked, so nothing is dropped behind anyone's back. In a tile's gear popover it also takes columns (2–4) and allByDefault: true: a tile that has never chosen shows every option on and saves nothing until someone flips one, so options that turn up later are on as well (give it no default then). select and multiselect may use optionsRoute in instanceSchema too — the popover GETs /api/modules/<id><optionsRoute> like the admin page does.

template (tile settings) is a text with placeholders, built by dragging or clicking the variables the module offers into it: "variables": [ { "value": "{series}", "label": "Series" }, … ]. Keep help to the joining rules; the chips carry the names.

text is a multi-line string — a <textarea> in the admin page, stored with its newlines; one item per line is the convention (the slack module's quick replies). Admin config only.

group gathers consecutive fields under a labeled subsection in the admin page, so a module with several distinct concerns (e.g. a ProPresenter connection and an LTC audio input) reads as clearly separated blocks. Fields with no group render loose, before/after the grouped ones in schema order.

A manifest-level configGroups map can dress a group up further, keyed by the group name:

"configGroups": {
  "Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
  "Show / hide":                 { "collapsed": true, "columns": 3 }
}
  • collapsed: true (starts folded), false (starts open but can fold), or "whenSet" — folded once the group already holds values (a saved password, or anything other than the schema default), open while it is still empty. Omit it and the group is a plain, always-open block.
  • columns: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact.
  • help: guidance for the whole group. The admin page shows it as an ⓘ beside the group title (click or hover to read), so a group can carry a paragraph without pushing its fields down the page. Say where a value comes from, not what a field is.
  • toggle: the key of a switch field in this group. It renders in the group's title row, at the right, as the group's on/off; the rest of the group dims while it is off, and the field is not repeated in the body. This is how an integration is disabled without unmounting the module — ProPresenter timers, say, on a Sunday when ProPresenter isn't running.

help on a field puts one line of guidance under its control — allowed placeholders, where a value comes from. Keep labels short and put the explanation there. This is for configSchema only: a tile's gear carries no explanations at all (rule 5), so a setting that needs one is telling you it belongs in admin.

group works on instanceSchema fields too, and a manifest instanceGroups map plays the configGroups role for the tile's gear popover (collapsed: true | false, columns, toggle — but not help, per rule 5). That is how a module with a long show/hide checklist keeps the popover usable: five or more hide-toggles go in one Show group, folded by default, and fewer than five go in no group at all — see pco-plan for the long form and slack for the short one. Display choices belong there, per tile; admin config is for connections. The popover itself is a small window beside the tile — sized to its content, moved by the slim bar on top, closed by clicking anywhere else or pressing Escape — so a tile never has to be big enough to hold its own settings, and a change can be watched as it lands. Under everything you offer, the shell adds Tile tint — a colour the operator can wash that tile's chrome in, saved with the layout. It is not yours to declare and it never remounts you; don't offer one of your own.

A picker entry (see Presenting multiple tiles) may carry its own instanceSchema and instanceGroups: tiles added from that entry get those instead of the module's. A tile that shows one ProPresenter timer has no use for a clock's format switches — give it a schema of what applies.

showWhen: "<key>" hides a field until the boolean/switch field named <key> is on — e.g. the audio device and channel appear only once the LTC listener switch is enabled. The hidden field still keeps its stored value. It works the same in a tile's gear (instanceSchema): "Short day" and "Short month" appear once the clock's "Date" is on.

A select whose choices are only knowable at runtime (audio devices, serial ports, discovered sources) sets optionsRoute instead of — or as a fallback refresh of — static options: the admin page GETs /api/modules/<id><optionsRoute> and expects { "options": [ { "value", "label" }, … ] } from one of the module's own server routes. The saved value stays selectable even when the route is down or the option has vanished. (See propresenter-timers' "Audio device" for a worked example.)

An option may carry a group: options sharing one render under a single <optgroup>, so a tree (Planning Center folders, device classes) is browsable inside the select. optionsDependsOn: "<key>" makes the admin page fetch the options with that other field's current value appended as a query parameter (?<key>=<value>), now and again whenever it changes — e.g. a plan list that follows the chosen service type. (Both in pco-plan.)

password fields are admin-config only in practice: their values are stored in the server's modules.json (in the per-machine data directory, see the README), handed to your server entry, and never sent to browsers — not in /api/modules, and never echoed back into the admin form. Anything upstream that needs the secret must go through your server routes.

Connection settings: use endpoint

Anything that points at a device or service on the network — ProPresenter, ProdCom, a camera, a mixer — must be one endpoint field, so every module's connection is entered the same way. The admin page renders an endpoint as a single "Host / IP : Port" control; the value is stored and handed to your server as an object:

"propresenter": {
  "type": "endpoint",
  "label": "ProPresenter",
  "default": { "host": "", "port": 1025 }
}
// in your server entry
const { host, port } = config.propresenter;
const base = `http://${host}:${port}`;

Do not split host and port into separate string/number fields, and do not ask for a full http://… URL unless the setting genuinely is a web address where protocol or path matter (a page to embed, a webhook). Endpoints belong in admin config, not per-tile settings — connections are server-wide by design.

Admin config lives in modules.json inside the server's data directory (written by the admin page — your module never touches that file, and it survives updates of the app folder). Per-tile settings live inside each browser's layout.

Permissions macOS has to grant

Some things a module does need the operating system's permission, not just configuration: reading an audio input, reaching gear on the local network. macOS grants those to the application that owns the process, which for ProdDash is the macOS launcher (launcher/macos) — the server is its child, so the prompts are asked, and remembered, in ProdDash's name. A server started over SSH or from a bare launchd job has no such owner, and the permission is silently absent rather than refused: an LTC input reads as endless zeroes, a local host simply never answers.

Declare what you need, and why, so the launcher's Permissions tab can list it and ask for it before a service rather than during one:

"permissions": [
  { "kind": "microphone",    "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
  { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
]
kind When to declare it
microphone The module reads an audio input (ltc-capture and anything like it).
local-network The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say.

reason is one line, shown to the operator as-is when they hover the permission; modules giving the same reason are listed together. A bare "permissions": ["microphone"] works when the standing explanation is enough. A kind the launcher doesn't manage is still listed, so nothing goes unmentioned — the shell itself ignores the key entirely, and other platforms ignore it too.

Client entry (client.js)

An ES module whose default export is a factory. The shell calls it once per tile:

export default function create({ root, moduleApi }) {
  return {
    start() {},            // begin rendering / connecting
    stop() {},             // tear down EVERYTHING (timers, streams, observers)
    onResize(w, h) {},     // optional; tile was resized (w, h in grid cells)
    onConfigChange(cfg) {} // optional; admin config was saved
  };
}
  • root — the tile's body element. The module owns everything inside it and must never touch DOM outside it.
  • All four methods are optional, but a module without stop() will leak timers when its tile is removed. stop() must leave nothing running.

moduleApi

Member What it is
id Your module id.
instanceId Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile.
variant For multi-tile modules: the id of the tile-list entry this tile was added as, '' otherwise (see Presenting multiple tiles).
config Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client.
instanceSettings This tile's settings: your instanceSchema defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. Keys beginning __ are the shell's own (__tint, the tile's colour) — never use that prefix, and ignore the ones you find.
saveInstanceSettings(patch) Merge patch into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets inside your tile (a toggled filter, a chosen tab).
fetch(path, opts) fetch scoped to your server routes: fetch('/state') hits /api/modules/<id>/state.
sse(path, handlers) An EventSource scoped the same way, with auto-reconnect (see below). Returns a handle with close().
setStatus(state, msg) Drives the tile's status dot: 'ok', 'connecting' or 'error', plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed.
header.addButton(…) Put a small button in the tile's own title bar (see below).
header.addMenu(…) Put a dropdown menu button there (see below).

Title-bar controls

Modules don't build their own toolbars — actions live in the tile's title bar, next to the shell's gear/close buttons:

const btn = moduleApi.header.addButton({
  icon: '<svg …>…</svg>',   // inline SVG string (13 px), and/or
  label: 'Timers ▾',        // short text
  title: 'Larger text',     // tooltip — one or two words; the shell shows
                            // it the moment the pointer arrives
  onClick() { … },
});
btn.classList.add('active'); // toggle styling for on/off buttons

const menu = moduleApi.header.addMenu({
  label: 'Channels ▾',
  title: 'Channels',
  build(menuEl) {
    // called on every open with an emptied menu element — fill it with
    // buttons (give them your scoped classes; the container is styled and
    // fixed-positioned by the shell, so the tile's overflow can't clip it)
  },
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time

Everything you add is removed automatically when your instance stops, so a remount never duplicates controls. Icons only, no labels (rule 9): the title bar is 32 px tall and shared with the tile's name, and the tooltip says what the icon means. Draw icons as inline SVG rather than characters — a glyph such as ✕ or + takes its size from whatever font each browser picks; text-size buttons are a drawn − and +. A small state indicator may ride alongside an icon (ProdCom's channel button carries a count while a filter is on); a name may not.

One control per action (rule 11). Whatever has a dedicated header button is not offered again in the gear popover: text size is − / + up here (persisted through saveInstanceSettings({ textSize }), read back with a sane fallback), never a "Text size" field in instanceSchema; a timestamps or newest-first toggle in the header means no such switch in the gear. The gear is for display choices that have no button of their own.

One more thing to know: the whole title bar folds away when the dashboard auto-hides it, and always in fullscreen (a clean-view mode for wall displays) — it comes back only while the pointer is at the tile's top edge. Your controls fold away with it, so nothing essential — status, reconnects, live data — may exist only as a header control.

sse(path, handlers) handlers:

const stream = moduleApi.sse('/stream', {
  open(e)    {},                 // (re)connected
  error(e)   {},                 // connection lost — say so via setStatus
  message(e) {},                 // unnamed "data:" events
  events: { state(e) {} },       // named events by name
});
// later: stream.close()

Your route serves a plain SSE stream; how it reaches the page is the shell's business. A dashboard holds one connection for every stream on it (GET /api/stream, which the server feeds from your route over loopback) — browsers allow about six connections to a host and a tile per stream ran out of them — so open also fires whenever that shared connection is remade (a tile was added or removed) and whenever your route comes back after a drop. Browsers' EventSource retries transient drops itself but gives up for good when a retry gets a completed non-SSE response — which is what a proxy's 502 looks like while the upstream is down; the shell recreates a closed connection on a 3-second timer until it works again, so reconnect logic is free: handle open (refresh/backfill your state; you may have missed events) and error (report degraded status), and you're resilient. Streams you forget to close are force-closed when the tile unmounts — but close your own in stop() anyway.

Lifecycle you must expect

  • Any number of instances. Two transcript tiles with different filters is a feature. Keep all state inside create()'s closure — no module-level mutable state in client.js.
  • Remounts are routine. Gear settings apply as the user changes them — there is no Apply button — so each change calls stop() and starts a fresh instance (typing is debounced, and an unchanged value remounts nothing). Starting cheaply matters: hold connections and timers in the closure and tear them down in stop(). When admin config changes, the shell calls your onConfigChange(cfg) if you export one, otherwise it remounts you. Either way you must come back cleanly.
  • The tile can be removed at any moment. stop() is your only notice.

Rules for the client

  1. Never touch DOM outside root. Never define global CSS. Scope every rule in your stylesheet with the attribute the shell puts on your tiles:

    [data-module="my-module"] .my-thing { … }
    

    (Shadow DOM works too, if you prefer full isolation.)

  2. Style with the shell's CSS variables — no hardcoded colors. The palette: --bg (page), --panel (tile), --border, --text, --muted, --accent, --accent-text (text that sits on the accent — dark on most themes, white where the accent is deep), --danger, --warn. Derive tints with color-mix(in srgb, var(--accent) 20%, transparent). This is what makes every module look native — and what makes the dashboard's themes (Admin → Theme) reach into every tile: a theme swaps those variables and nothing else. A colour a tile is configured with (a color instance setting, applied as a custom property on the tile) is set closer than the theme and wins over it, by design.

  3. No external network calls from the client. Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your module's server routes — same reasoning as the reference apps: no CORS, no per-browser credentials, one place that owns the connection.

  4. Handle upstream loss. Reconnect with backoff (or lean on sse()), and report honestly via setStatus. Tiles run unattended during live services; a module that silently freezes is worse than one that says it's down.

Server entry (server.js)

Optional — only needed when your module talks to something upstream or keeps server-side state. CommonJS, Node built-ins only (keep the zero-dependency ethos; Node 18+ so global fetch is available). Exports either or both of:

module.exports = {
  init({ config, log, shell }) {
    // Called on startup / re-init, BEFORE routes(). Start your upstream
    // connection here. Return a handle:
    return {
      stop() {},                                  // tear everything down
      health() { return { status: 'ok', message: '' }; }  // optional
    };
  },

  routes({ config, log, shell }) {
    // Route table, mounted under /api/modules/<id>/
    return {
      'GET /state':     (req, res) => { … },
      'GET /stream':    (req, res) => { … },      // SSE — see notes
      'POST /command':  (req, res) => { … },
      'GET /proxy/*':   (req, res) => { … },      // trailing /* = prefix route
      '* /anything/*':  (req, res) => { … },      // * method = any method
    };
  },
};
  • config is your admin config: configSchema defaults overlaid with what the admin page saved — including password fields.
  • log(...) prefixes output with your module id.
  • shell.port is the port ProdDash is serving on — what you need to read another module's routes over loopback (http://127.0.0.1:${shell.port}/api/ modules/<id>/…). Never guess it.
  • Handlers get plain Node (req, res). On a prefix route, req.wildcard is the matched remainder (leading / included) and req.search is the query string (with ?, or '') — so a proxy rebuilds the upstream path as req.wildcard + req.search.
  • Exact routes match the path exactly; prefix routes (/x/*) match /x and everything under it. First declared match wins.
  • Re-init instead of restart: when the admin page saves your config, the shell calls your handle's stop(), then init() and routes() again with the new config. Keep module-level state inside init()'s closure (or reset it there) so a re-init starts clean. Disabling a module unmounts its routes entirely (clients get 404).
  • health() feeds the admin page's status line — report your upstream's reachability (status: 'ok' | 'connecting' | 'error'), not just "running".
  • A throw from init()/routes() marks the module as failed: the error shows in /admin, and API calls answer 502 until a config fix re-inits it. Validate config early so bad input fails loudly there.

Presenting multiple tiles

By default a module is one entry in the + Add-tile picker. A module can instead present a list of tiles — one picker entry each, grouped under the module's name. This is how a module whose content is naturally plural works: one tile per ProPresenter timer, one tile per admin-configured page.

Export tiles from your server entry (sync or async):

module.exports = {
  init({ config, log }) { … },
  routes({ config, log }) { … },

  tiles({ config, log }) {
    // e.g. derived from admin config, or discovered from your upstream
    return config.pages.map((page) => ({
      id: page.name,                    // stable id, stored with the tile
      name: page.name,                  // picker label AND the tile's title
      description: page.url,            // optional; the picker's tooltip
      settings: { page: page.name },    // preset instance settings
      defaultSize: { w: 4, h: 3 },      // optional per-entry size overrides
      minSize: { w: 2, h: 2 },
    }));
  },
};
  • tiles() runs on every picker load, so the list may be live data — keep it fast (answer from state you already hold; don't fetch upstream on demand). A throw or a hang (>2 s) falls back to the classic single entry, so a dead upstream can't break the picker.
  • instanceSchema / instanceGroups on an entry replace the module's for tiles added from it — settings that fit that tile, nothing else.
  • aliases: ['old-id', …] on an entry names earlier variant ids it stands for: a tile added under one of them (a layout saved by an older version of your module) gets this entry's settings too. A tile whose variant matches no entry and no alias falls back to the module's full instanceSchema.
  • Entry descriptions are tooltips in the + picker now, not visible lines — one short sentence.
  • single: true on an entry allows at most one such tile per dashboard: the picker greys the entry out once it is placed. Use it for a control surface that would only confuse in duplicate — a send box, a master switch.
  • When the user adds an entry, its settings overlay your instanceSchema defaults in that tile's instance settings, the entry's name becomes the tile's title, and moduleApi.variant carries its id.
  • A tile is shown only while its entry is offered. The shell mounts a tile added from an entry only when that entry (or one of its aliases) is in your list right now; otherwise the tile keeps its place in the layout, draws nothing, and appears the moment your list offers it again — a Service timer waits for PCO Plan, a transcript for its channel. So your client is never asked to render an entry you did not offer at mount time. One case is still yours: an entry that vanishes after the tile mounted (a timer deleted in ProPresenter mid-service). Render nothing for it — no placeholder, no error — and the shell folds the tile away at its next registry refresh. Never fall back to your default view in its place: five different timers turning into five "All timers" is worse than five gaps.
  • A client-only module can declare a fixed list as a tiles array in module.json instead (same entry shape).
  • An empty list (or no tiles at all) keeps today's behavior: one picker entry, no preset.

Server-Sent Events from a module

For live data, expose a /stream route and let clients use moduleApi.sse('/stream', …):

'GET /stream': (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
  });
  res.write('retry: 3000\n\n');
  res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint
  streams.add(res);
  req.on('close', () => streams.delete(res));
}

Broadcast only when the payload actually changed, send a : ping\n\n comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every per-client res.write in try/catch (drop the client on failure). The propresenter-now-next module is the reference implementation of this pattern.

Working with other modules

Modules don't import each other. When one needs what another knows, it reads that module's own server routes over loopback, exactly as a browser would: http://127.0.0.1:${shell.port}/api/modules/<id>/…. The PCO Plan module follows ProPresenter this way — it reads propresenter-now-next's /stream rather than talking to ProPresenter a second time. A consumer must survive the provider being disabled, uninstalled, or restarting mid-service (404s and dropped connections are normal; retry with backoff and say so in health()).

Offering timers to the Timers module

The Timers module shows timers from anywhere: ProPresenter, LTC, its own clock, and any module that offers some. A module offers timers by:

  1. Declaring it — "provides": ["timers"] in module.json. The shell forwards provides in /api/modules, which is how the Timers module finds providers without a list anyone maintains.
  2. Serving GET /timers from its server routes, returning a snapshot:
{
  "source": "pco-plan",
  "label": "PCO Plan",
  "updatedAt": 1757800000000,
  "timers": [
    {
      "id": "service",
      "label": "Service",
      "kind": "elapsed",
      "state": "running",
      "startedAt": 1757799000000,
      "elapsedMs": 1000000,
      "remainingMs": null,
      "targetMs": 4500000,
      "status": { "text": "Welcome & Announcements", "tone": "ok" },
      "detail": "Item 3 of 12"
    }
  ]
}
Field Meaning
source, label Your module id, and the heading the Timers module puts over your timers.
updatedAt ms since epoch when this snapshot was taken. Every duration below is as of this instant.
id Stable within your module — the Timers module keys tile settings (shown/hidden, colours) on source + id.
kind elapsed (counts up), countdown (counts down to zero), clock (a time of day; put the ISO time in status.text).
state running, paused, stopped (finished; value frozen), idle (exists, hasn't started — a timer that will start at 11:00).
startedAt ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll.
elapsedMs, remainingMs The values at updatedAt; null when not meaningful for the kind.
targetMs Planned length (elapsed) or full length (countdown), or null. Lets the consumer colour overruns.
status Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". tone: ok, warn, danger, muted.
detail Optional second line.

The consumer derives the displayed time locally from startedAt/elapsedMs and its own clock, so a snapshot every couple of seconds is plenty — /timers must be cheap to answer (serve from state you already hold; never call upstream to answer it). Optionally also serve GET /timers/stream as SSE (event: timers, data: the same object, on every change plus a heartbeat comment every 15 s); the consumer uses the stream when it exists and polls /timers otherwise. Only offer timers a production tech would want on a wall — the Timers module lets them hide any of them per tile, but every entry you add is a decision someone has to make.

Complete minimal example

A working clock — copy-paste this as your starter. (A slightly fancier version ships in modules/clock/.)

modules/hello-clock/module.json

{
  "id": "hello-clock",
  "name": "Hello Clock",
  "version": "1.0.0",
  "description": "Minimal example module",
  "client": "client.js",
  "style": "style.css",
  "minSize": { "w": 2, "h": 1 },
  "defaultSize": { "w": 3, "h": 2 },
  "instanceSchema": {
    "showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }
  }
}

modules/hello-clock/client.js

export default function create({ root, moduleApi }) {
  let timer = null;

  function tick() {
    const { showSeconds } = moduleApi.instanceSettings;
    root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString(
      [], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) }
    );
  }

  return {
    start() {
      root.innerHTML = '<div class="hc-time"></div>';
      tick();
      timer = setInterval(tick, 250);
      moduleApi.setStatus('ok', 'Ticking');
    },
    stop() {
      clearInterval(timer);
      root.innerHTML = '';
    },
  };
}

modules/hello-clock/style.css

[data-module="hello-clock"] .hc-time {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.4em;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

Drop the folder in modules/, restart ProdDash, and “Hello Clock” appears in the Add-tile picker (new modules are enabled by default; /admin can disable them).

Testing your module

Run it:

node server.js        # from the repo root — prints the LAN URL

The server logs each module it mounts (or why it refused to). /admin shows mount errors and your health() line; the browser console shows client-side failures (a tile that fails to start says so in the tile).

Poke the pieces directly:

  • GET /api/modules — what the shell sees (your manifest + non-secret config).
  • GET /api/modules/<id>/... — your routes, curl-able. For SSE: curl -N http://localhost:24500/api/modules/<id>/stream.
  • GET /api/admin/state — enabled flags, health, mount errors.

Mock upstreams (so you can develop without the real gear):

  • ProdCom: node tools/prodcom-mock.js (port 24480) — channels, groups, history, and a live stream with in-progress speech. Point the transcript module's ProdCom URL at http://127.0.0.1:24480.
  • ProPresenter: node band-lineup-display/tmp/pp-mock.js 1599 — playlists and an advancing slide index (including the arrangement-repeat case). Point the Now/Next module at host 127.0.0.1, port 1599.
  • ProPresenter timers: node tools/pp-timers-mock.js 1600 — running, overrunning, stopped and elapsed timers plus an LTC timecode that periodically drops to no-signal (--no-ltc serves a ProPresenter without a timecode API). Also serves the stage-display websocket with an LTC-labeled layout field (--stage-pwd, --no-ltc-field exercise its failure paths). Point the Timers module at host 127.0.0.1, port 1600.
  • Planning Center: node tools/pco-mock.js (port 24700) — folders, service types, plans (last week / today / next week), plan times and items with songs, keys and item notes, behind a Basic/Bearer auth check. Planning Center is a cloud API, so the module reads its base URL from the environment instead of admin config: run ProdDash with PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js, enter any Application ID + Secret under PCO Plan in /admin, save, then pick "Sunday Service" from the Service type list. Its item titles match pp-mock.js, so running both mocks exercises the ProPresenter matching.

The resilience checklist — every module must pass this before it's done:

  1. Add your tile, then kill the upstream (or the mock). The status dot must go red (with a useful hover message) and the tile must show a clear degraded state, not a freeze.
  2. Restart the upstream. The tile must recover by itself — no reload, no clicking.
  3. Restart ProdDash itself with the tile open. Same expectation.
  4. Add two tiles of your module with different per-tile settings — both must work independently.
  5. Remove your tile, then check the browser console and server log: no errors, no timers still firing, no streams still open.
  6. Change your admin config in /admin while the tile is open — the tile must pick it up (via onConfigChange or the automatic remount).
  7. Reload the page — the tile comes back with its settings intact.