Getting Started

This guide walks you through creating, installing, and debugging a Signals & Sorcery plugin.

Quick Start

Clone the Plugin Templateopen in new window 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_VERSION and LLM_MODEL (model roles). The valid track roles are fetched at runtime via host.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):

ComponentDescription
TrackRowFull-featured track row with prompt input, generate/shuffle/copy buttons, mute/solo, volume/pan, FX drawer, instrument drawer, and progress overlay
VolumeSliderCompact horizontal volume slider (0-1) with dB tooltip
PanSliderCompact horizontal pan slider (-1 to +1) with double-click to center
SorceryProgressBarAnimated progress bar with time-based pacing for long operations
TrackDrawerPer-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
PanelMasterStripThe 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

FieldTypeDescription
idstringUnique ID using npm-style scoping: @scope/name
displayNamestringHuman-readable name shown in the accordion header
versionstringSemver version string (e.g., 1.0.0)
descriptionstringShort description for the settings panel
generatorTypestringOne of: midi, audio, sample, hybrid
mainstringBuilt entry file relative to plugin root (e.g., dist/index.js)

Optional Fields

FieldTypeDescription
iconstring24x24 icon: data URL or relative path from plugin directory
authorstringPlugin author name
licensestringLicense identifier
minHostVersionstringMinimum plugin SDK contract version the host must provide (e.g., 3.0.0). Compared with the host's SDK version, not the app version
capabilitiesobjectRequired capabilities (see below)
settingsobjectSetting definitions keyed by name (type, label, default, and so on), the same shape as getSettingsSchema().properties
rendererstringPath 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
repositorystringSource repository URL
supportedTimeSignaturesstring[] | '*'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
builtInbooleanReserved for built-in plugins

Generator Types

TypeDescriptionUse Case
midiCreates MIDI clips on tracksSynth patterns, drum sequences, arpeggiators
audioPlaces audio files on tracksGenerative audio, sound design
sampleManages sample library tracksSample browsers, beat slicers
hybridCombines MIDI and audioMulti-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
  }
}
CapabilityDefaultDescription
requiresLLMfalsePlugin needs access to generateWithLLM() and generateWithLLMTools()
requiresSurgeXTfalsePlugin needs the Surge XT synthesizer (gates loadSynthPlugin(), setSynthParameters() and applySurgeFxpPreset())
requiresNetworkfalsePlugin makes HTTP requests
network.allowedHosts[]Exact hostnames the plugin can reach via httpRequest() and downloadFile()
fileDialogfalsePlugin can show native file open/save dialogs
audioCapturefalsePlugin 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

  1. Discovery: Host scans plugin directories for plugin.json manifests
  2. Registration: Plugin is registered with its manifest metadata
  3. Version check: Host compares minHostVersion with its plugin SDK version; a newer requirement marks the plugin incompatible
  4. Activation: activate(host) is called with the scoped PluginHost instance
  5. Running: Plugin renders UI, responds to events, creates tracks/MIDI
  6. 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

PropTypeDescription
hostPluginHostThe scoped API instance for this plugin
activeSceneIdstring | nullCurrently active scene ID
isAuthenticatedbooleanWhether the user is logged in (for LLM access)
isConnectedbooleanWhether engine and gateway are connected
deckId'left' | 'right'Which workstation deck column this renders in
onHeaderContent(content: ReactNode | null) => voidSet/clear custom buttons in the accordion header
onLoading(loading: boolean) => voidShow/hide a loading spinner in the accordion header
sceneContextPluginSceneContext | nullScene-level context: contract state, chords, BPM, bars (see below)
onSelectScene(() => void) | nullCallback to open the scene selector. Null if not applicable
onOpenContract(() => void) | nullCallback to open the contract/chords section
onExpandSelf(() => void) | nullCallback to expand this plugin's own accordion section
isExpandedbooleanWhether 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.

FieldTypeDescription
hasContractbooleanWhether a contract has been generated (genre/prompt exists AND chords exist)
contractPromptstring | nullOriginal user prompt text (e.g., "dark psytrance")
genrestring | nullExtracted genre
key{ tonic: string; mode: string } | nullMusical key, or null if no chord progression
chordsstring[]Chord symbols (e.g., ["Cm", "Fm", "G"]). Empty if no chords
bpmnumberBPM from project tempo
barsnumberScene length in bars
hasTracksbooleanWhether any synth tracks exist in this scene
isBulkGeneratingbooleanWhether bulk generation is currently in progress
timeSignaturestringOptional. The scene's meter as "N/D" (e.g. '3/4'); treat absent as '4/4'
chordTimingPluginChordTiming[]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.

FieldTypeDescription
idstringUnique placeholder identifier
planIndexnumberPosition in the generation plan
rolestringMusical role (e.g., 'bass', 'lead')
descriptionstringHuman-readable description of the planned track
status'planned' | 'creating' | 'completed' | 'failed'Current generation status
errorstringError 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:

OSPlugins 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 CodeCauseFix
NOT_OWNEDTried to modify a track created by another pluginOnly modify tracks returned by createTrack() or getPluginTracks()
NO_ACTIVE_SCENECalled a track/MIDI method with no scene selectedCheck activeSceneId before operating
TRACK_LIMIT_EXCEEDEDCreated more than 16 tracks in one sceneDelete unused tracks or increase limit
CAPABILITY_DENIEDCalled a gated method without the manifest capabilityAdd the required capability to plugin.json
INCOMPATIBLEPlugin's minHostVersion is newer than the hostUpdate Signals & Sorcery or lower the version requirement
TIME_SIGNATURE_UNSUPPORTEDWrote content in a scene whose meter the manifest does not declareAdd the meter to supportedTimeSignatures once your plugin handles it

Tips

  • Check isConnected before engine operations; the engine may not be ready yet
  • Check activeSceneId before track/MIDI operations; it can be null
  • 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
    
Last Updated:
Contributors: shiehn