Skip to content
Loading SpecStep…
On this page

Quickstart

Updated 2026-05-26

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:

All four scripts cover the same flow with the same env-var conventions.

MCP

If your client speaks MCP, the equivalent flow is:

  1. tools/call → start_interview (no arguments).
  2. tools/call → submit_interview_turn with {interview_id, message} — async by default: returns a job_id. Poll tools/call → get_interview_turn_status with {job_id} until status is completed, then read the agent's reply + state from the snapshot. Repeat per turn until complete. (Pass mode: "sync" for the legacy inline-reply path — small/fast turns only.)
  3. tools/call → start_generation with {intake_id, review_profile}.
  4. tools/call → wait_for_generation with {generation_id} — returns the recommended polling delay, the state, the live estimated_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 (when Complete) a short-lived package_url.
  5. GET the package_url to 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.