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

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 '@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

  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 '@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

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

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