Getting Started
This guide walks you through creating, installing, and debugging a Signals & Sorcery plugin.
Quick Start
Clone the Plugin Template to skip the boilerplate. It includes a working hello-world plugin with heavily commented examples of track creation, MIDI writing, and all common patterns:
# macOS: see Install a Plugin for Windows/Linux paths
cd ~/Library/Application\ Support/signals-and-sorcery/plugins/
git clone https://github.com/shiehn/sas-plugin-template.git @my-org/my-plugin
cd @my-org/my-plugin
npm install && npm run build
# Restart Signals & Sorcery; the plugin appears in the workstation
Easier shortcut: in-app, go to Settings → Plugins → Open Folder to reveal the plugins directory in Finder/Explorer without having to remember the path.
Just installing, not authoring?
If you only want to use an existing plugin, skip this page and read Install a Plugin instead.
Prerequisites
- Signals & Sorcery v4.2.0 or later (plugin SDK contract 3.x); install the SDK with
npm install @signalsandsorcery/plugin-sdk(currently v3.21.1) - Node.js 18+ (for building your plugin)
- TypeScript recommended but not required
Installing the SDK
The Plugin SDK is published as an npm package with types, UI components, and hooks:
npm install @signalsandsorcery/plugin-sdk
react and react-dom 18+ are peer dependencies. Keep React and the SDK out of your bundle by marking all three as external, as the template's tsup.config.ts does.
This gives you:
- TypeScript types:
GeneratorPlugin,PluginHost,PluginUIProps, and all supporting types - UI Components:
TrackRow,TrackDrawer,VolumeSlider,PanSlider,SorceryProgressBar,PanelMasterStrip - Hooks:
useSceneState(scene-keyed state management),usePanelBus(panel mix bus),useAnySolo - Constants:
PLUGIN_SDK_VERSIONandLLM_MODEL(model roles). The valid track roles are fetched at runtime viahost.getValidRoles(), not shipped as a static constant
Host methods added in recent SDK versions are typed as optional on PluginHost. Feature-detect them (typeof host.method === 'function') so your plugin still runs on older releases of the app.
// Import types for your plugin class
import type { GeneratorPlugin, PluginHost, PluginUIProps } from '@signalsandsorcery/plugin-sdk';
// Import UI components for your React panel
import { TrackRow, useSceneState, VolumeSlider } from '@signalsandsorcery/plugin-sdk';
SDK UI Components
These pre-built components match the host app's visual style (Tailwind CSS classes provided by the host):
| Component | Description |
|---|---|
TrackRow | Full-featured track row with prompt input, generate/shuffle/copy buttons, mute/solo, volume/pan, FX drawer, instrument drawer, and progress overlay |
VolumeSlider | Compact horizontal volume slider (0-1) with dB tooltip |
PanSlider | Compact horizontal pan slider (-1 to +1) with double-click to center |
SorceryProgressBar | Animated progress bar with time-based pacing for long operations |
TrackDrawer | Per-track drawer with tabs for FX, the instrument picker, sound history, import, MIDI editing and freeze. A tab appears when you pass its callbacks. InstrumentDrawer is kept as an alias |
PanelMasterStrip | The panel's mix bus strip (fader, mute/solo, meter, bus FX). Drive it with the usePanelBus hook |
useSceneState Hook
Maintains separate state per scene: when the user switches scenes, state is preserved and restored:
import { useSceneState } from '@signalsandsorcery/plugin-sdk';
// Hoist object/array initial values to module level so the setters stay stable
const EMPTY_PROMPTS: Record<string, string> = {};
// Inside your React component:
const [prompts, setPrompts, setPromptsForScene] = useSceneState(activeSceneId, EMPTY_PROMPTS);
// prompts = state for current scene
// setPrompts(value) = update current scene
// setPromptsForScene(sceneId, value) = update a specific scene (for async callbacks)
Plugin Directory Structure
A minimal plugin looks like this:
my-plugin/
├── plugin.json # Required: manifest
├── index.ts # Required: GeneratorPlugin entry point
└── components/
└── Panel.tsx # Optional: React UI component
A more complete plugin might include:
my-plugin/
├── plugin.json
├── index.ts
├── components/
│ ├── Panel.tsx
│ └── Controls.tsx
├── lib/
│ └── algorithms.ts
├── assets/
│ └── icon.svg
├── presets/
│ └── factory.json
└── package.json
The Manifest (plugin.json)
Every plugin requires a plugin.json manifest in its root directory:
{
"id": "@my-org/my-plugin",
"displayName": "My Plugin",
"version": "1.0.0",
"description": "A short description of what this plugin does",
"generatorType": "midi",
"main": "dist/index.js",
"icon": "assets/icon.svg",
"author": "Your Name",
"license": "MIT",
"minHostVersion": "3.0.0",
"capabilities": {}
}
Required Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique ID using npm-style scoping: @scope/name |
displayName | string | Human-readable name shown in the accordion header |
version | string | Semver version string (e.g., 1.0.0) |
description | string | Short description for the settings panel |
generatorType | string | One of: midi, audio, sample, hybrid |
main | string | Built entry file relative to plugin root (e.g., dist/index.js) |
Optional Fields
| Field | Type | Description |
|---|---|---|
icon | string | 24x24 icon: data URL or relative path from plugin directory |
author | string | Plugin author name |
license | string | License identifier |
minHostVersion | string | Minimum plugin SDK contract version the host must provide (e.g., 3.0.0). Compared with the host's SDK version, not the app version |
capabilities | object | Required capabilities (see below) |
settings | object | Setting definitions keyed by name (type, label, default, and so on), the same shape as getSettingsSchema().properties |
renderer | string | Path to a UMD bundle of your panel UI (e.g., dist/ui.bundle.js). The app window loads it to show an installed plugin's panel; the bundle registers its component on window.SASPlugin_<id> (the id with @ dropped, / as __, other symbols as _) as default or Panel |
repository | string | Source repository URL |
supportedTimeSignatures | string[] | '*' | Scene meters the plugin can write for: exact meters ('3/4'), denominator families ('*/8'), or '*' for any. Absent means ['4/4']; in other meters the host disables the panel and refuses content writes with TIME_SIGNATURE_UNSUPPORTED |
builtIn | boolean | Reserved for built-in plugins |
Generator Types
| Type | Description | Use Case |
|---|---|---|
midi | Creates MIDI clips on tracks | Synth patterns, drum sequences, arpeggiators |
audio | Places audio files on tracks | Generative audio, sound design |
sample | Manages sample library tracks | Sample browsers, beat slicers |
hybrid | Combines MIDI and audio | Multi-layered generators |
Capabilities
Capabilities declare what platform features your plugin needs. The host enforces these at runtime: calling a capability-gated method without the right manifest entry throws a CAPABILITY_DENIED error.
{
"capabilities": {
"requiresLLM": true,
"requiresSurgeXT": true,
"requiresNetwork": true,
"network": {
"allowedHosts": ["api.example.com", "cdn.example.com"]
},
"fileDialog": true
}
}
| Capability | Default | Description |
|---|---|---|
requiresLLM | false | Plugin needs access to generateWithLLM() and generateWithLLMTools() |
requiresSurgeXT | false | Plugin needs the Surge XT synthesizer (gates loadSynthPlugin(), setSynthParameters() and applySurgeFxpPreset()) |
requiresNetwork | false | Plugin makes HTTP requests |
network.allowedHosts | [] | Exact hostnames the plugin can reach via httpRequest() and downloadFile() |
fileDialog | false | Plugin can show native file open/save dialogs |
audioCapture | false | Plugin records from a microphone or line input (startTrackRecording() and related methods) |
externalApps | [] | Process names of desktop apps the plugin may drive via automateExternalApp() |
Implementing GeneratorPlugin
Your entry point module implements the GeneratorPlugin interface in a class and exports an instance of it as the default export (a named plugin export also works). The host checks that export for activate(), deactivate() and getUIComponent():
import type {
GeneratorPlugin,
PluginHost,
PluginUIProps,
PluginSettingsSchema,
MusicalContext,
} from '@signalsandsorcery/plugin-sdk';
import { MyPanel } from './components/Panel';
export class MyPlugin implements GeneratorPlugin {
// --- Required readonly properties ---
readonly id = '@my-org/my-plugin';
readonly displayName = 'My Plugin';
readonly version = '1.0.0';
readonly description = 'Does something useful';
readonly generatorType = 'midi' as const;
private host: PluginHost | null = null;
// --- Lifecycle ---
async activate(host: PluginHost): Promise<void> {
this.host = host;
// Initialize plugin state, load saved data, etc.
const savedState = await host.getProjectData<MyState>('state');
if (savedState) {
this.state = savedState;
}
}
async deactivate(): Promise<void> {
// Clean up: unsubscribe listeners, save state, release resources
// Must complete within 5 seconds or host force-kills
if (this.host) {
await this.host.setProjectData('state', this.state);
}
this.host = null;
}
// --- UI ---
getUIComponent() {
return MyPanel;
}
// --- Settings (optional) ---
getSettingsSchema(): PluginSettingsSchema | null {
return {
type: 'object',
properties: {
density: {
type: 'number',
label: 'Note Density',
description: 'How many notes per bar',
default: 4,
min: 1,
max: 32,
},
scale: {
type: 'select',
label: 'Scale',
options: [
{ label: 'Major', value: 'major' },
{ label: 'Minor', value: 'minor' },
{ label: 'Pentatonic', value: 'pentatonic' },
],
default: 'major',
},
},
};
}
// --- Optional callbacks ---
async onSceneChanged(sceneId: string | null): Promise<void> {
// Called when the active scene changes
// Use this to reload scene-specific state
}
onContextChanged(context: MusicalContext): void {
// Called when musical context changes (BPM, key, chords, etc.)
// Use this to update UI or recalculate patterns
}
}
export default new MyPlugin();
Lifecycle
- Discovery: Host scans plugin directories for
plugin.jsonmanifests - Registration: Plugin is registered with its manifest metadata
- Version check: Host compares
minHostVersionwith its plugin SDK version; a newer requirement marks the plugin incompatible - Activation:
activate(host)is called with the scopedPluginHostinstance - Running: Plugin renders UI, responds to events, creates tracks/MIDI
- Deactivation:
deactivate()is called (5-second timeout)
If activate() throws, the plugin is marked as failed and is not rendered.
Building the UI Component
Your React component receives PluginUIProps:
import type { PluginUIProps } from '@signalsandsorcery/plugin-sdk';
interface PanelState {
isGenerating: boolean;
trackCount: number;
}
export function MyPanel({ host, activeSceneId, isAuthenticated, isConnected, onLoading }: PluginUIProps) {
const [state, setState] = React.useState<PanelState>({
isGenerating: false,
trackCount: 0,
});
// Load existing tracks on scene change
React.useEffect(() => {
if (!activeSceneId) return;
host.getPluginTracks().then(tracks => {
setState(prev => ({ ...prev, trackCount: tracks.length }));
});
}, [activeSceneId]);
const handleGenerate = async () => {
if (!activeSceneId) {
host.showToast('warning', 'No Scene', 'Select a scene first');
return;
}
setState(prev => ({ ...prev, isGenerating: true }));
onLoading?.(true); // spinner in the accordion header
try {
const track = await host.createTrack({ name: 'My Track', role: 'lead' });
const context = await host.getMusicalContext();
// ... generate notes ...
await host.writeMidiClip(track.id, { /* ... */ });
host.showToast('success', 'Done', 'Pattern generated');
} catch (err) {
host.showToast('error', 'Failed', String(err));
} finally {
onLoading?.(false);
setState(prev => ({ ...prev, isGenerating: false }));
}
};
return (
<div>
<p>Tracks: {state.trackCount}</p>
<button onClick={handleGenerate} disabled={state.isGenerating || !isConnected}>
{state.isGenerating ? 'Generating...' : 'Generate'}
</button>
</div>
);
}
PluginUIProps
| Prop | Type | Description |
|---|---|---|
host | PluginHost | The scoped API instance for this plugin |
activeSceneId | string | null | Currently active scene ID |
isAuthenticated | boolean | Whether the user is logged in (for LLM access) |
isConnected | boolean | Whether engine and gateway are connected |
deckId | 'left' | 'right' | Which workstation deck column this renders in |
onHeaderContent | (content: ReactNode | null) => void | Set/clear custom buttons in the accordion header |
onLoading | (loading: boolean) => void | Show/hide a loading spinner in the accordion header |
sceneContext | PluginSceneContext | null | Scene-level context: contract state, chords, BPM, bars (see below) |
onSelectScene | (() => void) | null | Callback to open the scene selector. Null if not applicable |
onOpenContract | (() => void) | null | Callback to open the contract/chords section |
onExpandSelf | (() => void) | null | Callback to expand this plugin's own accordion section |
isExpanded | boolean | Whether this plugin's accordion section is open (the panel stays mounted while collapsed) |
All props except host, activeSceneId, isAuthenticated and isConnected are optional; guard callbacks with ?..
PluginSceneContext
Provides scene-level musical context to the UI without requiring an async call.
| Field | Type | Description |
|---|---|---|
hasContract | boolean | Whether a contract has been generated (genre/prompt exists AND chords exist) |
contractPrompt | string | null | Original user prompt text (e.g., "dark psytrance") |
genre | string | null | Extracted genre |
key | { tonic: string; mode: string } | null | Musical key, or null if no chord progression |
chords | string[] | Chord symbols (e.g., ["Cm", "Fm", "G"]). Empty if no chords |
bpm | number | BPM from project tempo |
bars | number | Scene length in bars |
hasTracks | boolean | Whether any synth tracks exist in this scene |
isBulkGenerating | boolean | Whether bulk generation is currently in progress |
timeSignature | string | Optional. The scene's meter as "N/D" (e.g. '3/4'); treat absent as '4/4' |
chordTiming | PluginChordTiming[] | Optional. Chord segments with quarter-note timing, for drawing harmony against time |
sceneType | 'scene' | 'transition' | Optional. 'transition' for a scene that bridges two other scenes |
BulkAddPlaceholderTrack
Represents a planned track during the progressive bulk-add UX.
| Field | Type | Description |
|---|---|---|
id | string | Unique placeholder identifier |
planIndex | number | Position in the generation plan |
role | string | Musical role (e.g., 'bass', 'lead') |
description | string | Human-readable description of the planned track |
status | 'planned' | 'creating' | 'completed' | 'failed' | Current generation status |
error | string | Error message (only present when status is 'failed') |
Installing a Plugin
Place your compiled plugin directory in the plugins folder; the path depends on your OS:
| OS | Plugins folder |
|---|---|
| macOS | ~/Library/Application Support/signals-and-sorcery/plugins/ |
| Windows | %APPDATA%\signals-and-sorcery\plugins\ |
| Linux | ~/.config/signals-and-sorcery/plugins/ |
The in-app Settings → Plugins → Open Folder button reveals this directory without you having to remember the path.
Your plugin lands like this (macOS example):
~/Library/Application Support/signals-and-sorcery/plugins/
└── @my-org/my-plugin/
├── plugin.json
├── dist/
│ └── index.js
└── ...
Scoped plugin IDs (@org/name) create a nested directory; unscoped IDs sit at the top level.
Restart Signals & Sorcery. The plugin appears under Settings → Plugins and, once enabled, in the workstation accordion. See Install a Plugin for the full user-facing flow including the enable/disable toggle + restart-required modal.
Settings Form
If your plugin returns a schema from getSettingsSchema(), the host auto-renders a settings form in the Plugin Manager panel. Settings are persisted globally via host.settings:
// Read a setting (with default)
const density = host.settings.get<number>('density', 4);
// Write a setting
host.settings.set('density', 8);
// React to setting changes
const unsub = host.settings.onChange((key, value) => {
console.log(`Setting ${key} changed to`, value);
});
Debugging
Common Errors
| Error Code | Cause | Fix |
|---|---|---|
NOT_OWNED | Tried to modify a track created by another plugin | Only modify tracks returned by createTrack() or getPluginTracks() |
NO_ACTIVE_SCENE | Called a track/MIDI method with no scene selected | Check activeSceneId before operating |
TRACK_LIMIT_EXCEEDED | Created more than 16 tracks in one scene | Delete unused tracks or increase limit |
CAPABILITY_DENIED | Called a gated method without the manifest capability | Add the required capability to plugin.json |
INCOMPATIBLE | Plugin's minHostVersion is newer than the host | Update Signals & Sorcery or lower the version requirement |
TIME_SIGNATURE_UNSUPPORTED | Wrote content in a scene whose meter the manifest does not declare | Add the meter to supportedTimeSignatures once your plugin handles it |
Tips
- Check
isConnectedbefore engine operations; the engine may not be ready yet - Check
activeSceneIdbefore track/MIDI operations; it can benull - Use
showToast()to surface errors to the user during development - Use
logMetric()to track performance of expensive operations - Use
startTimer()for easy duration measurement:const stop = host.startTimer('midi-generation'); // ... do work ... stop(); // automatically logs duration