sas CLI reference

The sas command is a thin wrapper around the local S&S HTTP API. It auto-discovers tools from /api/v1/actions, so every tool registered in the app is available as a CLI subcommand with zero CLI rebuild.

Install

macOS — the installer runs automatically on first launch:

  1. Launch Signals & Sorcery.
  2. On the final wizard screen ("You're all set!") leave "Add sas command to your terminal" checked.
  3. Approve the admin prompt. The app writes a small wrapper to /usr/local/bin/sas that runs the CLI through the app's own bundled runtime — you don't need Node.js installed.
  4. Open a new terminal window (your existing shells don't inherit PATH changes).

Want to install later, reinstall after moving the app, or remove it? Settings → Developer Tools → sas CLI has Install / Reinstall / Uninstall buttons that read the current status and kick off the same one-prompt flow.

If you decline the admin prompt

We don't leave you empty-handed. If you click Cancel on the admin dialog, the app offers to install for just your account — no admin required. The wrapper lands at ~/.local/bin/sas instead of /usr/local/bin/sas. Two follow-ups you'll need to do yourself:

  1. Add ~/.local/bin to your PATH. It's not on PATH by default on macOS. Add this to your shell profile (~/.zshrc, ~/.bashrc, or equivalent):

    export PATH="$HOME/.local/bin:$PATH"
    

    …then open a new terminal and sas --version should work.

  2. Or invoke by full path: ~/.local/bin/sas get_status. Works immediately, no profile editing required.

You can switch to the admin-backed system-wide install at any time from Settings → Developer Tools → sas CLI — click Uninstall (removes the user-local copy without a prompt), then Install.

If sas is not found after install

The install writes to two places: /usr/local/bin/sas (the wrapper) and /etc/paths.d/signals-and-sorcery (for shells that don't have /usr/local/bin on PATH by default, like fish). A few scenarios can still leave sas out of reach:

SymptomFix
zsh: command not found: sas in a terminal you had open during installOpen a new terminal — the old shell still has the pre-install PATH
New terminal also says command not foundOpen Settings → Developer Tools → sas CLI and click Reinstall. The status line will read stale or not-installed and the fix is one click.
Preferences shows Status: stale — the app moved since installClick Reinstall — the wrapper hard-codes the app path at install time; moving the app bundle invalidates the wrapper.
Preferences shows Status: managed by Homebrew (or Nix, or another app)Remove the foreign /usr/local/bin/sas first (brew uninstall …), then click Install. The app refuses to clobber binaries it didn't write.
You use a non-default shell with a custom PATH that drops /usr/local/binRun export PATH=/usr/local/bin:$PATH to verify the wrapper works, then add that line to your shell rc.

Can't make the CLI work? You don't need it. The CLI is a thin wrapper around the local HTTP API — curl against http://localhost:7655/api/v1/execute gives you every action the CLI has. See the automation overview for worked examples.

Windows / Linux: the auto-installer is macOS-only today (the admin-elevation mechanism differs per platform). Use the HTTP or MCP paths instead — they work uniformly.

Verify

Two health checks ship with the CLI — pick the right one for the job.

# Reachability — is the API server responding?
sas health
# { "status": "ok", "timestamp": "2026-04-14T..." }

sas health hits GET /api/v1/health and returns immediately. Use it in CI smoke tests and set -e preludes.

# Layered service health — API, audio engine, database, auth
sas status
#   ✓ api          version=v1
#   ✓ engine       reachable=true, bpm=120
#   ✓ database     migrations=ok, project_bound=true
#   ✓ auth         token=present

sas status reports each subsystem's ok flag plus a short detail line. Exits 0 when every service is ok: true, 2 otherwise, 3 on connection refused. Pair with --json for scripting:

sas status --json | jq '.data.engine'

If either command fails with "Connection refused — is the Signals & Sorcery app running?", launch the app and retry. The CLI is a thin HTTP client; it needs the in-app API server on http://localhost:7655.

Usage shape

sas <action> [--key value]...        Run a tool action by name
sas list-actions                     List every registered tool
sas help [action]                    Show top-level help, or per-action help
sas health                           Reachability check (GET /health)
sas status                           Layered service health (API / engine / db / auth)
sas events stream [--filter <e>]     SSE stream of typed domain + job events
sas refresh                          Re-fetch the /actions manifest cache

# Async job management (every state-mutating tool returns a jobId)
sas job list [--status <s>]          List jobs (filter: queued|running|completed|failed|cancelled)
sas job status <id>                  One job's state
sas job wait <id> [--timeout <sec>]  Long-poll until a job completes (default 300s)
sas job cancel <id>                  Cancel a running job

# Plan-as-artifact surface (recommended for agents)
sas inspect project [--include …]    Read-only project snapshot
sas inspect scene [sceneId]          One scene + its tracks
sas inspect track <trackId>          One track's mute/solo/vol/pan
sas inspect history [--limit n]      Recent checkpoints
sas plan <intent…> [--plan-out f]    Free-text → typed JSON Plan
sas validate <plan-file|->           Validate a plan against current state
sas apply <plan-file|-> [--checkpoint name|--dry-run|--skip-checkpoint]
sas preview [sceneId] [--track-id …] [--refresh] [--bpm …] [--bars …]
sas history list [--limit n]         List checkpoints (newest first)
sas history checkpoint <name> [--notes …]   Manual checkpoint
sas history undo <name>              Restore to checkpoint
sas history delete <name>            Drop one checkpoint
sas history prune                    Drop expired checkpoints

Async jobs are the default. Every state-mutating tool now wraps its workflow in an async job. Calls return a jobId immediately; you (or your agent) call sas job wait <jobId> before invoking anything that depends on the result. See Status & async jobs for the full contract, including HTTP endpoints, SSE events, and the wait_for_job MCP tool.

Global flags

Parsed before the subcommand. All commands honour them.

FlagEffect
--jsonEmit raw JSON envelopes (default is a human-friendly summary)
--host <host>Override API server host (default localhost)
--port <port>Override API server port (default 7655)
--token <token>Bearer token (also read from ~/.sas/token)
--verboseMore chatty logs
--no-colorDisable ANSI colour (also honours NO_COLOR=1)
-h, --helpTop-level help, or per-action help if passed after an action name

Environment variables: SAS_TIMEOUT_MS overrides the default 300 s HTTP timeout (composite tools like make_beat routinely run 30–120 s, so the default is intentionally generous). NO_COLOR=1 disables colour output. Config persists in ~/.sas/config.json; the bearer token in ~/.sas/token (mode 600).

Argument conventions

  • Kebab-case flags → camelCase params: --scene-id abc maps to sceneId: "abc" in the underlying tool call.
  • Booleans: --enabled (true), --no-enabled or --enabled=false (false).
  • Numbers: --bpm 90 — coerced from the tool's input schema; non-numeric values error out early with exit 2.
  • Arrays: repeat the flag — --paths a.wav --paths b.wav.
  • Nested objects (escape hatch): --json '{"key":"value"}'.

Exit codes

CodeMeaning
0Success
1Plan validation failed (sas validate only)
2Argument parsing, tool failure, or generic non-zero
3Connection refused — the app isn't running on http://localhost:7655
4Timeout — typically sas job wait hit its --timeout before terminal
5Job terminated with status: 'failed' (sas job wait only)

This means set -e works in shell scripts — a failing tool stops the script unless you explicitly handle it. For async-aware scripting:

sas job wait "$JOB" --timeout 120
case $? in
  0) echo "Job completed" ;;
  4) echo "Timed out; keep waiting?" ;;
  5) echo "Job failed — sas job status $JOB for details" ;;
esac

Tool discovery

# Every action
sas list-actions

# One action's full help (parameters + when-to-use)
sas help compose_scene
sas help dsl_set_track_fx

Help output follows the 4-section templateopen in new window every tool is enforced to have:

  • WHEN TO USE — scenarios that fit this tool
  • WHEN NOT TO USE — when another tool fits better (named)
  • INPUTS — parameter list with example values
  • OUTPUTS — success / failure envelope shape and emitted events

Progressive disclosure

By default list-actions returns the curated core tool set (~24 scene-scoped verbs covering create, mix, transport, scene navigation, plus the plan-loop verbs). Less-common tools (samples, export, advanced scene plumbing, etc.) are deferred — agents discover them via tool_search:

# Agent: I need something to export audio. Let me search.
sas tool_search --query "export wav" --limit 3
# Returns matches ranked by name + description relevance, with schemas
# so the agent can invoke directly.

The same default-curated set is what the in-app chat-plugin agent sees — /api/v1/actions (used by the sas CLI) and host.listAppTools (used by the chat-plugin) share a single filter implementation. Adding a tool to the registry exposes it on both surfaces atomically; promoting a deferred tool reaches both at once.

Filter parameters

QueryEffect
(none)Curated default — non-deferred tools across all scopes
?scope=sceneNon-deferred, scene-scoped only (mirrors the chat-plugin's default)
?scope=projectNon-deferred, project-scoped only
?include_deferred=trueAll registered tools incl. deferred
?all=trueLegacy alias of ?include_deferred=true
# What the chat-plugin's agent sees by default
curl 'http://localhost:7655/api/v1/actions?scope=scene'

# Every registered tool (admin/debug visibility)
curl 'http://localhost:7655/api/v1/actions?include_deferred=true'

Events

Every mutating tool emits typed domain events. Stream them to react in real time:

# Raw JSON event stream (one event per line)
sas events stream

# Filter for specific event types
sas events stream | grep 'track:created'

# Pretty-print with jq
sas events stream | jq -r 'select(.event == "domainEvent") | .data'

Event types include: scene:created, scene:activated, track:created, track:midi-written, track:fx-changed, bpm:changed, deck:state-changed, sample:imported, transition:created, and more.

Async jobs (every state-mutating tool returns a jobId)

Breaking change (May 2026). The CLI's job subcommand is now sas job (singular). The old plural sas jobs … form was retired alongside the universal async-job rollout. Update scripts accordingly.

Every state-mutating tool (compose_scene, make_beat, dsl_generate_midi, render_to_performance, export_audio, sas_split_stems, …) now wraps its workflow in an async job and returns a jobId immediately. The work continues in the background.

# 1. Kick off the job — call returns in < 1 s.
JOB=$(sas compose_scene --description "chill lo-fi" --scene-name "Verse" \
  --json '{"tracks":[{"name":"Bass","role":"bass","prompt":"deep slow"}]}' \
  | jq -r '.data.changes.jobId')

# 2. Block until the workflow reaches terminal state.
sas job wait "$JOB" --timeout 180

# 3. Or peek without blocking.
sas job status "$JOB"

# 4. Or list everything currently running.
sas job list --status running

The agent recovery rule: if any tool response includes changes.jobId, call sas job wait <id> (CLI) or wait_for_job (MCP / HTTP) before invoking any tool that depends on the result. The async tool's response already includes a nextSteps array whose first entry is the wait call pre-substituted with the job id, so agents that follow nextSteps are async-correct by construction.

sas job is the wrapper around four HTTP endpoints:

VerbHTTPBehaviour
sas job list [--status …]GET /api/v1/jobs[?status=…]Newest-first array
sas job status <id>GET /api/v1/jobs/:idOne job; 404 if unknown
sas job wait <id> [--timeout N]GET /api/v1/jobs/:id/wait?timeout=<ms>Long-poll; --timeout is seconds
sas job cancel <id>POST /api/v1/jobs/:id/cancel404 if unknown or already terminal

See Status & async jobs for the complete contract: which tools are wrapped, the SSE event stream (jobProgress, jobComplete, jobFailed), the wait_for_job MCP tool, Python/bash worked examples, and troubleshooting.

Idempotency keys

All mutating tools accept a top-level --idempotency-key:

# Same key + same params = same result (cached within 60s, per project)
sas dsl_track_create --idempotency-key "retry-abc-1" --name "Bass" --role bass
sas dsl_track_create --idempotency-key "retry-abc-1" --name "Bass" --role bass
# ↑ second call returns the first's result — no duplicate track

Safe to retry on transient errors without corrupting state. See the AI orchestration design docopen in new window § 8 for the full spec.

The --json escape hatch

For tools with complex nested inputs (like compose_scene which takes a tracks array), pass them as JSON directly:

sas compose_scene \
  --description "chill lo-fi" \
  --scene-name "Verse" \
  --json '{
    "tracks": [
      {"name": "Bass",  "role": "bass",  "prompt": "deep, slow lo-fi"},
      {"name": "Drums", "role": "drums", "prompt": "laid-back swung"},
      {"name": "Keys",  "role": "chords","prompt": "jazzy extensions"}
    ]
  }'

Scene loop length: --bar-length

compose_scene and compose_contract both accept --bar-length (one of 2, 4, 8, 16; default 4). It sets the SCENE's loop length — distinct from the per-track bars field inside the tracks[] array (which controls how many bars of MIDI to generate for each track).

# Two-bar disco contract — no tracks yet, agent adds instruments next
sas compose_contract \
  --name "Disco" \
  --description "punchy 2-bar disco" \
  --bar-length 2

# Long 16-bar ambient intro, three tracks generated at once
sas compose_scene \
  --description "ambient 16-bar intro in F minor" \
  --scene-name "Intro" \
  --bar-length 16 \
  --json '{"tracks":[
    {"name":"Pad","role":"pads","prompt":"slow swell"},
    {"name":"Bass","role":"bass","prompt":"sub drone"},
    {"name":"Lead","role":"lead","prompt":"sparse melodic line"}
  ]}'

Passing an invalid --bar-length returns a structured remediation envelope pointing at the allowed values; the LLM-extracted bars from the prompt (when detectable) override the hint.

compose_contract vs compose_scene

Use caseTool
One-shot "scene + contract + tracks"compose_scene
"Contract first, then I'll pick instruments"compose_contract then N × add_instrument

compose_contract returns the new scene's sceneId / engineSceneId in its result and a nextSteps array pre-substituted with the scene ID, so the agent can pipe straight into add_instrument.

Change a track's sound without re-rolling MIDI: dsl_shuffle_preset

dsl_shuffle_preset swaps the Surge XT preset on a synth track without touching its MIDI clip. It's the CLI/agent counterpart of the 🎲 button on the track row in the UI.

# Pick a fresh preset for the snare — MIDI stays, only the timbre changes
sas dsl_shuffle_preset --track Snare

# Or by engine track id (from `sas dsl_list_tracks`)
sas dsl_shuffle_preset --track engine-track-1067

When to reach for it (vs. neighbouring tools):

User intentTool
"Change the sound of the snare" / "give me a different bass preset"dsl_shuffle_preset
"Change the snare pattern" / "regenerate the kick"dsl_generate_midi
"Add reverb to the lead" / "compress the drums"dsl_set_track_fx

The category is auto-derived from the track's role + MIDI note range (via the same buildPresetCategory helper the UI uses), so a bass track gets a bass preset, a low-range bass gets a basses-low preset, etc. Failure envelopes follow the standard remediation taxonomy — no_project_bound, track_not_found, clarification_needed (when the selector matches multiple tracks), unsupported_value (track has no role, or no presets installed for the category), engine_unreachable (Surge XT couldn't be loaded or applied).

Plan-as-artifact surface

Granular tools (scene_create, dsl_track_create, …) remain available and stable, but the recommended path for agents is the six-verb plan-as-artifact loop:

inspect → plan → validate → apply → preview → undo

Each verb is its own subcommand; together they let an agent reason about the project, propose a typed change, check it against current state, mutate the world reversibly, hear the result, and roll back without losing data.

sas inspect … — read-only views

sas inspect project                     # everything: scenes, tracks, context, history
sas inspect project --include scenes,tracks
sas inspect scene                       # active scene
sas inspect scene <sceneId>             # specific scene
sas inspect track <trackId>             # one track's surface state
sas inspect history --limit 10          # recent checkpoints

inspect never mutates. The output is structured JSON in --json mode; human mode prints compact summaries. Names are resolved from UUIDs so agents can chain conversationally without a second lookup.

sas plan <intent…> — emit a typed JSON Plan

# Free-text intent → typed plan, printed to stdout
sas plan "make me a chill lo-fi beat"

# Save the plan for later
sas plan "make me a chill lo-fi beat" --plan-out beat.plan.json

# Force a specific PlanType (when goal-router would guess wrong)
sas plan "add a sub bass" --type track_revise

# Legacy Phase 4 prereq-chain preview (no typed plan, just the chain)
sas plan "play the scene" --chain-only

The plan is the contract: a JSON document the agent can read, edit, explain to the user, and hand to validate / apply. Plan shape lives at src/shared/types/agent-plan.ts and is versioned via metadata.plan_schema_version (currently 1).

Top-level shape:

{
  "id": "plan-scene_create-1714850000-abc123",
  "intent": "make me a chill lo-fi beat",
  "type": "scene_create",
  "preconditions": { "project_bound": true },
  "steps": [
    { "id": "plan-…0.scene_create",     "type": "scene_create",     "inputs": { "name": "lo-fi" } },
    { "id": "plan-…1.dsl_track_create", "type": "dsl_track_create", "inputs": { "name": "Bass", "role": "bass" } }
  ],
  "rollback": { "strategy": "checkpoint_undo" },
  "metadata": {
    "created_at": "2026-05-04T15:00:00.000Z",
    "created_by": "cli",
    "plan_schema_version": 1
  }
}

PlanTypes recognized today: scene_create, scene_revise, track_revise, transition_create, mix_balance, render_preview, composite.

sas validate <plan-file|-> — check before apply

sas validate beat.plan.json
sas plan "make a beat" --plan-out /tmp/p.json && sas validate /tmp/p.json

# Pipe directly — validate reads stdin when the file arg is "-"
sas plan "make a beat" --json | jq '.data.changes.plan' | sas validate -

Returns a PlanValidationResult:

{
  "valid": false,
  "errors": [
    {
      "path": "$.preconditions.project_bound",
      "code": "missing_precondition",
      "message": "No project is bound — open or create one first.",
      "suggestedFix": { "tool": "list_projects", "args": {} }
    }
  ],
  "warnings": [],
  "preview": {
    "wouldCreate": { "scenes": 1, "tracks": 4 },
    "riskLevel": "medium",
    "requiresConfirmation": false
  }
}

Exit codes:

  • 0 — valid, no errors
  • 1 — invalid (one or more errors); script can branch on this
  • 2 — bad input (file not found, malformed JSON)

suggestedFix is the agent's recovery hook: it points at the exact tool + args that would unblock the failed precondition, so the agent can self-correct without re-prompting the user.

sas apply <plan-file|-> — execute reversibly

# Auto-checkpoint pre-apply (default). Restorable via `sas history undo`.
sas apply beat.plan.json

# Override the checkpoint name
sas apply beat.plan.json --checkpoint pre-techno

# Validate-only mode; print the preview block, don't mutate
sas apply beat.plan.json --dry-run

# Skip the checkpoint entirely (caller handles undo themselves)
sas apply beat.plan.json --skip-checkpoint

# Pipe from `plan` directly
sas plan "make me a beat" --json | jq '.data.changes.plan' | sas apply -

Default behavior:

  1. Validate the plan. If invalid, exit 1 with the error list.
  2. Auto-create a checkpoint named pre-apply-<plan.id>-<ts> capturing DB rows + engine surface state (mute/solo/volume/pan/plugin state).
  3. Execute steps sequentially. Each step's outputs resolve ${steps.<id>.outputs.<key>} references in later step inputs.
  4. On any step failure, fire compensate hooks LIFO and return failed_step_id + rolled_back_to. The checkpoint is preserved so the user can recover with sas history undo.

Idempotent: re-running an interrupted plan replays from the last non-completed step. Step ids are deterministic (${plan.id}.${idx}.${type}).

sas preview [sceneId] — render audio

sas preview                              # active scene
sas preview <sceneId>
sas preview --track-id <trackId>         # bounce just this track
sas preview <sceneId> --refresh          # force re-render (skip cache)
sas preview <sceneId> --bpm 120 --bars 8 # render-time overrides

Returns:

{
  "audio": {
    "url": "file:///…/render-cache/<hash>.wav",
    "durationSeconds": 7.74,
    "sampleRate": 48000,
    "contentHash": "sha256:…",
    "summary": "4 tracks · 4 bars @ 90 BPM",
    "cacheHit": true,
    "staleness": "fresh"
  }
}

Backed by the content-addressable render cache. staleness values:

ValueMeaning
freshRender cache hit; the WAV reflects current state
stale_renderCache exists but content hash drifted; pass --refresh to rebuild
no_renderFirst render request — --refresh not needed; we'll build it
rendered_nowWe just rendered for this call

Per-track preview uses the C++ trackIds filter in SceneRenderer.cpp to bounce one track in isolation — handy for A/B-ing a track_revise plan before applying it.

sas history … — checkpoints + undo

sas history list --limit 10                       # newest first
sas history checkpoint pre-experiment             # manual save point
sas history checkpoint pre-experiment --notes "before mix tweaks"
sas history undo pre-experiment                   # restore
sas history delete pre-experiment                 # drop one
sas history prune                                 # drop expired (default TTL 24h)

undo runs in a single SQLite transaction + sequence of engine RPCs. Render cache entries are content-addressable and survive undo independently — restoring scene state hits the cache immediately when you sas preview after the undo.

Audio bounces are not included in checkpoints. To preserve a render explicitly, run sas preview (or render_to_performance) before the checkpoint — it lands in the cache and stays there.

Universal flags for plan/apply/preview

Following clig.devopen in new window, every plan-shaped command accepts:

FlagAll commandsMutatingApply-only
--jsonyesyesyes
--no-coloryesyesyes
--verboseyesyesyes
--dry-runyesyes
--plan-out <file>yes (plan)
--checkpoint <name>yes
--skip-checkpointyes
Last Updated:
Contributors: shiehn