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:

cd ~/.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 — plugin appears in the workstation

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

Installing the SDK

The Plugin SDK is published as an npm package with types, UI components, and hooks:

npm install @signalsandsorcery/plugin-sdk

This gives you:

  • TypeScript typesGeneratorPlugin, PluginHost, PluginUIProps, and all supporting types
  • UI ComponentsTrackRow, VolumeSlider, PanSlider, FxToggleBar, SorceryProgressBar, InstrumentDrawer
  • HooksuseSceneState (scene-keyed state management)
  • ConstantsVALID_INSTRUMENT_ROLES, FX_CATEGORIES, PLUGIN_SDK_VERSION
// 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, FxToggleBar } 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
FxToggleBarPer-track FX control panel with 6 categories, preset buttons, and dry/wet sliders
SorceryProgressBarAnimated progress bar with time-based pacing for long operations
InstrumentDrawerSearchable grid of available VST3/AU instrument plugins

useSceneState Hook

Maintains separate state per scene — when the user switches scenes, state is preserved and restored:

import { useSceneState } from '@signalsandsorcery/plugin-sdk';

// Inside your React component:
const [prompts, setPrompts, setPromptsForScene] = useSceneState(activeSceneId, {});
// 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": "index.js",
  "icon": "assets/icon.svg",
  "author": "Your Name",
  "license": "MIT",
  "minHostVersion": "1.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
mainstringEntry point file relative to plugin root

Optional Fields

FieldTypeDescription
iconstring24x24 icon — data URL or relative path from plugin directory
authorstringPlugin author name
licensestringLicense identifier
minHostVersionstringMinimum SDK version required (e.g., 1.0.0)
capabilitiesobjectRequired capabilities (see below)
settingsobjectJSON Schema for auto-rendered settings form
builtInbooleanReserved for built-in plugins

Generator Types

TypeDescriptionUse Case
midiCreates MIDI clips on tracksSynth patterns, drum sequences, arpeggiators
audioPlaces audio files on tracksAI audio generation, 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()
requiresSurgeXTfalsePlugin needs the Surge XT synthesizer
requiresNetworkfalsePlugin makes HTTP requests
network.allowedHosts[]Specific hosts the plugin can reach via httpRequest()
fileDialogfalsePlugin 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 '@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
  }
}

Lifecycle

  1. Discovery — Host scans plugin directories for plugin.json manifests
  2. Registration — Plugin is registered with its manifest metadata
  3. Version check — Host verifies minHostVersion compatibility
  4. Activationactivate(host) is called with the scoped PluginHost instance
  5. Running — Plugin renders UI, responds to events, creates tracks/MIDI
  6. Deactivationdeactivate() 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 '@signalsandsorcery/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

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

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

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:

~/.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 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

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