Cadence's composer is built around a small, typed Plugin SDK. Everything the
app ships — its instruments, its import/export formats, its AI providers — is a
plugin registered through the same host a third-party plugin uses. This guide
shows how to write one, using the bundled reference plugin
(helloPlugin.tsx)
as a complete, working example.
- Where it lives:
apps/web/src/composer/plugins/ - Public surface:
import { … } from '../plugins'(theindex.tsbarrel) - Runtime deps added: none. Manifest validation is hand-rolled in the same
defensive style as
model/persistence.ts.
A plugin is a plain module object: a manifest (identity) plus a set of typed
contributes (capabilities). You register it with a host, the host validates
the manifest, and when the plugin is activated its contributions go live. The
composer reads only the contributions of active plugins.
register(plugin) → activate(id) → contributions live → dispose(id)
│ │
validate manifest hidden, still registered
There are six extension points, all optional:
| Point | Field | What it adds |
|---|---|---|
| (a) Instrument | instruments |
a selectable voice the audio engine can play |
| (b) Effect | effects |
a node inserted into the master audio chain |
| (c) Format | formats |
an exporter and/or importer in the project toolbar |
| (d) AI provider | aiProviders |
a composition-assistant backend |
| (e) Command | commands |
an action (with an optional keybinding) |
| (e) Panel | panels |
a React surface in the composer sidebar |
The smallest useful plugin — one command:
import type { CadencePlugin } from '../plugins'
export function createGreeter(): CadencePlugin {
return {
manifest: { id: 'acme.greeter', name: 'Greeter', version: '1.0.0' },
contributes: {
commands: [
{
id: 'acme.greet',
title: 'Say hello',
keybinding: 'mod+shift+g',
run: (api) => api.notify('Hello from a plugin!'),
},
],
},
}
}Register it (inactive) with the default host and it appears in the composer's Extensions panel, ready to enable:
import { defaultPluginHost } from '../plugins'
defaultPluginHost.register(createGreeter())interface PluginManifest {
id: string // stable, unique, e.g. "acme.extra-instruments"
name: string // shown in the Extensions panel
version: string // semantic version, e.g. "1.0.0"
description?: string
author?: string
builtin?: boolean // reserved for the always-on core plugin
}register() runs validateManifest(), which throws a typed
PluginManifestError (mirroring MidiImportError / ProjectFileError) if the
id/name is missing or version isn't a semantic version. There is no schema
library — the check is a few lines of hand-rolled validation, so the SDK adds
zero runtime dependencies.
import { validateManifest, PluginManifestError } from '../plugins'
try {
validateManifest({ id: 'x', name: 'X', version: 'not-semver' })
} catch (err) {
err instanceof PluginManifestError // true
}The reference plugin exercises four of the six points (instrument, format, command, panel) in one small module. The two audio points share the same shape; all six are shown below.
An instrument is metadata plus a voice factory. The factory is called only when the engine builds a voice, so importing your module never touches the audio context.
import * as Tone from 'tone'
import { pitchToName } from '../../model/project'
import type { InstrumentVoice, InstrumentVoiceContext } from '../plugins'
function createMusicBoxVoice(context: InstrumentVoiceContext): InstrumentVoice {
const synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: 'sine' },
envelope: { attack: 0.005, release: 1.2 },
}).connect(context.output) // wire into the master chain
return {
trigger: (pitch, duration, time, velocity) =>
synth.triggerAttackRelease(pitchToName(pitch), duration, time, velocity),
dispose: () => synth.dispose(),
}
}
// contributes:
instruments: [
{
id: 'music-box',
name: 'Music Box',
kind: 'synth', // 'synth' | 'drum'
description: 'A soft sine voice with a gentle bell-like release.',
polyphonic: true,
createVoice: createMusicBoxVoice,
},
]Once the plugin is active, the instrument appears in every track's instrument dropdown and the engine plays it — no other wiring required.
An instrument may also declare an optional group label (e.g. Keys, Bass,
Pads). It's purely presentational: the picker buckets instruments that share a
group under an <optgroup>, in first-seen order, and instruments without a
group render as plain options. Omitting it is fully backward compatible.
The core plugin dogfoods this: alongside the original poly/FM synths and drum
kit it now contributes an expanded built-in library — electric piano, organ,
pads, basses, leads, mallets/plucks, and additional drum kits — each registered
through this exact same seam (see plugins/builtins/synthVoices.ts and
plugins/builtins/drumKits.ts).
An effect returns an EffectNode (input → effect → output). Effects with
enabledByDefault: true are inserted into the master chain when the engine is
built; the built-in softener ships off so the default signal path is
unchanged.
import * as Tone from 'tone'
import type { EffectNode } from '../plugins'
effects: [
{
id: 'softener',
name: 'High-Cut Softener',
description: 'A gentle low-pass filter on the master bus.',
enabledByDefault: false,
createNode: (): EffectNode => {
const filter = new Tone.Filter(8000, 'lowpass')
return { input: filter, output: filter, dispose: () => filter.dispose() }
},
},
]A format contributes an exporter, an importer, or both. export returns a
string or Uint8Array; import parses a project and should throw a typed error
on malformed input (like the built-in importers).
import type { Project } from '../../model/project'
formats: [
{
id: 'hello-text',
name: 'Text summary (.txt)',
extension: '.txt',
mimeType: 'text/plain',
export: (project: Project) => projectToText(project), // string | Uint8Array
// import: (data, options?) => Project // optional
},
]Exporters show up in the toolbar's Export as menu and importers in the file picker automatically.
Providers wrap the existing CompositionAssistant seam. create is lazy so heavy
models aren't loaded until the provider is selected.
import type { CompositionAssistant } from '../../ai/types'
aiProviders: [
{
id: 'acme.markov',
name: 'Markov toy model',
create: (): CompositionAssistant => new MarkovAssistant(),
},
]The assistant resolves its active provider from the host + preferences (see
resolveAssistant.ts); the
e2e mock provider swaps in the same way.
A command receives a minimal CommandApi — it never reaches into React or the
reducer directly:
export function insertCMajor(api: CommandApi): void {
const trackId = api.getSelectedTrackId()
if (!trackId) return api.notify('Select a track first.')
api.insertNotes(trackId, [60, 64, 67].map((pitch) => ({
pitch, start: 0, duration: 1, velocity: 0.8,
})))
api.notify('Inserted a C-major chord.')
}
commands: [
{ id: 'example.insert-cmajor', title: 'Insert a C-major chord',
keybinding: 'mod+shift+h', run: insertCMajor },
]A panel renders into the composer sidebar and can run commands:
panels: [
{
id: 'example.about',
title: 'Example plugin',
render: (context) => (
<div className="example-plugin-panel">
<p className="plugin-desc">This example adds an instrument and a command.</p>
<button type="button" className="btn btn-sm"
onClick={() => context.runCommand('example.insert-cmajor')}>
Insert a C-major chord
</button>
</div>
),
},
]Keybindings use mod+key grammar, where mod is Ctrl on Windows/Linux
and ⌘ on macOS (e.g. mod+shift+h). The global dispatcher ignores
keystrokes while an input/textarea/select is focused, and users can rebind any
command from the Extensions panel.
- Bundled plugins are registered with the module-singleton
defaultPluginHost. The core plugin (the built-ins) is registered active; optional plugins like the reference plugin are registered inactive so the user opts in. - Enabling a plugin is a user preference.
usePlugins()reconciles the host with the saved preferences on mount: a non-core plugin is activated iffenabledPlugins[id]is true, and disabled otherwise. Toggling the checkbox in the Extensions panel flips that preference and activates/disposes the plugin live. - Preferences (enabled plugins, per-command keybinding overrides, panel
visibility, selected AI provider) persist through the same versioned
localStorageseam as projects — seepreferences.ts. Parsing is defensive and never throws: an unreadable blob coerces to defaults, and theschemaVersionlets the shape migrate. It is client-side only (no backend).
import { createPreferencesStore } from '../plugins'
const store = createPreferencesStore()
store.update((p) => ({
...p,
enabledPlugins: { ...p.enabledPlugins, 'acme.greeter': true },
}))defaultPluginHost (or a fresh createPluginHost()) exposes:
| Method | Purpose |
|---|---|
register(plugin, { override? }) |
validate + register (inactive); duplicate id throws PluginRegistrationError unless override |
use(plugin, opts?) |
register and activate in one step |
activate(id) / dispose(id) |
flip contributions on/off (dispose keeps it registered) |
unregister(id) |
remove entirely |
instruments() / effects() / formats() / aiProviders() / commands() / panels() |
active-only aggregated views |
subscribe(fn) |
observe any lifecycle change (drives the React glue) |
When two active plugins contribute the same contribution id, the most-recently-registered wins — so a plugin can override a built-in.
Plugins are plain objects, so they unit-test without a browser. The reference
plugin's tests
(helloPlugin.test.tsx)
are a good template:
import { createPluginHost } from '../plugins'
const host = createPluginHost()
host.use(createGreeter())
expect(host.commands().map((c) => c.id)).toContain('acme.greet')- Validate your manifest (valid + each malformed case →
PluginManifestError). - Assert your contribution shows up in the matching host view once active and
disappears after
dispose. - For instruments/effects, mock
tone(importing it is safe under jsdom; only constructing nodes needs a real audio context). - Add a Playwright smoke that enables the plugin and uses its contribution; keep
any new UI axe-clean. See
plugins.spec.tsandtesting.md.
This is deliberately an in-process, typed module plugin model — the MVP that later efforts (collaboration, billing, stem separation) register their capabilities through. The following are intentionally not built yet; the current surface is the seam they'll grow from:
- No remote loading / marketplace. Plugins are imported modules compiled into the app. There is no dynamic download or discovery service.
- No untrusted-plugin sandbox. Plugins run with full app privileges. Real isolation (a worker/iframe boundary, a capability-scoped host API, permission prompts) is required before loading third-party code and is the biggest follow-up.
- Binary/async formats stay dedicated controls.
exportis synchronous and returnsstring | Uint8Array; MIDI (binary import) and WAV (async offline render) remain first-class toolbar controls rather than generic contributions. - Runtime effect toggling. Effects are resolved when the engine is built; per-session enable/disable of an already-built chain is a follow-up.
- Plugin-instrument persistence. Saved projects store an instrument by id, and the persistence layer's coercion seam is registry-aware: any id the host currently knows (built-in or contributed by an active plugin) round-trips intact, and only genuinely unknown ids fall back to the default synth. A project referencing a plugin-provided instrument therefore still coerces to the default when that plugin isn't active; durable per-plugin instrument identity across load order is a follow-up.