feedBack plugin best practices¶
Practical, non-normative guidance for building good feedBack plugins. The specification is the contract — what a plugin must do to load. This guide is the advice — what a plugin should do to be a good citizen. Nothing here overrides the spec.
If you are writing your first plugin, start from examples/minimal-plugin
and grow it toward examples/full-plugin.
Getting started¶
1. Start from the smallest thing that loads¶
The smallest valid plugin is a folder with one file:
Get that discovered first (folder name equal to id, dropped into a plugins directory), then
add one surface at a time — a screen, then settings, then routes. Adding surfaces incrementally
means when something stops loading you know exactly which change caused it.
2. Choose the id carefully — it's a permanent, far-reaching identifier¶
Your id is not just a label; the Host derives a surprising amount of machinery from it. It keys
the settings store, the routes namespace, and the capability participant, and it is interpolated
into names all over the app: the server routes module (plugin_<id>_routes), the screen container
element (plugin-<id>), a visualization's factory global (window.feedBackViz_<id>), the
diagnostics path (plugins/<id>/), and the conventional localStorage prefix. A collision or a
rename therefore ripples through all of them at once.
So:
- Pick it once and never change it. Renaming an
idsilently orphans every user's saved settings and breaks every derived name above. Changing theidis a new plugin, not a new version (see spec §4.2). - Make it specific and unique. Prefer a descriptive
id(drum_highway_3d, notdh3or a genericplayer/viz). A genericidis the easiest way to collide with someone else's plugin. - The folder name MUST equal the
id, exactly, including case. A folder namedTunerortuner-pluginholding{"id": "tuner"}is simply not discovered — the most common "why won't my plugin load?" (see spec §5.2). - Stick to
^[a-z0-9][a-z0-9_-]*$(lowercase, with-or_separators — both are fine and both are used in practice). The charset isn't cosmetic: theidis spliced into a Python module name and DOM/JS identifiers. Uppercase breaks the exact-match discovery rule, and dots/spaces/other punctuation break module or element naming. The reference validator rejects anything outside this set — run it (python tools/validate.py).
Collisions with an existing plugin. When two plugins share an id, only one loads, and the
rule is not "last one wins":
- A bundled (first-party) plugin always wins — a plugin you install that reuses a bundled
idis silently ignored (the Host keeps it only as a fallback if the bundled copy fails). Before naming a plugin, make sure theidisn't already a bundled one. - Between two non-bundled plugins, the first the Host discovers wins and the other is dropped — so a
duplicate
idmeans one of them silently doesn't load.
Reserved ids. Do not name a plugin capability_inspector, or use an app_tour_ prefix, unless
you intend to replace those core surfaces — the Host treats them as always-enabled (they cannot be
disabled), so a collision there is especially sticky.
Namespace what the id doesn't namespace for you. Because every plugin shares one window and
one document, prefix anything you put in a shared space with your id: localStorage keys, any
window globals you must expose, your routes (/api/plugins/<id>/…, rule 7), and your CSS (rule 10).
Two plugins writing window.state or localStorage["theme"] clobber each other silently.
3. Keep the manifest declarative¶
Every file your plugin uses at load time should be pointed at from the manifest — script,
screen, styles, settings.html, routes, tour. If the Host has to guess a filename, a
future Host change can break you. Conversely, don't reference files you don't ship.
4. Version your plugin with semver¶
Set version and bump it on every release: PATCH for fixes, MINOR for new optional behaviour,
MAJOR when you change or remove behaviour users depend on. The plugin manager uses it to detect
updates, and users use it to report bugs against a known build.
Server routes¶
5. Do nothing at import time¶
# Good — all work happens inside setup()
from fastapi import FastAPI
def setup(app: FastAPI, context: dict) -> None:
config_dir = Path(context["config_dir"])
log = context.get("log") or logging.getLogger("feedBack.plugin.my_plugin")
...
Importing your routes module must be cheap and side-effect-free: no network calls, no file
reads, no blocking. The Host imports every plugin's routes during startup; slow or failing
imports slow or break the whole load.
6. Register routes only after you've validated¶
The Host may be unable to un-register a route once mounted. So validate configuration, open
files, and check inputs before the first app.get(...) / app.post(...). A setup() that
mounts two routes and then throws leaves two half-working endpoints behind permanently.
7. Namespace everything under your id¶
- Routes:
"/api/plugins/my-plugin/state", not"/state". - CSS: scope selectors to your screen's root element, don't style bare
body/h1. - Persisted files: write under the
config_dirthe Host hands you, in a file named for your plugin (config_dir / "my_plugin.json").
Collisions with the Host or another plugin are silent and maddening; namespacing prevents them.
8. Persist state where the Host tells you¶
Read and write only inside context["config_dir"] and any paths you declared in
settings.server_files. Never write elsewhere in the Host's config/data tree, and never read
another plugin's files. This is what keeps export/import, backups, and uninstall clean.
Client screen & the shared main thread¶
Your script does not run in a sandbox. It executes in the app's own document and global
scope, on the same main thread as everything else — including a real-time note-highway that
renders at ~60 fps and mutates the DOM many times per second during play. Every millisecond your
screen spends on the main thread is a millisecond the render loop doesn't have. Most plugin
performance regressions in feedBack's history came from this one fact. The rules below are the
ones that were learned the hard way.
9. Never touch the DOM or read layout on a per-frame / high-frequency path¶
Do not call querySelector / querySelectorAll, read layout (getBoundingClientRect,
offsetWidth, offsetHeight), or write styles inside anything that fires at frame rate:
requestAnimationFrame callbacks, a draw() loop, a short setInterval, or a MutationObserver
callback.
A real profiled lag report in feedBack traced to three plugins each doing a
querySelectorAllevery frame — together ~18% of main-thread CPU, all of it stolen from the render loop.
Resolve the elements you need once, on mount, cache the references, and re-resolve only when a
cached node has actually detached (!el.isConnected). Reading layout and writing styles in the
same high-frequency pass also forces synchronous reflow ("layout thrash") — batch all reads, then
all writes.
10. Don't observe or mutate the app shell — use the registration APIs¶
It is tempting to reach into the app's own UI — the song/library cards, the nav bar, the transport
— with document.querySelector and inject or rewrite nodes. Don't. The shell re-renders those
regions (the library grid is virtualized and repaints on every scroll frame and on score updates),
so anything you inject gets clobbered, and a MutationObserver watching a shared container fires
on every one of those repaints.
feedBack specifically removed a legacy pattern where several plugins added buttons to song cards by DOM-observing
.song-card; it was replaced with a Host-provided card-action registration API for exactly this reason.
So:
- Contribute UI to a shared surface through the Host's registration API for that surface, not by mutating its DOM.
- Never
MutationObserver.observe(document.body, { subtree: true })(or any shared container withsubtree: true) — it turns every shell mutation into your work. - Keep any predicate the Host calls per item (e.g. "does this card-action apply to this song?") O(1) and allocation-free — it runs once per visible card on every re-render.
11. No synchronous storage or awaited I/O on a hot or fan-out path¶
localStorage is synchronous — it blocks the main thread while it runs. fetch is asynchronous,
but awaiting a network round-trip inside a hot handler still stalls that handler on I/O. Neither
a synchronous localStorage call nor an awaited fetch belongs in a handler that fires per note,
per frame, or on a high-frequency event. The worst offender is doing
this in response to a gameplay event (a song-complete / score event can arrive at the exact moment
the frame budget is tightest). Read settings once on mount and cache them; debounce writes to an
idle callback or a single requestAnimationFrame.
12. Make re-hydration idempotent (plugin-runtime-idempotent.v1)¶
The Host may re-run your script mid-session (for example when the plugin set reloads). If a
second run installs another event wrapper, timer, observer, DOM root, or capability participant,
you now have duplicates — a class of bug that has duplicated audio signal chains in practice.
Make a second run a no-op. The established pattern is a stable singleton on window:
// Idempotent (plugin-runtime-idempotent.v1): re-hydration replaces impl, installs nothing twice.
const hooks = (window.__feedBackMyPluginHooks ||= { installed: false, impl: null });
hooks.impl = makeImpl(); // always refresh the implementation
if (hooks.installed) return; // …but wire listeners/timers/wrappers only once
hooks.installed = true;
Only put "plugin-runtime-idempotent.v1" in your manifest standards once this is actually true
of your screen.
13. Stop work when your screen is hidden, and stay per-instance¶
- When your screen is not the active one, cancel your
requestAnimationFrameloop and unsubscribe from playback/gameplay events. A background plugin animating or listening during play is pure waste on the hottest path. - Keep all state per screen instance, not in module-level globals — feedBack can show two screens at once (splitscreen), so a global counter or a single cached element handle will fight itself across instances.
14. Talk to other plugins through capabilities, not their globals¶
To drive another plugin or a shared subsystem, use the capability pipeline's
claim / dispatch / release flow rather than reaching into another plugin's window globals
or internal objects. Declare your own participation honestly (roles, ownership, safety) and
service only the commands/events you actually implement. Reaching into foreign globals is exactly
the coupling the capability system exists to remove, and it breaks the moment that plugin reloads.
Visualizations¶
A plugin whose manifest sets "type": "visualization" can replace the app's note-highway
renderer. The Host runs this renderer inside its own ~60 fps draw loop, and — critically — the app
can show several highways at once (splitscreen). Everything below exists so one renderer works
correctly when the Host makes many copies of it. The mechanism names here (the feedBackViz_<id>
factory global, the renderer methods, applySetting) are the current Host contract; treat the
principles as stable and the exact API as Host-versioned.
15. Always register a factory, never a singleton¶
Expose your renderer as a factory function — a zero-argument function the Host calls to get a
fresh renderer instance every time — on the global window.feedBackViz_<id> (where <id> is
your plugin.json id). Do not assign a single shared renderer object.
This is the whole reason splitscreen works: the Host creates one highway per panel and calls your factory once per panel, so N panels get N independent renderers. A singleton would have every panel fight over one WebGL context, one canvas, and one set of meshes — the classic splitscreen bug.
function createRenderer() {
// ALL state is per-instance closure state — one set per panel.
let canvas, gl, meshes, unsubscribe;
return {
contextType: 'webgl2', // '2d' (default) or 'webgl2'; read before init()
init(canvasEl, bundle) { canvas = canvasEl; /* acquire own context, build scene */ },
draw(bundle) { /* render this frame from the snapshot */ }, // the only REQUIRED method
resize(w, h) { /* rebuild framebuffers */ },
destroy() { unsubscribe?.(); /* free GL + DOM */ },
};
}
window.feedBackViz_my_viz = createRenderer; // the global IS the factory function
window.feedBackViz_my_viz.contextType = 'webgl2'; // optional static, read before constructing
The renderer interface: draw(bundle) is required; init(canvas, bundle), resize(w, h),
destroy(), contextType, and readyPromise are optional. The Host lifecycle is
factory() → init(canvas, bundle) → per-frame draw(bundle) → resize on canvas change →
destroy() on renderer swap or stop.
16. Keep every resource per-instance and release it in destroy()¶
Hold your context, buffers, meshes, DOM overlays, and event subscriptions in the factory's closure,
one set per instance — never in module-level globals or a single shared DOM node parented to "the"
panel. destroy() runs on every swap and on stop and MUST release everything (unsubscribe, free GL,
remove any DOM you added); a leak here multiplies by the number of panels. Resolve any DOM against
your own instance's container, never a global document.querySelector that could grab a sibling
panel's node.
17. Treat the per-frame bundle as read-only, and keep draw() allocation-free¶
The bundle the Host passes to draw() is a snapshot object reused across frames — its array
fields are live, read-only references, not copies. Never mutate it, and never cache its identity or
its arrays across frames. Because draw() runs ~60 times a second per panel, do no allocation
and no DOM/layout work inside it (see rule 9) — precompute on init/resize.
18. Self-detect canvas size changes¶
Don't assume the Host will call your resize(). Under splitscreen the host may resize the highway
without forwarding the call to your renderer, so check the canvas's width/height at the top of
draw() against the last size you applied and rebuild your framebuffers when it drifts. (This was a
real bug where 3D highways stayed framed for their pre-fullscreen size in splitscreen.)
19. Communicate settings through applySetting, per instance — not a side channel¶
For user-adjustable controls, declare a settings array on your visualization capability
({ key, label, type: "toggle" | "range" | "select", default, min/max/step, options }) and
implement applySetting(key, value) on the renderer instance. The Host validates the
descriptors, renders the controls, owns persistence, and calls applySetting on each specific
per-panel instance — so a change reaches every panel and is inherently per-instance, with no
shared global keys and no canvas-to-panel lookup to get wrong.
Hard-won rules this replaces — the ways settings communication actually broke:
- Apply live; never reload. Applying a setting via
location.reload()reboots the app and drops the user out of the settings panel. Update the running renderer instead. - Don't let one setting leak into another. If you migrate an old setting into a new one, back it up once on load and persist it without re-broadcasting; a "mirror on every read" makes one control silently overwrite another, and the render disagree with the UI.
- Scope keys deliberately. Only genuinely per-panel controls get per-panel storage; shared state (a palette, an uploaded asset) stays global, so a stale per-panel override can't shadow a global edit or duplicate a heavy asset per panel.
- Reach every instance. A settings change must fan out to all mounted panels, each re-reading in its own scope — not just the panel that happens to be focused.
- The settings panel loads before your renderer.
settings.htmlis injected before yourscriptruns, so guard any calls into your renderer's globals (window.myViz && window.myViz…) and let the panel hydrate its own controls from persisted values/defaults independently.
On persistence and localStorage. Under the applySetting contract the Host owns
persistence — declare the setting, apply values live, and let the Host store and replay them.
Prefer that: do not hand-roll settings into localStorage, which is what keeps export/import and
backups whole and stops per-panel copies from drifting. If your plugin nonetheless manages its own
persistence (a self-managed viz that predates the contract), two rules from the fixes apply:
localStorage is synchronous and can throw (quota / private mode), so stage the new value in an
in-memory fallback before the setItem and prefer that in-memory value on read — a failed write
must never leave the renderer showing a stale value while the UI claims the change applied. And
never touch localStorage on a per-frame path (rule 9) — read it once and cache it.
20. Fail safe — the Host reverts a broken renderer¶
If your draw() throws on several consecutive frames the Host automatically reverts to the built-in
renderer and emits a revert event. Guard draw() so a transient error degrades one frame rather
than tripping the auto-revert and dropping the user back to the default visualization.
Minigames¶
A minigame is a plugin that registers a small, self-contained game into the bundled
minigames host — a hub + SDK that lists every registered game, runs one at a time, and owns
scoring and progression. You don't build a standalone game screen; you ship a normal plugin that
hands the host a spec, and the host mounts, runs, tears down, and scores it. As with the
visualization contract, the names below (window.feedBackMinigames, the minigame manifest block,
spec.start/spec.stop) are the current Host contract — treat the principles as stable and the
exact API as Host-versioned.
21. Register into the host — and bind late¶
The host loads after your plugin (plugins load alphabetically), so window.feedBackMinigames
usually does not exist yet when your script runs. Never assume it's there. Register through
the host's pending queue, which the host drains on init, and/or wait for its ready event:
// Queue now; the host drains this when it initializes.
(window.__feedBackMinigamesPending ||= []).push(spec);
// Belt and braces: if the host is already up, or when it announces itself, register directly.
window.feedBackMinigames?.register(spec);
window.addEventListener('feedBack-minigames-ready',
() => window.feedBackMinigames.register(spec), { once: true });
Registration is idempotent by spec.id, so queueing and handling the ready event registers once.
22. Declare a minigame manifest block; keep spec.id === plugin id¶
Put a minigame block in your plugin.json (display metadata — title, tagline, thumbnail,
modifiers) and register a runtime spec whose id exactly equals your plugin id. The host
treats the manifest as authoritative for display and the JS spec as the runtime behaviour; if the
two ids diverge, runs are misattributed and your manifest metadata is dropped. (This is the rule 2
id contract, applied to minigames.)
23. Implement start/stop — and release in stop() everything you create¶
Your spec provides start({ container, modifiers, sdk }) (required) and stop(). Render your game
into the container the host gives you; drive input and scoring through the sdk. The host
tears your session down on navigation away mid-run (a screen:changed), and on teardown it cancels
only its own resources — its scheduler timers, the stage DOM, the summary listener. Every
requestAnimationFrame loop, AudioContext, getUserMedia stream, timer, and listener you open
is yours to release in stop(). The host cannot cancel a loop it never knew about, so anything
you forget leaks past the end of your game and keeps burning the main thread (see rule 9).
24. Make start supersede-safe¶
A user can double-tap a game tile, or navigate away while your start is still awaiting. Guard
against both: flip a synchronous "starting" flag on entry (don't rely on an async-set "active"
flag), and capture a start generation you re-check after every await, bailing if it changed. An
in-flight start that lost its race must not finish mounting or begin a run after teardown.
25. Be standalone by default; set usesPlayer only if you drive the highway¶
Minigames share the guitar input, not the song library or playback — a game is standalone by
default and is torn down on any screen change. Only if your game deliberately navigates to the
player screen and drives the note-highway itself should you mark it usesPlayer, which tells the
host not to tear you down for that one navigation. If you set it, you own your own
abandonment/cleanup for that hop.
26. Let the host score and persist; use the SDK for audio; expect one session¶
- Report results, don't persist them. End a run through the
sdkyourstartreceived (sdk.end({ score, durationMs, modifiers, meta })); the host owns the runs database, the profile, and XP/progression, writes them atomically, and reconciles with the app's unified XP. Don't hand-roll a scores file. Keepmodifiers/metasmall (the host caps them). - Use the SDK's scoring/audio helpers rather than opening your own microphone — on desktop the
SDK pulls post-noise-gate frames from the native engine, where a naive
getUserMediawould grab the wrong device. - Assume exactly one active session. The host runs a single game at a time with singleton overlays; it does not support two concurrent minigame sessions (e.g. under splitscreen). Don't build a game that assumes it can run alongside another.
Organizing client code across files¶
A plugin declares a single script entry (screen.js), but that doesn't force you into one giant
file. You can split your client code two ways, depending on how the Host loads your entry: as an ES
module (recommended, scriptType: "module") or as a classic script (the default, and the only
option on older Hosts). The mechanics below are the current Host contract.
27. Split with an ES-module entry (scriptType: "module")¶
Set "scriptType": "module" in plugin.json and make your entry a one-line re-export of a src/
tree — the Host then loads screen.js as <script type="module">, so real import / export work:
// plugin.json
{ "id": "my-plugin", "script": "screen.js", "scriptType": "module", "minHost": "0.3.0-alpha.1" }
my-plugin/
├── screen.js # one-line module entry
├── src/ # served by the Host; imports resolve here
│ ├── main.js # import { x } from './util.js';
│ └── util.js # export function x() { … }
└── assets/ # worklets, wasm, css, images
The Host serves the src/ tree (alongside assets/), so relative imports between your modules
resolve naturally — no hardcoded /api/plugins/<id>/… URLs. Modules share state through ES exports,
not window. Resolve non-JS assets against the module URL rather than a hardcoded base:
// src/audio.js — loads a worklet that lives in ../assets/
const url = new URL('../assets/worklet.js', import.meta.url).href;
await audioCtx.audioWorklet.addModule(url);
Declare minHost (the minimum core version you need): an older Host ignores scriptType and
loads your entry as a classic script, where top-level import throws — minHost lets the Host
surface that instead of silently mounting a broken screen. Re-hydration idempotence (rule 12) still
applies: guard your global side effects, since the Host may re-mount your screen.
28. On an older Host: bundle to one classic screen.js, or split via window¶
Without scriptType: "module" — or on a Host too old to honour it — screen.js runs as a classic
script: no top-level import / export, no import.meta. Two options:
- Bundle. If you have build tooling, author many source files and bundle them into the one
screen.jsyou ship. The Host loads exactly one script, so a bundle sidesteps every runtime-split concern below. (feedBack ships no bundler and serves plugin JS verbatim — this is your own build step, the simplest path to a non-monolithic classic plugin.) - Split at runtime. Put split-out
.js(and any.css, workers,.wasm) underassets/, which the Host serves path-traversal-guarded with the correct MIME type. Reference them by an absolute URL (a relativeimport('./part.js')from a classic script resolves against the app root, not your script, and silently 404s) — but derive that URL from your own script's served URL rather than hardcoding a route: asset URLs are Host-assigned (spec §6.7), so build the base once fromdocument.currentScriptat top level. Classic split files share state throughwindow, namespaced under youridwith bracket notation (anidmay contain-):(window.__feedBackPlugins ||= {})['my-plugin'], neverwindow.my-plugin.
// Derive the asset base from this script's own served URL — don't hardcode the route
// (asset URLs are Host-assigned, spec §6.7). Capture it at top level, while
// document.currentScript is still your screen.js.
const ASSET_BASE = new URL('assets/', document.currentScript.src).href;
import(ASSET_BASE + 'lib/util.js').then(util => { /* ES module served from assets/ */ });
loadScriptOnce(ASSET_BASE + 'lib/legacy.js'); // classic, window-attaching helper (rule 29)
29. Load each classic split file exactly once¶
When you split a classic entry at runtime (rule 28), the Host may re-run screen.js mid-session
(rule 12) and your screen may re-mount — so runtime loading must be idempotent. (A module entry
needs none of this: the browser evaluates each statically-imported module once and dedupes it for
you.) De-dupe with a cache kept on window (a module-local Set is wiped when screen.js
re-runs), cache the in-flight promise so concurrent callers share one load, and drop the entry
on failure so a transient error can be retried:
const _loading = (window.__myPluginScripts ||= new Map()); // survives screen.js re-run
function loadScriptOnce(src) {
let p = _loading.get(src);
if (p) return p; // resolved or in-flight — reuse
p = new Promise((res, rej) => {
const s = document.createElement('script'); // classic; attaches to window
s.src = src; s.onload = res; s.onerror = rej;
document.body.appendChild(s);
}).catch(err => { _loading.delete(src); throw err; }); // allow retry after a failure
_loading.set(src, p);
return p;
}
Integrating with the app¶
Beyond mounting a screen, most plugins need to react to and drive the app — react to the song
that's playing, navigate, control the transport. The Host exposes this through the window.feedBack
object; reach for it rather than the app's own DOM. (As with the rest of the client runtime surface,
these names are the current Host contract, versioned by the Host — feature-detect before you rely on
one.)
30. Subscribe to app state through the event bus¶
window.feedBack is an event bus: on(event, fn), off(event, fn), and emit(event, detail). The
Host emits lifecycle events over it and you subscribe to react; the payload rides on event.detail.
The commonly-used events:
| Event | event.detail |
Fires when |
|---|---|---|
screen:changed |
{ id } |
The active screen changes (your screen becoming visible/hidden). |
song:loading / song:ready |
song info | A song starts loading / is ready to play. |
song:play / song:resume / song:pause / song:stop / song:ended |
— | Transport state changes. |
song:seek / song:position-changed |
{ time, duration } |
The playhead moves. |
song:arrangement-changed |
arrangement | The user switches arrangement. |
library:changed |
{ reason } |
The song library is rescanned/updated. |
viz:renderer:ready / viz:reverted |
{ reason } on revert |
A visualization renderer starts / auto-reverts. |
highway:canvas-replaced / highway:visibility |
{ … } |
The highway canvas is swapped / shown or hidden. |
Treat any event you don't recognise as optional (the set grows over time), keep handlers cheap (some fire during playback — see rule 9), and unsubscribe when your screen is hidden or torn down (rule 13) so a background plugin isn't doing work on every transport tick.
31. Drive the app through the feedBack API, not its DOM¶
To navigate, play, or control playback, call the Host API — never click or mutate the app's own
controls (document.querySelector('#btn-loop-…') and friends are private and move between UI
versions). The surface comes in two forms; feature-detect whichever one you call before relying
on it:
- On the
window.feedBackobject:feedBack.navigate(screenId, params)andfeedBack.getNavParams(),feedBack.seek(seconds, reason),feedBack.setLoop(a, b)/feedBack.clearLoop()/feedBack.getLoop(), and the live read-only statefeedBack.currentSong/feedBack.isPlaying. - Legacy top-level globals (supported but being migrated behind
feedBack, per rule 32's caution about the surface):window.showScreen(id),window.playSong(...),window.setReturnScreen(id), and thewindow.feedBack.playQueuequeue API. Prefer thefeedBack-namespaced call where one exists.
Going through the API keeps you working when the app's markup changes and avoids fighting the Host for control of the transport.
32. Wrap Host functions carefully — call through, stay idempotent, clean up¶
A common pattern is wrapping a Host global like playSong or showScreen to run your own logic
around it. Do it defensively:
- Always call — and
await— the original. Capture it, invoke it, return its result. Swallowing it breaks playback/navigation for the whole app and every other plugin in the wrapper chain. - Install the wrapper once. Store it behind a stable singleton (rule 12) so re-hydration doesn't stack wrapper-on-wrapper.
- Undo what a transition invalidates. If you wrap
showScreen, tear down your player-screen hooks when the user navigates away. - Don't assume load order. Plugins load alphabetically, so a Host global or another plugin's API
may not exist yet when your
scriptruns — check for it at the moment you use it (or on the relevant event), not at load time.
33. Support both player UIs¶
feedBack has two player chromes (v2 and v3). A plugin that injects controls into the player MUST
work in both: detect the active one (the Host exposes a uiVersion and a v3 mount point such as
ui.playerControlSlot()), mount into the Host-provided slot rather than a hard-coded container, and
verify your plugin in both UIs before shipping. Don't rely on any backward-compatibility shim —
it's a migration aid, not a contract.
Server-side robustness¶
Your routes module runs inside the Host's server process, sharing its event loop and startup
sequence with every other plugin. A slow or misbehaving plugin doesn't just hurt itself — it can
stall the server or delay every plugin that loads after it. These rules keep a backend plugin a good
tenant.
34. Declare Python dependencies in requirements.txt — and keep them light¶
A plugin's Python dependencies go in a requirements.txt file in the plugin directory (there is
no manifest field for this). On first load the Host pip installs them into a persistent location
and adds it to sys.path; the install is keyed by a hash of the file, so unchanged requirements
don't reinstall on later boots.
Two consequences shape good practice:
- Installs are sequential and can be slow, and they delay later plugins. While your deps install (potentially minutes) your plugin shows as "installing…", and plugins after you in load order wait their turn. Keep the dependency set small, prefer wheels, and pin versions for reproducibility. Don't pull a huge library for a small need.
- A failed install is non-fatal — the Host still tries to load your routes. So
importa heavy or optional dependency defensively (guard it and degrade if it's missing) rather than assuming it installed; if a genuinely required dep fails, your routes import will fail and the Host shows your plugin as "failed" rather than crashing.
35. Don't block the event loop; keep setup() fast¶
The Host calls your setup(app, context) on the server's event-loop thread, and it is killed if
it takes too long (on the order of a minute). Do only wiring in setup() — register routes, read a
small config — and defer any heavy work (scanning, model loading, large I/O) to a background task or
the first request that needs it.
The same discipline applies to your handlers. An async def handler that performs blocking work
(synchronous file, network, or CPU-bound work) freezes the whole server for every request while it
runs. Either use non-blocking I/O in an async def, or write the handler as a plain def — the
Host runs synchronous handlers in a threadpool where blocking is safe.
36. Split routes.py with load_sibling, not bare imports¶
The Host puts each plugin's directory on sys.path, and Python caches modules by bare name in
sys.modules — so if two plugins each ship a top-level util.py, whichever loads first wins and the
other silently gets the wrong module. To split your server code across files, load your own modules
through context["load_sibling"]("name"), which imports them under a per-plugin namespace
(plugin_<id>.<name>) so they can't collide, and lets your siblings use relative imports
(from .shared import x). Don't mix a bare import util and load_sibling("util") for the same
file — that executes it twice and splits its module-level state.
37. Log through context["log"], never print()¶
Use the logger the Host hands you in context["log"]. It carries the Host's correlation context and
lands in the rotated log stream under your plugin's namespace; print() bypasses both and is easy to
lose. (And, per rule 7, mount every route under /api/plugins/<id>/… — route paths aren't namespaced
for you, and a collision with the Host or another plugin is silent and, per rule 6, permanent.)
Styling¶
The app's own compiled stylesheet only contains the utility classes the bundled code happens to use. A plugin installed at runtime (from the plugin manager, a shared folder, the community) cannot rely on it — any class the app doesn't already use simply won't exist, and the plugin renders unstyled. So a plugin owns its own styling.
38. Ship your own compiled stylesheet via styles¶
Declare a styles entry pointing to a compiled CSS file (under assets/, per rule 28) and put
every class your screen needs in it. Don't assume a utility class exists just because the app uses a
similar one — especially arbitrary-value utilities like w-[37px] or bg-slate-800/50, which are
generated on demand and are almost never in the app's sheet. If you author with a utility framework,
run its build to produce your own sheet; ship the compiled output, not a config.
39. Build it to coexist — no global resets, no CDN, cache-busted¶
- Turn off the global reset. Build your stylesheet with the framework's base/preflight reset
disabled (e.g. Tailwind's
corePlugins.preflight = false). A plugin sheet that ships a full CSS reset re-styles the entire app, not just your screen. - Keep it scoped. Scope selectors under your screen's root (rule 7) so your rules don't leak outward — the flip side of the reset rule.
- Never load a runtime CSS engine or CDN. Don't pull the Tailwind Play CDN or any in-browser CSS-in-JS/JIT: it's slow, it's unavailable offline (feedBack runs local-first), and it recompiles on the main thread. Compile ahead of time and ship the result.
- Bump your
versionwhen the stylesheet changes. The Host cache-busts your assets by plugin version, so a stale sheet keeps serving until you bump (rule 4).
Diagnostics¶
When something goes wrong, a user can export a diagnostics bundle and send it to whoever's helping. A plugin can add its own state to that bundle — which is the difference between a bug report you can act on and one you can only guess at. Contributing diagnostics is cheap and worth it for any non-trivial plugin.
40. Make your plugin debuggable — contribute diagnostics¶
- Client state. Call
window.feedBack.diagnostics.contribute("<id>", payload)to push whatever would help you debug — the active mode, the last input, internal counters, the last error. It's idempotent (each call overwrites the previous), so just re-contribute a fresh snapshot whenever your state changes; whatever you contributed last before the export is what lands in the bundle under your plugin. - Server state. In your manifest's
diagnostics, listserver_files(files under yourconfig_dirto include verbatim) and/or acallable("module:function") that returns a snapshot (adict/list,bytes, orstr). The Host resolves the callable lazily at export time and catches any exception into the bundle's notes, so a broken diagnostics function never crashes the export — but return something small and useful, and embed aschema/version field so a reader can tell what they're looking at.
41. Never put secrets in a diagnostics bundle¶
A bundle gets shared with maintainers and is often attached to a public issue. Treat everything you contribute as public: no tokens, API keys, or credentials; no absolute filesystem paths or usernames; no raw audio or other user content. Redact aggressively and keep each contribution small (on the order of tens of KB, not megabytes). This is the security boundary (rule 53) applied to diagnostics — the one place it's easy to leak by accident.
Extending the app's shared surfaces¶
Rule 10 says don't reach into the app's own UI with the DOM — but plugins legitimately need to add a button to a song card, a nav entry, a keyboard shortcut, a mixer fader. The Host provides registration APIs for exactly this: you hand the Host a declaration, and it renders and manages your contribution so it survives the app's own re-renders. These are the sanctioned way to extend shared surfaces. (All are the current Host contract — feature-detect before relying on one.)
42. Add card and nav entries through their registries, not the DOM¶
- Song/library card actions. Register with
window.feedBack.libraryCardActions.register(spec), which returns anunregister(). The spec is{ id, pluginId, label, icon?, placement?('menu'|'inline'|'overlay'), order?, destructive?, applies?(song), enabled?(song), run(song, ctx) }. The library callsapplies/enabledper card on every render, so keep them O(1) and allocation-free (rule 10). Duplicateids are rejected. - A navigation entry is declarative: a
navobject ({ label, screen }) in yourplugin.jsonmakes the Host add the entry that activates your screen. Prefer this to injecting a nav link yourself.
43. Register keyboard shortcuts with a scope, and clean them up¶
Use window.registerShortcut({ key, handler, description, scope, condition? }) rather than adding
your own keydown listener (which would fire app-wide and fight other handlers). scope limits when
the shortcut is live — global, player, library, settings, or plugin-<id> for your own
screen — so pick the narrowest that fits. Keep the returned handle (or re-use the same scope) and
unregister when your screen tears down, so a stale shortcut doesn't linger.
44. Register audio faders through the mixer¶
If your plugin exposes an audio level the user should control, register it with the mixer —
window.feedBack.audio.registerFader(spec) ({ id, label, unit?, min, max, step, defaultValue,
getValue, setValue }) — instead of building your own slider UI. The Host renders it in the shared
mixer and calls your getValue/setValue. The mixer surface may not exist at load, so gate
registration on the feedBack:audio:ready event, and own your value's persistence yourself (the
mixer reflects state, it doesn't store it).
Onboarding tours¶
Any plugin can ship a guided tour — a sequence of highlighted steps that walks a user through its screen. The Host has a built-in tour engine; you supply the tour, it runs it. For most plugins this is pure data and needs no JavaScript.
45. Ship a tour as data (tour.json)¶
Declare a tour key in your plugin.json pointing at a tour.json, which the Host serves and runs.
The file is a small, declarative list of steps:
{
"version": 1,
"tour": [
{ "id": "welcome", "title": "Welcome", "content": "What this screen does.",
"shape": "bubble", "position": "auto" },
{ "id": "search", "selector": "#my-plugin-search", "title": "Search",
"content": "Filter as you type.", "shape": "spotlight", "position": "bottom" }
]
}
Each step is either a centered bubble (no target) or a spotlight that highlights the
element named by selector. Practical rules:
- Target elements you own. Point
selectorat stable ids inside your screen — not the app shell's markup, which moves between versions (this is rule 10 again). - Guard async targets with
waitFor. If a step's element renders after a fetch, give the step awaitForselector so the engine waits for it instead of skipping the step. - Keep it short and re-runnable. A tour is an intro, not a manual; and a user may replay it, so steps must not assume first-run-only state.
46. Only reach for the client tour API when the steps are dynamic¶
If your steps depend on runtime state (what the user has, which mode is active), register a tour on
the client instead of shipping static JSON. The first argument is your plugin id — the tour is
keyed by it, so it's namespaced per plugin (rule 2) and can't collide with another plugin's tour:
window.feedBackTour.register("my-plugin", { name, buildSteps, onStart, onComplete, autoPrompt }),
where buildSteps computes the step list when the tour starts. Use autoPrompt to offer the tour on first visit — offer once and remember the
answer; don't re-nag on every visit. Static tour.json is still the right default; only take on the
client API when data alone can't express the tour.
Library providers¶
A plugin can add song sources to the library — a folder scanner, a remote catalog, a generated set — by registering a library provider on the server. The app then lists and plays songs from your source alongside the built-in local library.
47. Add song sources as a library provider¶
Register in your setup() via context["register_library_provider"](provider). Keep your
provider's id so you can later call context["unregister_library_provider"](provider_id) — there's
no formal per-plugin teardown callback, so unregister explicitly if you re-register (e.g. on a
config change) or before replacing a provider, to avoid leaving a stale source registered. Two more
things to know:
- Attribution is enforced, not declared. The Host stamps every provider with your plugin as its
owner — you can't register a provider attributed to another plugin, and the built-in
localprovider can't be removed. - Declare the
librarycapability in your manifest too, so the capability inspector and diagnostics can show your source before its route code runs, and implement the provider's read/query methods (listing, artists, stats, per-song data) per the Host's library-provider contract. Return results promptly and page large libraries — the provider is queried on the app's library screen.
Highway overlays and note-state providers¶
The Visualizations section covers replacing the note-highway renderer. Two other surfaces let a plugin participate in the highway without replacing it: overlays draw a layer on top of whatever renderer is active, and a note-state provider feeds per-note judgment into the active renderer. Both are the current Host contract.
48. Decorate the highway with an overlay, not a renderer¶
An overlay is a layer on top of whichever renderer the user has picked — it decorates rather than
replaces, so several can stack (a fretboard, chord labels, practice feedback) without fighting over
the canvas. An overlay does not set "type": "visualization" and does not appear in the viz
picker (it's typically a navbar toggle). Building one:
- Own your own
requestAnimationFrameloop and canvas. Don't piggyback on the highway's draw hooks or rendering context — they fire for the built-in 2D renderer and for custom renderers that opt in, but not universally. - Re-read state every frame. Your output must track whatever the active renderer is drawing; don't cache note positions across frames.
- Respect the lefty and invert toggles — if you depict strings or frets, mirror with the same transforms the active renderer uses.
- Gate 2D-geometry helpers on the default renderer. If you position with the built-in highway's
geometry helpers (
highway.project/highway.fretX), guard onhighway.isDefaultRenderer()— those coordinates describe the built-in 2D highway and won't match a custom 3D/piano scene, so skip drawing when it returns false. Renderer-agnostic overlays (that read note/chord data via the highway's data accessors — e.g.highway.getNotes()/highway.getChordTemplates()— and do their own layout) don't need the guard. - Track highway visibility if you mount your own DOM. Listen for the
highway:visibility({ visible, canvas }) event and hide/show your overlay canvas to match — while the highway is hidden the Host skips its draw, but your sibling DOM won't hide itself. Cancel your rAF and hide your canvas when the overlay is toggled off.
49. Light up notes through the note-state provider¶
If your plugin judges playing (a scorer/note detector) and wants the highway to reflect it, don't
float your own ring near each note — publish per-note judgment through
highway.setNoteStateProvider(fn). The active renderer then lights the note gem itself on a
correct hit and keeps a sustain trail glowing while it's held, whichever renderer is in use. It's a
single global surface — one provider at a time — so clear it (setNoteStateProvider(null)) when
your plugin stops, and don't assume you're the only one that might want it.
Shipping & good citizenship¶
50. Fail soft, log clearly¶
- Use
context["log"](server) so your messages land in the Host log under your plugin's namespace. - A missing config file should mean "use defaults", not a crash. Read tolerantly (see the full example).
- If a surface can't initialise, degrade to a reduced-but-working state rather than taking the whole plugin down.
51. Degrade gracefully across Host versions¶
A plugin may run on a Host older than the one you developed against. Don't assume a context key
or a client runtime API exists without a documented Host version guaranteeing it. If an optional
surface isn't supported, your plugin's other surfaces must still work.
52. Only declare capabilities you actually implement¶
capabilities and standards wire you into cross-plugin pipelines (diagnostics, capability
inspection). Declaring a capability you don't service registers a phantom participant and breaks
the pipeline. If you don't participate, omit both keys entirely.
53. Mind the security boundary¶
Your routes run arbitrary Python in the server process and your script runs in the app's
renderer. Validate every route input, don't shell out on user data, and don't reach outside your
plugin directory. Users installing your plugin are trusting it like an app extension — earn it.
54. Ship a README and a changelog¶
A plugin folder should carry a short README.md (what it does, which Host version it targets) and
note changes per version. It costs little and saves every future reader — including you.
Checklist before you publish¶
- [ ] Folder name equals
idexactly (incl. case), matching^[a-z0-9][a-z0-9_-]*$. - [ ]
idis specific and doesn't collide with a bundled plugin (a bundledidwins; yours would be silently ignored) or a reserved one (capability_inspector,app_tour_*). - [ ] Global namespaces are prefixed by
id:localStoragekeys and anywindowglobals. (Routes and CSS are covered separately below.) - [ ]
plugin.jsonis valid againstschemas/plugin.schema.json(python tools/validate.py path/to/my-plugin). - [ ] Every manifest file reference resolves to a shipped file.
- [ ]
routes.pydoes no work at import time;setup()validates before registering. - [ ] Routes, CSS, and persisted files are namespaced under the
id. - [ ]
versionis set and follows semver.
Server-side robustness (if you ship routes):
- [ ] Python deps are in
requirements.txt, pinned and minimal; heavy/optional imports are guarded and degrade if missing. - [ ]
setup()only wires things (fast); no blocking work on the event loop — blocking handlers are plaindef, notasync def. - [ ] Server code is split via
context["load_sibling"], not bareimport, and routes are namespaced under/api/plugins/<id>/. - [ ] Logging goes through
context["log"], neverprint().
Styling (if your screen has custom CSS):
- [ ] A compiled stylesheet ships via
styles, containing every class the screen uses (no reliance on the app's sheet). - [ ] Built with the base/preflight reset off and selectors scoped to your screen; no Tailwind Play
CDN or runtime CSS engine;
versionbumped when the sheet changes.
Client-screen performance (if you ship a script):
- [ ] No
querySelector/layout reads/style writes insiderequestAnimationFrame,draw(), shortsetInterval, orMutationObservercallbacks — element refs resolved once on mount. - [ ] No
MutationObserveron a shared shell container withsubtree: true; shell UI contributed via registration APIs, not DOM injection. - [ ] No synchronous
localStorage, and no awaitedfetch/network I/O, on a per-frame / per-note / gameplay-event path. - [ ] Re-running
scriptis a no-op (idempotent hydration) if you declareplugin-runtime-idempotent.v1. - [ ] rAF loops and event subscriptions stop when the screen is hidden; state is per-instance.
Visualizations (if type is "visualization"):
- [ ]
window.feedBackViz_<id>is a factory function returning a fresh renderer per call, not a shared object; all renderer state is per-instance closure state. - [ ]
draw(bundle)is implemented; the bundle is treated as read-only and never cached;draw()allocates nothing. - [ ]
destroy()releases every context/DOM/subscription; DOM is resolved against the instance's own container, not a global selector. - [ ] Canvas size drift is self-detected in
draw()(don't rely onresize()being called). - [ ] Settings apply live via
applySetting(key, value)on the instance (no reload, no cross-setting leakage, no shared global keys for per-panel controls). - [ ] Persistence is left to the Host (no hand-rolled
localStorage); if self-managed, writes are quota-safe (in-memory fallback staged beforesetItem) and never on a per-frame path.
Highway overlays / note-state providers (if applicable):
- [ ] An overlay owns its own rAF + canvas, re-reads state each frame, gates
project/fretXonisDefaultRenderer(), trackshighway:visibility, and cleans up on toggle-off — and does not settype: "visualization". - [ ] A scorer lights notes via
highway.setNoteStateProvider(fn)(single global; cleared on stop), not a floating overlay.
Minigames (if you register into the minigames host):
- [ ] Registration binds late (pending queue +
feedBack-minigames-ready), never assumingwindow.feedBackMinigamesexists at eval. - [ ] A
minigamemanifest block is declared andspec.id=== the pluginid. - [ ]
stop()releases every rAF loop,AudioContext,getUserMediastream, timer, and listener the game opened;startis supersede-safe. - [ ] Standalone by default (
usesPlayeronly if the game drives the highway); results reported via the SDK (host owns scoring/persistence); assumes a single active session.
Split client code (if screen.js isn't a single bundle):
- [ ] Module mode (
scriptType: "module"):screen.jsis a one-lineimport './src/main.js';, modules live undersrc/and share state viaimport/export, assets resolve viaimport.meta.url, andminHostis declared. - [ ] Classic mode (no
scriptType): extra JS lives underassets/(or aroutes.py-served dir), referenced by an absolute URL derived from the Host-served base (§6.7 — e.g. fromdocument.currentScript), not a hardcoded route, and split files share state via a bracket-keyedwindow["<id>"]namespace (noimport/exportor top-levelawait). - [ ] Split pieces load exactly once, so re-hydration doesn't load them twice (classic runtime
splits need an explicit
window-kept de-dupe cache; a module graph dedupes itself).
Integrating with the app:
- [ ] React to app state via the
window.feedBackevent bus; handlers are cheap and unsubscribe when hidden. - [ ] Navigation/playback goes through the
feedBackAPI (navigate,playSong,setLoop, …), never the app's own DOM controls. - [ ] Any wrapped Host function calls +
awaits the original, installs once, and cleans up; no load-order assumptions. - [ ] Player-injecting plugins work in both
v2andv3(mount into the Host slot).
Diagnostics:
- [ ] Client state is contributed via
window.feedBack.diagnostics.contribute("<id>", payload)and/or server state viadiagnostics.server_files/diagnostics.callable. - [ ] Contributions carry no secrets/credentials/absolute paths/usernames/raw user content and are
small (tens of KB), with a
schema/version field.
Extending shared surfaces (if you contribute to app UI):
- [ ] Card actions via
libraryCardActions.register(O(1)applies/enabled), nav via the manifestnavkey — not DOM injection. - [ ] Keyboard shortcuts via
registerShortcutwith the narrowest scope, unregistered on teardown. - [ ] Audio levels via
feedBack.audio.registerFader(gated onfeedBack:audio:ready); you own persistence.
Tours & library providers (if applicable):
- [ ] A tour, if any, ships as declarative
tour.json(targeting your own stable selectors,waitForfor async targets); the client tour API is used only for dynamic steps and offers once. - [ ] A library provider registers via
context["register_library_provider"], unregisters on teardown, declares thelibrarycapability, and pages large sources.
Capabilities & shipping:
- [ ] Capabilities/standards declared only if actually implemented; cross-plugin calls go through
claim/dispatch/release, not foreign globals. - [ ] A
README.mdstates purpose and target Host version.