Need an account first? API keys are minted from the in-app Settings panel. Sign up (free tier: 5 generations / month) → 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.
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 tools mirror the REST surface 1:1 with one exception: package delivery to GitHub is REST-only (POST /v1/packages/{id}/deliver). See Manual JSON-RPC walkthrough for a copy-paste MCP example that includes the initialize handshake.