Skip to content
Loading SpecStep…
On this page

Quickstart

Updated 2026-07-01

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/403 auth, 409/422 generation validation, 429 backoff (honour Retry-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:

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 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 SessionEnd hook reports the session's token usage automatically. Set SPECSTEP_API_KEY (scoped to session_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-session as a SessionEnd hook — there's no separate "usage plugin" to install. The one claude plugin install above gives you both the protocols and the automatic cost-to-build (set SPECSTEP_API_KEY with session_state.write so 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 markdownimport_session_state_from_markdown (dry-run by default; idempotent re-imports). Call list_session_state_import_parsers first to pick the parser for each aggregate type.
  • Existing spec/design docspreview_doc_migration then commit_doc_migration to normalize a .zip of 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.