Need an account first? API keys are minted from the in-app Settings panel. Sign up (free tier: 1 free credit, one-time — see pricing) → Settings → API keys → "Create API key". The script below assumes that key is already on your clipboard.
Drive the SpecStep generation pipeline end-to-end with one script. The flow below verifies your key, runs an interview to completion, kicks off a generation, polls until the package is ready, and downloads it. Drop your API key in SPECSTEP_API_KEY, paste the script, run.
Before you start, you need an API key. Create one at Settings → API keys and copy the raw value once — it's only shown at create time. See Authentication for the why.
When a call fails: every failure is a typed code with a fix in the error reference. The flow below surfaces the ones you'll hit first — async turn/job polling,
401/403auth,409/422generation validation,429backoff (honourRetry-After), and the clarification-required pause — so keep that page open the first time through.
Bash (cURL)
Runs against a stock /bin/sh. Uses POSIX grep + sed so no jq dependency.
#!/usr/bin/env sh
set -eu
: "${SPECSTEP_API_KEY:?Set SPECSTEP_API_KEY (sf_...) before running.}"
: "${SPECSTEP_API_URL:=https://specstep.com}"
: "${SPECSTEP_PROFILE:=Fast}" # Fast | Normal | Extensive — Researcher is a fan-out profile that spawns 3 child generations, not handled by this single-id poll loop.
AUTH_HEADER="Authorization: Bearer ${SPECSTEP_API_KEY}"
curl_json() {
# $1 = method, $2 = path, $3 (optional) = body JSON
if [ "$#" -ge 3 ]; then
curl --fail --silent --show-error \
--request "$1" \
--header "${AUTH_HEADER}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "$3" \
"${SPECSTEP_API_URL}$2"
else
curl --fail --silent --show-error \
--request "$1" \
--header "${AUTH_HEADER}" \
--header "Accept: application/json" \
--header "Content-Length: 0" \
"${SPECSTEP_API_URL}$2"
fi
}
extract() {
# extract <field-name> <json> — returns the first string value or empty.
printf '%s' "$2" | grep -o "\"$1\":\"[^\"]*\"" | head -n 1 \
| sed "s/.*\"$1\":\"\\([^\"]*\\)\".*/\\1/"
}
extract_num() {
# extract_num <field-name> <json> — returns the first numeric value or empty.
printf '%s' "$2" | grep -o "\"$1\":[0-9.]*" | head -n 1 \
| sed "s/.*:\\([0-9.]*\\)/\\1/"
}
# 1. Verify the key.
curl_json GET /v1/me >/dev/null
printf 'Key OK.\n'
# 2. Start an interview. No request body required.
START=$(curl_json POST /v1/interviews)
INTERVIEW_ID=$(extract id "${START}")
printf 'Interview: %s\n' "${INTERVIEW_ID}"
# 3. Submit interview turns. Turns are ASYNC by default (changed
# 2026-05-19): the POST commits your message and returns 202 with a
# job_id; the agent's reply runs in the background. Poll the job until
# it's "completed", then read the agent's reply + updated state from
# the returned "snapshot". The agent drives the conversation; you reply
# one turn at a time. A typical interview is 5-15 turns. Each turn body
# is {"message": "..."}. Keep quotes and newlines out of the literal so
# the JSON body stays valid without an escaping helper.
submit_turn() {
# submit_turn <message> — submits one turn and blocks until the async
# turn job reaches a terminal status; prints the completed turn JSON.
JOB=$(curl_json POST "/v1/interviews/${INTERVIEW_ID}/turns" \
"$(printf '{"message":"%s"}' "$1")")
JOB_ID=$(extract job_id "${JOB}")
[ -n "${JOB_ID}" ] || { printf 'No job_id on turn submit.\n' >&2; return 1; }
while true; do
TURN=$(curl_json GET "/v1/interviews/turns/${JOB_ID}")
case "$(extract status "${TURN}")" in
completed) printf '%s' "${TURN}"; return 0 ;;
failed) printf 'Turn failed: %s\n' "$(extract error_code "${TURN}")" >&2; return 1 ;;
esac
sleep 3 # queued | running — agent reply still in flight.
done
}
FIRST_TURN='I want to build a customer-feedback portal: small SaaS web app where customers leave structured feedback on the products my company ships. Single-tenant, internal pilot first, English only.'
submit_turn "${FIRST_TURN}" >/dev/null
printf 'First turn complete.\n'
# Continue the interview by hand — read the agent's reply from the
# completed turn's "snapshot" (or fetch GET /v1/interviews/{id}), reply
# via another submit_turn call, and repeat. When you've answered enough
# to proceed, mark the interview complete:
curl_json POST "/v1/interviews/${INTERVIEW_ID}/complete" >/dev/null
# 4. Read the intake_artifact_id off the completed interview.
INTERVIEW=$(curl_json GET "/v1/interviews/${INTERVIEW_ID}")
INTAKE_ID=$(extract intake_artifact_id "${INTERVIEW}")
[ -n "${INTAKE_ID}" ] || { printf 'No intake_artifact_id — interview did not complete cleanly.\n' >&2; exit 1; }
printf 'Intake: %s\n' "${INTAKE_ID}"
# 5. Start a generation. Body uses intake_id + review_profile.
START_BODY=$(printf '{"intake_id":"%s","review_profile":"%s"}' "${INTAKE_ID}" "${SPECSTEP_PROFILE}")
GEN_START=$(curl_json POST /v1/generations "${START_BODY}")
GENERATION_ID=$(extract id "${GEN_START}")
printf 'Generation: %s\n' "${GENERATION_ID}"
# 6. Poll until terminal. 15s is gentle; honour Retry-After on 429.
DELAY=15
PACKAGE_ID=""
while true; do
STATUS=$(curl --silent --show-error \
--output /tmp/specstep_gen.json \
--dump-header /tmp/specstep_gen.headers \
--write-out '%{http_code}' \
--request GET \
--header "${AUTH_HEADER}" \
--header "Accept: application/json" \
"${SPECSTEP_API_URL}/v1/generations/${GENERATION_ID}")
if [ "${STATUS}" = "429" ]; then
RA=$(grep -i '^Retry-After:' /tmp/specstep_gen.headers | tr -d '\r' | awk -F': ' '{print $2}')
: "${RA:=30}"
sleep "${RA}"
continue
fi
if [ "${STATUS}" != "200" ]; then
printf 'Poll failed: HTTP %s\n' "${STATUS}" >&2
cat /tmp/specstep_gen.json >&2
exit 1
fi
BODY=$(cat /tmp/specstep_gen.json)
# Surface the fields that make a 4-32 min run legible: phase_detail
# ("Revising — round 2 of 3"), live progress, accrued cost, and the
# ETA countdown. estimated_time_remaining_seconds recomputes each read
# (it won't sit frozen); it's null between phases — show "?" then.
STATE=$(extract state "${BODY}")
PHASE=$(extract phase_detail "${BODY}")
PROGRESS=$(extract_num progress_percent "${BODY}")
COST=$(extract_num running_cost_usd "${BODY}")
ETA=$(extract_num estimated_time_remaining_seconds "${BODY}")
printf 'state=%s phase=%s progress=%s%% cost=$%s eta=%ss\n' \
"${STATE}" "${PHASE:-—}" "${PROGRESS:-0}" "${COST:-0}" "${ETA:-?}"
case "${STATE}" in
Complete)
PACKAGE_ID=$(extract package_id "$(cat /tmp/specstep_gen.json)")
break
;;
Failed|Cancelled)
printf 'Generation %s\n' "${STATE}" >&2
exit 1
;;
PausedAwaitingClarification)
printf 'Generation paused for a clarification. Fetch GET /v1/generations/%s/clarifications, then POST /v1/generations/%s/clarifications/answers.\n' "${GENERATION_ID}" "${GENERATION_ID}" >&2
exit 1
;;
esac
sleep "${DELAY}"
done
# 7. Download the package zip. The zip endpoint redirects to a short-
# lived SAS URL — -L follows the redirect.
curl --fail --location --silent --show-error \
--output "${GENERATION_ID}.zip" \
--request GET \
--header "${AUTH_HEADER}" \
"${SPECSTEP_API_URL}/v1/packages/${PACKAGE_ID}/zip"
printf 'Package downloaded to %s.zip\n' "${GENERATION_ID}"
The interview phase between steps 3 and 4 is iterative — the loop above shows the minimum (one user turn + force-complete) so the script reads end-to-end. In practice you submit several turns with submit_turn, reading the agent's reply from each completed job's snapshot before sending the next, until you've answered enough for the agent to produce a useful intake. Each turn body is the same {"message": "..."} shape. (A turn job that comes back failed with is_retryable: true — e.g. INTERVIEW_TURN_TIMEOUT — can simply be re-submitted; send an Idempotency-Key header if you want safe retries.)
Other languages
Equivalent end-to-end scripts in other languages:
- Python —
requests - JavaScript / TypeScript —
fetch - C# —
HttpClient
All four scripts cover the same flow with the same env-var conventions.
MCP
If your client speaks MCP, the equivalent flow is:
tools/call → start_interview(no arguments).tools/call → submit_interview_turnwith{interview_id, message}— async by default: returns ajob_id. Polltools/call → get_interview_turn_statuswith{job_id}untilstatusiscompleted, then read the agent's reply + state from thesnapshot. Repeat per turn untilcomplete. (Passmode: "sync"for the legacy inline-reply path — small/fast turns only.)tools/call → start_generationwith{intake_id, review_profile}.tools/call → wait_for_generationwith{generation_id}— returns the recommended polling delay, the state, the liveestimated_time_remaining_seconds/estimated_completion_at/running_cost_usd/phase_detail/progress_explanation(display these so a 4–32 min run doesn't look hung), and (whenComplete) a short-livedpackage_url.GETthepackage_urlto download the zip.
The MCP tool catalog mirrors the REST surface for the operations an agent typically needs, with one delivery exception: package delivery to GitHub is REST-only (POST /v1/packages/{id}/deliver) — there is no MCP equivalent. Your tools/list is also scoped to your key's permissions: a standard key sees the customer catalog, while operator/admin tools are filtered out entirely (see the MCP permission boundary). See the Manual JSON-RPC walkthrough for a copy-paste MCP example that includes the initialize handshake.
Track how you build it — the session-state kit
The script above generates the package. The session-state kit is the complementary half: a Claude Code plugin that has your AI coder record how the project gets built against your SpecStep project — build sessions, a decision log, a backlog, and per-session token usage that rolls up into a project's "cost to build."
Install it from SpecStep's public marketplace:
claude plugin marketplace add No-Compromise-AI/specstep-plugins
claude plugin install specstep-session@specstep
That registers two skills plus a usage reporter:
/start-session— run before any material work. Recovers where the last session left off (active build session + recent decisions + backlog), surfaces drift against your main branch, and starts (or resumes) one build session that everything the session ships links back to./end-session— run as the session closes. Appends a decision-log entry per material decision, files anything deferred, resolves the item you picked up, and closes the build session with a history entry — before the code ships, so the record stays honest.- A
SessionEndhook reports the session's token usage automatically. SetSPECSTEP_API_KEY(scoped tosession_state.write) in the agent's environment so the usage lands.
The kit drives only the self-service session-state and project tools, so your standard API key runs the whole protocol — nothing in it needs elevated scopes. Full walkthrough — the scopes, the four setup steps, and how to wire the pieces by hand for non-plugin agents — is in Session-state kit → Hooking it up.
One plugin, not two. The token-usage / cost-to-build reporter ships inside
specstep-sessionas aSessionEndhook — there's no separate "usage plugin" to install. The oneclaude plugin installabove gives you both the protocols and the automatic cost-to-build (setSPECSTEP_API_KEYwithsession_state.writeso it can record).
Already have a project underway? Migrate it
If you're adding SpecStep to an existing codebase — one that already has handoff notes, a decision log, a backlog, or a folder of spec/design docs — don't start from a blank slate. Import the history first, then run the protocols forward from there:
- Handoff / decision-log / backlog markdown →
import_session_state_from_markdown(dry-run by default; idempotent re-imports). Calllist_session_state_import_parsersfirst to pick the parser for each aggregate type. - Existing spec/design docs →
preview_doc_migrationthencommit_doc_migrationto normalize a.zipof docs into a package linked to your project (or use the in-app Migrate existing docs card).
Full step-by-step — including the 1 MB inline cap and the REST upload path for a larger corpus — is in Migrating an existing project.