Status & async jobs
Every state-mutating tool in Signals & Sorcery now runs as an async job. The HTTP call returns immediately with a jobId; the work continues in the background; you (or your agent) poll or long-poll for the result. This page covers the full contract — the endpoints, the CLI verbs, the MCP tools, and the SSE event stream — plus how to read the structured status envelopes.
Why async
Long-running tools (compose_scene, make_beat, render_to_performance, sas_split_stems, export_audio, dsl_generate_midi, …) routinely take 30 – 120 s. Pre-async, agents waited on a single HTTP socket and got killed by 30 s subprocess timeouts while the workflow finished invisibly on the server. The agent then acted on stale state.
The async wrapper is a single fix:
- The tool returns
{ jobId, status, operation }in under a second. - The workflow keeps running.
- The caller polls (
sas job wait <id>/GET /jobs/:id/wait) or subscribes (SSE) for terminal state. - Downstream tools that depend on the work block on the
jobIdfirst.
The contract is identical across CLI, HTTP, MCP, and the in-app chat-plugin.
The agent recovery rule
If any tool response carries
changes.jobId, you MUST callwait_for_job(orsas job wait) before invoking any tool that depends on that result.
The async tool's response also includes a nextSteps array whose first entry is the wait_for_job call pre-substituted with the job id — agents that follow nextSteps are async-correct by construction.
Quick start
# 1. Liveness check — does the API server respond?
sas health
# → { "status": "ok", "timestamp": "2026-05-13T…" }
# 2. Kick off async work. The tool returns a jobId immediately.
JOB=$(sas make beat --vibe "punchy 4-bar techno" --json \
| jq -r '.data.changes.jobId')
echo "Job: $JOB"
# 3. Block until the job reaches a terminal state.
sas job wait "$JOB" --timeout 120
# Exits 0 on completed, 5 on failed, 4 on timeout.
# 4. Or peek without blocking.
sas job status "$JOB"
# 5. Or list everything running.
sas job list --status running
CLI surface
The sas CLI has two relevant command families: one-shot health (sas health, sas status) and the sas job family for async jobs.
sas health — is the API up?
sas health
# { "status": "ok", "timestamp": "2026-05-13T13:24:55.000Z" }
Hits GET /api/v1/health. Exits 0 on success, 3 on connection refused (the app isn't running), 2 on any other error.
If sas health 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 (localhost:7655) to be listening.
sas status — layered service health
sas status
# ✓ api version=v1
# ✓ engine reachable=true, bpm=120
# ✓ database migrations=ok, project_bound=true
# ✓ auth token=present
A multi-service health check (API, engine, database, auth). Exits 0 if every service reports ok: true, 2 otherwise, 3 on connection refused.
Use --json to get the raw envelope for scripting:
sas status --json | jq '.data.engine.bpm'
sas job … — manage running jobs
sas job list # every job, newest first
sas job list --status running # filter by status
sas job list --status failed # …
sas job status <jobId> # one job's full state
sas job wait <jobId> [--timeout 300] # long-poll until terminal
sas job cancel <jobId> # cancel a running job
| Subcommand | HTTP equivalent | Notes |
|---|---|---|
sas job list | GET /api/v1/jobs[?status=…] | Newest-first JobState array |
sas job status <id> | GET /api/v1/jobs/:id | One job; 404 if unknown |
sas job wait <id> | GET /api/v1/jobs/:id/wait?timeout=<ms> | Long-poll; returns when terminal |
sas job cancel <id> | POST /api/v1/jobs/:id/cancel | 404 if unknown or already terminal |
Status values: queued, running, completed, failed, cancelled.
Filtering accepts any of those literal strings: --status queued, --status running, --status completed, --status failed, --status cancelled.
Exit codes (job-aware)
The CLI's exit-code contract is async-aware so set -e scripts react correctly to in-flight work:
| Code | Meaning |
|---|---|
0 | Success |
1 | Plan validation failed (sas validate only) |
2 | Argument parsing, tool failure, or generic non-zero |
3 | Connection refused — the app isn't running |
4 | Timeout — typically sas job wait hit its --timeout before terminal |
5 | Job terminated with status: 'failed' |
sas job wait "$JOB" --timeout 120
case $? in
0) echo "Job completed" ;;
4) echo "Still running after 120s — keep waiting?" ;;
5) echo "Job failed — inspect with: sas job status $JOB" ;;
esac
HTTP endpoints
Every CLI verb is a thin wrapper over a stable HTTP route on http://localhost:7655/api/v1. Hit them directly from Python, curl, or any HTTP client.
GET /api/v1/health — liveness
curl -s http://localhost:7655/api/v1/health
# { "success": true, "data": { "status": "ok", "timestamp": "…" } }
The simplest "is the server up?" probe. Returns immediately, no engine RPC.
GET /api/v1/jobs — list jobs
curl -s 'http://localhost:7655/api/v1/jobs'
curl -s 'http://localhost:7655/api/v1/jobs?status=running'
Returns { success: true, data: JobState[] } newest-first.
GET /api/v1/jobs/:id — one job
curl -s "http://localhost:7655/api/v1/jobs/$JOB"
# { "success": true, "data": { id, operation, status, progress, … } }
Returns 404 if the job id is unknown (e.g. expired from in-memory store).
GET /api/v1/jobs/:id/wait?timeout=<ms> — long-poll
curl -s "http://localhost:7655/api/v1/jobs/$JOB/wait?timeout=60000"
Blocks server-side until the job reaches completed / failed / cancelled, then returns its final state. If timeout elapses first, the server returns HTTP 408 with the in-progress snapshot. Default timeout: 300 000 ms (5 min).
POST /api/v1/jobs/:id/cancel — cancel
curl -s -X POST "http://localhost:7655/api/v1/jobs/$JOB/cancel"
# { "success": true, "data": { "cancelled": true } }
Transitions the job to cancelled only if it hasn't reached a terminal state. 404 otherwise.
POST /api/v1/execute — invoke a tool
curl -s -X POST http://localhost:7655/api/v1/execute \
-H 'Content-Type: application/json' \
-d '{"action":"make_beat","params":{"vibe":"chill lo-fi"}}'
Returns an OperationResult envelope. When the tool is async-wrapped, the response includes changes.jobId — call wait_for_job (or /jobs/:id/wait) before assuming the work is done.
SSE event stream
Subscribe to a live event feed via GET /api/v1/events/stream:
sas events stream
# event: mutation
# data: { "action": "make_beat", "result": {…}, "timestamp": … }
# event: jobProgress
# data: { "jobId": "…", "percent": 42, "message": "Generating drums…" }
# event: jobComplete
# data: { "jobId": "…", "result": {…} }
# event: jobFailed
# data: { "jobId": "…", "error": "…" }
# event: domainEvent
# data: { "type": "scene:created", "sceneId": "…" }
| SSE event | Payload | When |
|---|---|---|
mutation | { action, result, timestamp } | Any /execute call (success or failure) |
jobProgress | { jobId, percent, message? } | A wrapped tool called reportProgress(pct, msg) |
jobComplete | { jobId, result } | Async job reached completed |
jobFailed | { jobId, error, remediation? } | Async job reached failed |
domainEvent | Typed business event (e.g. scene:created) | State changed in the engine or DB |
Use SSE when you want real-time progress without polling — a GUI progress bar, a Slack notification on failure, a live dashboard. Use polling (sas job wait) when you just want the final answer.
Subscribe with curl or sas events stream; filter with grep:
sas events stream | grep -E 'jobProgress|jobComplete|jobFailed'
sas events stream --filter jobProgress
The job envelope
Every JobState returned by the endpoints has this shape:
{
"id": "job_01HN…", // uuid
"operation": "make_beat", // tool name
"status": "running", // queued|running|completed|failed|cancelled
"progress": 42, // 0-100, optional
"progressMessage": "Generating drums…",
"startedAt": "2026-05-13T13:24:55.000Z",
"completedAt": null, // ISO string once terminal
"error": null, // error string when status='failed'
"metadata": { // tool-supplied correlation hints
"projectId": "…",
"sceneId": "…"
},
"dependsOn": null, // optional jobId array
"result": null, // workflow output, present when 'completed'
"elapsedMs": 12345
}
result carries the original tool's changes/data payload — the same shape it would have returned synchronously pre-async. Read it from sas job wait, sas job status, or the jobComplete SSE event.
Job dependencies (dependsOn)
A job may declare dependsOn: [otherJobId, …]. It stays in queued state until every dependency reaches completed. If a dependency fails or is cancelled, the dependent job auto-fails with a cascade reason. This is how composite tools (e.g. generate_scene_midi_bulk) coordinate per-track generation without exposing the orchestration to callers.
MCP — wait_for_job and the async tools
MCP clients see the same async contract via a registered tool named wait_for_job (reachable through the meta-tool sas_run, since it's not one of the six top-level MCP primitives):
// MCP tool call
{
"tool": "sas_run",
"params": {
"action": "wait_for_job",
"params": { "jobId": "job_01HN…", "timeoutSeconds": 60 }
}
}
wait_for_job polls the JobManager every 250 ms and returns when the job reaches terminal state, or after timeoutSeconds (default 25, capped at 60). On timeout it returns success: true with status: 'running' and stillWaiting: true — not an error; the agent retries.
Among the six default MCP primitives, sas_apply_plan is the one that returns an async jobId. Other primitives (sas_inspect, sas_create_plan, sas_validate_plan, sas_render_preview, sas_undo_checkpoint) are fast enough to stay synchronous. Tools reached via sas_run follow the wrapped-tool contract below.
Which tools are async-wrapped
Wrap status (May 2026):
| Category | Wrapped tools |
|---|---|
| Scene composition | scene_create, create_project, compose_contract, make_beat |
| MIDI generation | dsl_generate_midi, dsl_generate_drums, generate_scene_midi_bulk |
| Revision | revise_track, revise_scene |
| FX | dsl_set_track_fx, dsl_load_fx_chain, dsl_shuffle_preset |
| Rendering & export | render_to_performance, export_audio |
| Audio analysis | sas_analyze_audio, sas_split_stems |
| Sample library | scan_audio_directory, import_samples_by_criteria |
| Planning | sas_apply_plan |
Synchronous tools (read-only or sub-second) include sas_inspect_*, sas_create_plan, sas_validate_plan, summarize_project, dsl_list_tracks, dsl_track_mute, dsl_play, etc. They return the result directly, no jobId.
If you're unsure whether a tool is async, look at its response: if changes.jobId is present, treat it as async.
Worked example — Python
import requests, time
API = "http://localhost:7655"
def execute(action, **params):
r = requests.post(f"{API}/api/v1/execute",
json={"action": action, "params": params})
r.raise_for_status()
return r.json()["data"]
def wait(job_id, timeout_ms=120_000):
"""Long-poll until terminal."""
r = requests.get(f"{API}/api/v1/jobs/{job_id}/wait",
params={"timeout": timeout_ms},
timeout=(timeout_ms + 5000) / 1000)
return r.json()["data"]
# 1. Kick off async work.
res = execute("make_beat", vibe="chill lo-fi", sceneName="Verse")
job_id = res["changes"]["jobId"]
print(f"Job: {job_id}")
# 2. Block until terminal.
final = wait(job_id, timeout_ms=120_000)
if final["status"] == "completed":
print(f"Done in {final['elapsedMs']}ms")
print("Result:", final["result"])
elif final["status"] == "failed":
print("FAILED:", final["error"])
elif final.get("stillWaiting"):
print("Still running — re-poll")
Worked example — bash
#!/usr/bin/env bash
set -euo pipefail
# Compose a scene asynchronously, render it, wait again.
SCENE=$(sas compose_scene \
--description "moody dub techno" \
--scene-name "Intro" \
--bar-length 8 \
--json '{"tracks":[
{"name":"Bass","role":"bass","prompt":"deep sub"},
{"name":"Drums","role":"drums","prompt":"laid back 90 BPM"}
]}' \
--json)
# compose_scene returns a jobId — block before downstream calls.
JOB=$(echo "$SCENE" | jq -r '.data.changes.jobId')
sas job wait "$JOB" --timeout 180
# Now safe to render. render_to_performance is also async.
RENDER=$(sas render_to_performance --json)
RJOB=$(echo "$RENDER" | jq -r '.data.changes.jobId')
sas job wait "$RJOB" --timeout 60
echo "Composed and rendered."
Troubleshooting
sas job wait returns immediately with exit 4 ("timeout") The CLI's --timeout is in seconds, not milliseconds. --timeout 60 means wait 60 s, not 60 ms.
/api/v1/jobs/:id returns 404 for a job I just kicked off Jobs live in-memory inside the app process. Completed jobs are garbage-collected after ~1 h, and all jobs are lost on app restart. If you need persistent results, capture them from sas job wait while the app is alive.
A job is stuck in queued forever It's waiting on a dependsOn that hasn't completed. Inspect with sas job status <id> and look at dependsOn; chase those job ids with sas job list to see which dependency is blocking.
My downstream tool fails saying "track not found" but the upstream job's changes showed a trackId You skipped wait_for_job. The upstream call returned a jobId; the track wasn't created until later. Always block on the jobId before running a tool that depends on the work.
sas events stream keeps disconnecting SSE connections drop on app restart and through some reverse-proxy configurations. The stream is fire-and-forget; restart the subscriber on disconnect. The polling endpoints (/jobs/:id, /jobs/:id/wait) are the durable option when reliability matters.
See also
- CLI reference — every
sasverb in detail. - For agents — integration patterns for Claude Code, Cursor, Claude Desktop, and custom MCP clients.
- Plan-as-artifact loop — the recommended six-verb pattern:
inspect → plan → validate → apply → preview → undo.applyis async-wrapped, so plan execution follows the samewait_for_jobrule.