Plugin SDK

Signals & Sorcery has an extensible plugin system that lets you build custom input generators for the Loop Workstation. Plugins can generate MIDI patterns, manage audio samples, create AI-generated audio textures, or combine all three.

Each plugin gets its own accordion section in the workstation UI and a scoped PluginHost API for interacting with tracks, MIDI, audio, and more. Plugins never access the audio engine directly — all interaction goes through the PluginHost, which enforces ownership scoping, capability gating, and track limits.

Start Here

The fastest way to build a plugin is to clone the template:

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 — your plugin appears in the workstation. Edit the source, rebuild, and iterate.

Plugin Template on GitHubopen in new window — Fully commented hello-world plugin with examples of track creation, MIDI writing, and all common patterns.

Guides

PageDescription
Getting StartedDirectory structure, manifest options, installation, and debugging
API ReferenceComplete PluginHost API with full type signatures, parameters, and code examples
TutorialBuild a Euclidean Rhythm Generator plugin from scratch

Resources

ResourceLink
Plugin Templategithub.com/shiehn/sas-plugin-templateopen in new window
Plugin SDK (npm)@signalsandsorcery/plugin-sdkopen in new window
SDK Sourcegithub.com/shiehn/sas-plugin-sdkopen in new window
Synth Plugin (reference)github.com/shiehn/sas-synth-pluginopen in new window
Sample Plugin (reference)github.com/shiehn/sas-sample-pluginopen in new window
Audio Plugin (reference)github.com/shiehn/sas-audio-pluginopen in new window

Plugin Lifecycle Hooks

Every plugin implements the GeneratorPlugin interface. The host calls these methods during the plugin lifecycle.

MethodSignatureDescription
activate(host: PluginHost) => Promise<void>Called when the plugin is activated. Receives the scoped PluginHost instance. Initialize state, load saved data, subscribe to events here.
deactivate() => Promise<void>Called when the plugin is deactivated (5-second timeout). Clean up listeners, save state, release resources.
getUIComponent() => ComponentType<PluginUIProps>Return the React component to render in the accordion section.
getSettingsSchema() => PluginSettingsSchema | nullReturn a JSON schema for auto-rendered settings, or null for no settings UI.
onSceneChanged(sceneId: string | null) => Promise<void>Optional. Called when the active scene changes. Reload scene-specific state here.
onContextChanged(context: MusicalContext) => voidOptional. Called when musical context changes (BPM, key, chords). Update UI or recalculate patterns.

PluginUIProps

Props passed to your plugin's React component by the host.

PropTypeDescription
hostPluginHostThe scoped API instance for this plugin
activeSceneIdstring | nullCurrently active scene ID
isAuthenticatedbooleanWhether the user is logged in (needed for LLM access)
isConnectedbooleanWhether engine and gateway are connected
deckId'left' | 'right'Which workstation deck column this renders in
onHeaderContent(content: ReactNode | null) => voidInject custom content (e.g., buttons) into the accordion header
onLoading(loading: boolean) => voidShow/hide a loading spinner in the accordion header
sceneContextPluginSceneContext | nullScene-level context: contract state, chords, BPM, bars
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

PluginHost API — Complete Method Reference

All methods below are available on the host object your plugin receives in activate() and via PluginUIProps.host. Methods marked with ownership require the track to be owned by the calling plugin.

Track Management

MethodSignatureDescription
createTrack(options: CreateTrackOptions) => Promise<PluginTrackHandle>Create a new track in the active scene. Options: name, role, loadSynth, synthName, metadata.
deleteTrack(trackId: string) => Promise<void>Delete an owned track. Ownership.
getPluginTracks() => Promise<PluginTrackHandle[]>Get all tracks this plugin owns in the active scene.
getTrackInfo(trackId: string) => Promise<PluginTrackInfo>Get detailed info (name, muted, volume, pan, plugins) for an owned track. Ownership.
adoptSceneTracks() => Promise<PluginTrackHandle[]>Adopt unowned tracks in the scene matching this plugin's generator type. Useful for re-activation.
setTrackMute(trackId: string, muted: boolean) => Promise<void>Mute or unmute a track. Ownership.
setTrackSolo(trackId: string, solo: boolean) => Promise<void>Solo or unsolo a track. Ownership.
setTrackVolume(trackId: string, volume: number) => Promise<void>Set track volume (0.0 silent – 1.0 full). Ownership.
setTrackPan(trackId: string, pan: number) => Promise<void>Set track pan (-1.0 left – 1.0 right). Ownership.
setTrackName(trackId: string, name: string) => Promise<void>Rename a track. Ownership.
shufflePreset(trackId: string) => Promise<ShufflePresetResult>Randomly change the Surge XT preset based on MIDI pitch analysis. Returns { presetName, presetCategory }. Ownership.
duplicateTrack(trackId: string) => Promise<PluginTrackHandle>Clone an owned track — copies MIDI data, role, and loads Surge XT on the new track. Ownership.

MIDI Operations

MethodSignatureDescription
writeMidiClip(trackId: string, clip: MidiClipData) => Promise<MidiWriteResult>Write MIDI notes to a track (replaces existing MIDI). Clip has startTime, endTime, tempo, notes. Ownership.
clearMidi(trackId: string) => Promise<void>Clear all MIDI from a track. Ownership.
postProcessMidi(notes: PluginMidiNote[], options: PostProcessOptions) => Promise<PluginMidiNote[]>Run the host's MIDI pipeline: quantize, swing, scale enforcement, register clamping, overlap removal, humanization.
auditionNote(trackId: string, pitch: number, velocity: number, durationMs: number) => Promise<void>Play a single note for preview. Fire-and-forget. Ownership.

Audio Operations

MethodSignatureDescription
writeAudioClip(trackId: string, filePath: string, position?: number) => Promise<void>Place an audio file (.wav, .aiff, .mp3, .flac, .ogg) on a track. Ownership.
generateAudioTexture(request: PluginAudioTextureRequest) => Promise<PluginAudioTextureResult>Invoke AI audio generation. Request has prompt, optional durationSeconds and bpm. Returns { filePath, durationSeconds }.

Plugin/Synth Operations

MethodSignatureDescription
loadSynthPlugin(trackId: string, pluginName: string) => Promise<number>Load a VST3/AU plugin onto a track. Returns plugin index. Ownership.
setPluginState(trackId: string, pluginIndex: number, stateBase64: string) => Promise<void>Set plugin state from base64-encoded preset data. Ownership.
getPluginState(trackId: string, pluginIndex: number) => Promise<string>Get current plugin state as base64. Ownership.
getTrackPlugins(trackId: string) => Promise<PluginSynthInfo[]>List all plugins loaded on a track. Returns { index, name, type, enabled }[]. Ownership.
removePlugin(trackId: string, pluginIndex: number) => Promise<void>Remove a plugin from a track. Ownership.
isPluginAvailable(pluginName: string) => Promise<boolean>Check if a VST3/AU plugin is installed on the system.

Instrument Plugin Selection

MethodSignatureDescription
getAvailableInstruments() => Promise<InstrumentDescriptor[]>Get available instrument plugins (VST3/AU synths) scanned by the engine.
getTrackInstrument(trackId: string) => Promise<InstrumentDescriptor | null>Get the instrument currently loaded on a track. Null = default (Surge XT). Ownership.
setTrackInstrument(trackId: string, pluginId: string) => Promise<void>Change the instrument plugin on a track. Preserves MIDI data. Ownership.

FX Operations

Per-track FX with 6 categories in signal chain order: eqcompressorchorusphaserdelayreverb.

MethodSignatureDescription
getTrackFxState(trackId: string) => Promise<PluginTrackFxDetailState>Get FX state for all categories (enabled, presetIndex, dryWet per category). Ownership.
toggleTrackFx(trackId: string, category: string, enabled: boolean) => Promise<void>Enable or disable an FX category. Ownership.
setTrackFxPreset(trackId: string, category: string, presetIndex: number) => Promise<{ dryWet?: number }>Set FX preset (0–4). Returns new dry/wet if the preset changes it. Ownership.
setTrackFxDryWet(trackId: string, category: string, value: number) => Promise<void>Set dry/wet mix (0.0 dry – 1.0 wet). Ownership.

Scene Context

MethodSignatureDescription
getGenerationContext(excludeTrackId?: string) => Promise<PluginGenerationContext>Full context with chord progression and concurrent track MIDI data. Use excludeTrackId to omit the current track.
getMusicalContext() => Promise<MusicalContext>Lightweight context: key, mode, bpm, bars, genre, timeSignature, chordProgression. No concurrent MIDI.
getActiveSceneId() => string | nullGet the currently active scene ID. Synchronous. Returns null if no scene is selected.
getSceneList() => Promise<PluginSceneInfo[]>Get all scenes in the project. Returns { id, name, isMuted }[].

Transport & Events

MethodSignatureDescription
getTransportState() => Promise<PluginTransportState>One-shot snapshot: isPlaying, isPaused, bpm, position, timeSignature.
onTrackStateChange(listener) => UnsubscribeFnSubscribe to real-time track state changes (mute, solo, volume, pan). Only fires for owned tracks.
onTransportEvent(listener) => UnsubscribeFnSubscribe to transport events (play, stop, BPM change, position change).
onDeckBoundary(listener) => UnsubscribeFnSubscribe to deck loop boundary events (deckId, bar, beat, loopCount).
onSceneChange(listener) => UnsubscribeFnSubscribe to scene change events. Listener receives string | null.

All event methods return an UnsubscribeFn — call it to stop receiving events.

LLM Access

Metered and requires authentication. Check availability before use.

MethodSignatureDescription
generateWithLLM(request: LLMGenerationRequest) => Promise<LLMGenerationResult>Generate text or JSON. Request: system, user, optional maxTokens, responseFormat. Returns { content, tokensUsed, model }.
isLLMAvailable() => Promise<boolean>Check if LLM service is available (user authenticated, gateway reachable).

Synth Preset System

For interacting with Surge XT factory presets.

MethodSignatureDescription
getPresetCategories(pluginName: string) => Promise<string[]>Get available categories (e.g., ['Bass', 'Keys', 'Lead', 'Pad', ...] for Surge XT).
getRandomPreset(category: string) => Promise<PluginPresetData | null>Get a random preset from a category. Returns base64 state data.
getPresetByName(category: string, name: string) => Promise<PluginPresetData | null>Get a specific preset by name.
classifyPresetCategory(description: string) => Promise<string>Classify a text description (e.g., "warm analog pad") into a preset category.

Plugin Presets

Custom presets specific to your plugin (distinct from synth presets).

MethodSignatureDescription
getPluginPresets(category?: string) => Promise<PluginPresetInfo[]>Get saved presets, optionally filtered by category.
savePluginPreset(options: SavePluginPresetOptions) => Promise<PluginPresetInfo>Save a preset. Options: name, optional category, data.
deletePluginPreset(id: string) => Promise<void>Delete a saved preset by ID.

Data Persistence

Scene-Scoped Data

Per-scene key-value storage. Data is tied to a specific scene.

MethodSignatureDescription
getSceneData<T>(sceneId: string, key: string) => Promise<T | null>Read a value for this scene.
setSceneData(sceneId: string, key: string, value: unknown) => Promise<void>Write a value for this scene.
getAllSceneData(sceneId: string) => Promise<Record<string, unknown>>Get all stored data for a scene.
deleteSceneData(sceneId: string, key: string) => Promise<void>Delete a key from scene data.

Project-Scoped Data

Project-wide data that persists across scenes.

MethodSignatureDescription
getProjectData<T>(key: string) => Promise<T | null>Read project-scoped data.
setProjectData(key: string, value: unknown) => Promise<void>Write project-scoped data.

Global Settings

Persists across projects via host.settings:

MethodSignatureDescription
settings.get<T>(key: string, defaultValue: T) => TRead a setting (synchronous, from cache). Returns defaultValue if not set.
settings.set(key: string, value: unknown) => voidWrite a setting (persists to DB).
settings.getAll() => Record<string, unknown>Get all settings.
settings.onChange(listener) => UnsubscribeFnReact to setting changes. Returns unsub function.

Data Directory

MethodSignatureDescription
getDataDirectory() => stringAbsolute path to the plugin's isolated data directory on disk.

File System

Requires the fileDialog capability in the manifest.

MethodSignatureDescription
showOpenDialog(options: PluginFileDialogOptions) => Promise<string[] | null>Show a native file open dialog. Returns selected paths or null if cancelled.
showSaveDialog(options: PluginFileDialogOptions) => Promise<string | null>Show a native file save dialog.
downloadFile(url: string, filename: string, options?) => Promise<string>Download a file to the plugin's data directory. Returns the local path.
importFile(sourcePath: string, destFilename: string) => Promise<string>Copy a local file into the plugin's data directory.

Network

Requires the network capability with allowedHosts in the manifest.

MethodSignatureDescription
httpRequest(options: PluginHttpRequestOptions) => Promise<PluginHttpResponse>Make an HTTP request to an allowed host. Options: url, method, headers, body, timeoutMs. Returns { status, statusText, headers, body }.

Secure Storage

Secrets are encrypted via the OS keychain and scoped per plugin. Plugin A cannot access plugin B's secrets.

MethodSignatureDescription
storeSecret(key: string, value: string) => Promise<void>Store an encrypted secret (e.g., API key).
getSecret(key: string) => Promise<string | null>Retrieve a secret. Returns null if not found.
deleteSecret(key: string) => Promise<void>Delete a stored secret.

Sample Library

MethodSignatureDescription
getSamples(filter?: PluginSampleFilter) => Promise<PluginSampleInfo[]>Query the sample library. Filter by bpm, key, category, searchQuery.
getSampleById(id: string) => Promise<PluginSampleInfo | null>Get a specific sample by ID.
importSamples(filePaths: string[]) => Promise<PluginSampleImportResult>Import audio files. Returns { imported, skipped, errors }.
createSampleTrack(sampleId: string, options?) => Promise<PluginTrackHandle>Create a sample track in the active scene.
deleteSampleTrack(trackId: string) => Promise<void>Delete a sample track.
getPluginSampleTracks() => Promise<PluginSampleTrackInfo[]>Get all sample tracks in the scene. Re-establishes ownership. Returns { track, sample, volume, pan }[].
timeStretchSample(sampleId: string, targetBpm: number) => Promise<PluginSampleInfo>Time-stretch a sample to a target BPM. Returns the new sample info.

Scene Composition

MethodSignatureDescription
composeScene(options: ComposeSceneOptions) => Promise<ComposeSceneResult>Trigger bulk composition for the active scene. LLM plans arrangement, creates tracks, generates MIDI. Options: contractPrompt, optional genre.
onComposeProgress(listener: ComposeProgressListener) => UnsubscribeFnSubscribe to composition progress events (planning, generating, complete, error).
onEngineReady(listener: () => void) => UnsubscribeFnSubscribe to engine ready events. Fires when the engine finishes loading tracks after a scene change.

Notifications & Progress

MethodSignatureDescription
showToast(type, title, message?) => voidShow a toast notification. Type: 'info', 'success', 'warning', 'error'.
setProgress(trackId: string, progress: number) => voidShow progress on a track (0–100). Pass -1 to hide.
setStatusMessage(message: string | null) => voidSet a status message in the accordion header. Pass null to clear.
confirmAction(title: string, message: string) => Promise<boolean>Show a confirmation dialog. Returns true if confirmed.

Performance / Logging

MethodSignatureDescription
logMetric(name: string, durationMs: number, metadata?) => voidLog a named performance metric.
startTimer(name: string) => () => voidStart a timer. Returns a stop function that auto-logs the duration via logMetric().

Error Codes

All errors thrown by the host are PluginError instances with a typed code property.

CodeDescription
NOT_OWNEDTried to modify a track not owned by this plugin
TRACK_NOT_FOUNDTrack ID doesn't exist in engine
TRACK_LIMIT_EXCEEDEDPlugin has too many tracks (default: 16 per scene)
NO_ACTIVE_SCENENo scene is selected
ENGINE_ERRORAudio engine call failed
INVALID_MIDIMalformed MIDI data (e.g., empty notes array)
FILE_NOT_FOUNDReferenced file doesn't exist
INVALID_FORMATUnsupported audio format
PLUGIN_NOT_FOUNDVST/AU plugin not installed or not found on track
LLM_BUDGET_EXCEEDEDOver daily token limit
LLM_UNAVAILABLELLM gateway unreachable
NOT_AUTHENTICATEDUser not logged in
TIMEOUTOperation timed out
CANCELLEDUser cancelled the operation
INCOMPATIBLEPlugin requires newer SDK version
CAPABILITY_DENIEDPlugin lacks required capability in manifest
SECRET_NOT_FOUNDSecret key doesn't exist

Built-in Plugins

These ship with Signals & Sorcery and serve as reference implementations:

PluginTypeDescription
@signalsandsorcery/synth-generatormidiAI-powered MIDI generation with Surge XT presets
@signalsandsorcery/sample-playersampleSample library browser with time-stretching
@signalsandsorcery/audio-textureaudioAI audio texture generation via Lyria 2

Security Model

  • Ownership scoping — Plugins can only modify tracks they created (enforced at runtime)
  • Capability gating — Network and file system access require manifest declarations
  • Secret isolation — Each plugin's secrets are encrypted and scoped per plugin
  • Track limits — 16 tracks per plugin per scene (configurable)
Last Updated:
Contributors: shiehn