Getting Started
This guide walks you through creating, installing, and debugging a Signals & Sorcery plugin.
Prerequisites
- Signals & Sorcery v2.24.0 or later (Plugin SDK v1.0.0)
- Node.js 18+ (for building your plugin)
- TypeScript recommended but not required
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": "index.js",
"icon": "assets/icon.svg",
"author": "Your Name",
"license": "MIT",
"minHostVersion": "1.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 | Entry point file relative to plugin root |
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 SDK version required (e.g., 1.0.0) |
capabilities | object | Required capabilities (see below) |
settings | object | JSON Schema for auto-rendered settings form |
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 | AI audio generation, 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() |
requiresSurgeXT | false | Plugin needs the Surge XT synthesizer |
requiresNetwork | false | Plugin makes HTTP requests |
network.allowedHosts | [] | Specific hosts the plugin can reach via httpRequest() |
fileDialog | false | Plugin can show native file open/save dialogs |
Implementing GeneratorPlugin
Your entry point module must export a class that implements the GeneratorPlugin interface:
import type {
GeneratorPlugin,
PluginHost,
PluginUIProps,
PluginSettingsSchema,
MusicalContext,
} from '@sas/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
}
}
Lifecycle
- Discovery — Host scans plugin directories for
plugin.jsonmanifests - Registration — Plugin is registered with its manifest metadata
- Version check — Host verifies
minHostVersioncompatibility - 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 its accordion section shows an error boundary.
Building the UI Component
Your React component receives PluginUIProps:
import type { PluginUIProps } from '@sas/plugin-sdk';
interface PanelState {
isGenerating: boolean;
trackCount: number;
}
export function MyPanel({ host, activeSceneId, isAuthenticated, isConnected }: 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 }));
try {
const track = await host.createTrack({ name: 'My Track', role: 'lead' });
host.setProgress(track.id, 50);
const context = await host.getMusicalContext();
// ... generate notes ...
await host.writeMidiClip(track.id, { /* ... */ });
host.setProgress(track.id, -1); // hide progress
host.showToast('success', 'Done', 'Pattern generated');
} catch (err) {
host.showToast('error', 'Failed', String(err));
} finally {
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 |
Installing a Plugin
Place your compiled plugin directory in the plugins folder:
~/.signals-and-sorcery/plugins/
└── my-plugin/
├── plugin.json
├── index.js
└── ...
Restart Signals & Sorcery. The plugin appears in the workstation accordion and can be enabled/disabled in the Plugin Manager settings panel.
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 |
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