Skip to content
Loading SpecStep…
On this page

Language samples

Updated 2026-05-29

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". Each snippet below reads the key from an environment variable.

SpecStep has no wrapper SDKs — there is no library to install. Every language hits the REST API directly with whatever HTTP client is idiomatic. The snippets below are standalone scripts: copy them, set two environment variables, run.

For the full end-to-end pipeline (interview → generation → package fetch), the Quickstart covers bash. Sections below cover the equivalent flow in Python, JavaScript / TypeScript, and C#.

Environment variables

All samples read credentials from the environment. Set these before running:

export SPECSTEP_API_URL=https://specstep.com
export SPECSTEP_API_KEY=sf_xxxxxxxxxxxx

Don't put your key in source control. If you don't have a key yet, mint one at Settings → API keys.

Bash

See the Quickstart — full end-to-end flow with cURL, no jq dependency. The bash sample matches the conventions of the snippets below.

Python

httpx works equally well — swap the import and session = httpx.Client(). The script below uses requests for familiarity.

"""End-to-end SpecStep flow: interview → generation → download package."""

import os
import sys
import time

import requests

API_KEY = os.environ["SPECSTEP_API_KEY"]                                 # sf_*
API_URL = os.environ.get("SPECSTEP_API_URL", "https://specstep.com")
TERMINAL = {"Complete", "Failed", "Cancelled"}

session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {API_KEY}",
    "Accept": "application/json",
})


def fmt(value, missing="—"):
    """Render a nullable status field. The status projection leaves
    forecast/retry fields null between phases — show a dash, not 'None'."""
    return missing if value is None else value


# 1. Verify the key.
session.get(f"{API_URL}/v1/me", timeout=30).raise_for_status()

# 2. POST /v1/interviews — no body. Returns interview id + opening turn.
start = session.post(f"{API_URL}/v1/interviews", timeout=30)
start.raise_for_status()
interview = start.json()
interview_id = interview["id"]
print(f"Interview {interview_id}")


# 3. Submit interview turns. Turns are ASYNC: 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".
def submit_turn(message):
    """Submit one turn and block until the async turn job is terminal.
    Returns the completed interview snapshot; raises on failure."""
    post = session.post(
        f"{API_URL}/v1/interviews/{interview_id}/turns",
        json={"message": message},
        # Optional: an Idempotency-Key makes a retry safe — any unique
        # token, 1..128 chars of [A-Za-z0-9._:-].
        # headers={"Idempotency-Key": "..."},
        timeout=30,
    )
    post.raise_for_status()
    submitted = post.json()
    job_id = submitted.get("job_id")
    if not job_id:
        # Idempotency cached-replay (a retry with the same key): the original
        # result is inlined as "snapshot" — there's no job to poll.
        return submitted.get("snapshot")
    while True:
        poll = session.get(
            f"{API_URL}/v1/interviews/turns/{job_id}", timeout=30,
        )
        poll.raise_for_status()
        turn = poll.json()
        status = turn["status"]
        if status == "completed":
            return turn["snapshot"]
        if status == "failed":
            # Re-submit when is_retryable (e.g. INTERVIEW_TURN_TIMEOUT).
            raise RuntimeError(
                f"Turn failed: {turn.get('error_code')} "
                f"(retryable={turn.get('is_retryable')})")
        time.sleep(3)   # queued | running — agent reply still in flight.


# A typical interview is 5-15 turns. In practice you read the agent's
# reply from each returned snapshot and send the next message before
# completing — the call below shows the single-turn minimum.
submit_turn("I want to build a customer-feedback portal.")
print("First turn complete.")

# Then close the interview. Returns the intake_artifact_id needed
# for kickoff.
session.post(
    f"{API_URL}/v1/interviews/{interview_id}/complete",
    timeout=30,
).raise_for_status()
interview_final = session.get(
    f"{API_URL}/v1/interviews/{interview_id}", timeout=30,
).json()
intake_id = interview_final["intake_artifact_id"]
if not intake_id:
    sys.exit("Interview did not complete cleanly (no intake_artifact_id).")

# 4. POST /v1/generations. Enum values are case-sensitive strings;
#    integer ordinals still work for back-compat.
start_gen = session.post(
    f"{API_URL}/v1/generations",
    json={"intake_id": intake_id, "review_profile": "Fast"},
    timeout=30,
)
start_gen.raise_for_status()
generation_id = start_gen.json()["id"]
print(f"Generation {generation_id}")


# A mid-generation clarification pauses the run (state
# PausedAwaitingClarification). Programmatic callers fetch the questions
# and answer them here — no UI round-trip required.
def answer_clarifications():
    """Answer every pending clarification, then return. No-op when the
    generation isn't paused, so it's safe to call on every poll tick."""
    pending = session.get(
        f"{API_URL}/v1/generations/{generation_id}/clarifications",
        timeout=30,
    )
    pending.raise_for_status()
    clarifications = pending.json()["clarifications"]
    if not clarifications:
        return   # not paused, or already answered — nothing to send.
    answers = [
        {
            "question": c["question"],
            # proposedDefault is the agent's safe-but-generic fallback (and
            # may be null) — replace it with a real decision in production.
            "answer": c.get("proposedDefault") or "Use your best judgment for v1.",
        }
        for c in clarifications
    ]
    # All-or-nothing: must cover every pending clarification, and each
    # question text must match the GET response verbatim.
    session.post(
        f"{API_URL}/v1/generations/{generation_id}/clarifications/answers",
        json={"answers": answers},
        timeout=30,
    ).raise_for_status()


# 5. Poll until terminal. Honour Retry-After on a 429.
#    progress_percent is a COARSE signal — phase_detail, the ETA,
#    billing_state, and the retry fields are the higher-trust indicators
#    that make a 4-32 min paid run legible (healthy, not hung).
delay, package_id = 15.0, None
while not package_id:
    poll = session.get(f"{API_URL}/v1/generations/{generation_id}", timeout=30)
    if poll.status_code == 429:
        time.sleep(float(poll.headers.get("Retry-After", "30")))
        continue
    poll.raise_for_status()
    body = poll.json()
    state = body["state"]
    print(
        f"state={state} "
        f"phase={fmt(body.get('phase_detail'))} "
        f"progress={body.get('progress_percent', 0)}% "
        f"({fmt(body.get('progress_explanation'))}) "
        f"cost=${fmt(body.get('running_cost_usd'), '0')} "
        f"billing={fmt(body.get('billing_state'))} "
        f"eta={fmt(body.get('estimated_time_remaining_seconds'), 'estimating…')}s "
        f"complete_at={fmt(body.get('estimated_completion_at'))} "
        f"retries={body.get('retry_count', 0)} "
        f"err_cat={fmt(body.get('recoverable_error_category'))} "
        f"next_retry={fmt(body.get('next_retry_at'))} "
        f"host_resumes={body.get('host_restart_resume_count', 0)}")
    if state == "Complete":
        package_id = body.get("package_id")
        break
    if state in TERMINAL:
        sys.exit(f"Generation {state}")
    if state == "PausedAwaitingClarification":
        # Answer the agent's questions and keep polling — the orchestrator
        # resumes the run on its next dispatcher tick.
        answer_clarifications()
    time.sleep(delay)

# 6. Download the package zip. /v1/packages/{id}/zip redirects to a
#    short-lived SAS URL — requests follows the 302 automatically.
zip_path = f"{generation_id}.zip"
zip_response = session.get(
    f"{API_URL}/v1/packages/{package_id}/zip",
    timeout=120, stream=True,
)
zip_response.raise_for_status()
with open(zip_path, "wb") as out:
    for chunk in zip_response.iter_content(chunk_size=65_536):
        out.write(chunk)
print(f"Package downloaded to {zip_path}")

JavaScript / TypeScript

The script below runs in Node 18+, Deno, and Bun. Strip the type annotations for plain JavaScript.

// End-to-end SpecStep flow: interview → generation → download package.

import { writeFile } from "node:fs/promises";

const env = (globalThis as { process?: { env: Record<string, string | undefined> } })
  .process?.env ?? {};
const apiKey = env.SPECSTEP_API_KEY;
const apiUrl = env.SPECSTEP_API_URL ?? "https://specstep.com";
if (!apiKey) throw new Error("Set SPECSTEP_API_KEY (sf_...).");

const authHeaders = {
  "Authorization": `Bearer ${apiKey}`,
  "Accept": "application/json",
};
const TERMINAL = new Set(["Complete", "Failed", "Cancelled"]);
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

// Render a nullable status field. The status projection leaves
// forecast/retry fields null between phases — show a dash, not "null".
const fmt = (value: unknown, missing = "—") =>
  value === null || value === undefined ? missing : value;

// 1. Verify the key.
const me = await fetch(`${apiUrl}/v1/me`, { headers: authHeaders });
if (!me.ok) throw new Error(`GET /v1/me ${me.status}`);

// 2. POST /v1/interviews — no body. Returns interview id + opening turn.
const startInterview = await fetch(`${apiUrl}/v1/interviews`, {
  method: "POST",
  headers: { ...authHeaders, "Content-Length": "0" },
});
if (!startInterview.ok) throw new Error(`POST /v1/interviews ${startInterview.status}`);
const interview = await startInterview.json() as { id: string };
const interviewId = interview.id;
console.log(`Interview ${interviewId}`);

// 3. Submit interview turns. Turns are ASYNC: 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".
async function submitTurn(message: string): Promise<unknown> {
  // Submits one turn and blocks until the async turn job is terminal.
  // Returns the completed interview snapshot; throws on failure.
  const post = await fetch(`${apiUrl}/v1/interviews/${interviewId}/turns`, {
    method: "POST",
    // Optional: an "Idempotency-Key" header makes a retry safe — any
    // unique token, 1..128 chars of [A-Za-z0-9._:-].
    headers: { ...authHeaders, "Content-Type": "application/json" },
    body: JSON.stringify({ message }),
  });
  if (!post.ok) throw new Error(`POST .../turns ${post.status}`);
  const submitted = await post.json() as { job_id?: string | null; snapshot?: unknown };
  // Idempotency cached-replay (a retry with the same key): the original
  // result is inlined as "snapshot" — there's no job to poll.
  if (!submitted.job_id) return submitted.snapshot;
  const jobId = submitted.job_id;
  while (true) {
    const poll = await fetch(`${apiUrl}/v1/interviews/turns/${jobId}`, {
      headers: authHeaders,
    });
    if (!poll.ok) throw new Error(`GET .../turns/${jobId} ${poll.status}`);
    const turn = await poll.json() as {
      status: string; snapshot?: unknown;
      error_code?: string; is_retryable?: boolean;
    };
    if (turn.status === "completed") return turn.snapshot;
    if (turn.status === "failed") {
      // Re-submit when is_retryable (e.g. INTERVIEW_TURN_TIMEOUT).
      throw new Error(
        `Turn failed: ${turn.error_code} (retryable=${turn.is_retryable})`);
    }
    await sleep(3_000);   // queued | running — agent reply still in flight.
  }
}

// A typical interview is 5-15 turns. In practice you read the agent's
// reply from each returned snapshot and send the next message before
// completing — the call below shows the single-turn minimum.
await submitTurn("I want to build a customer-feedback portal.");
console.log("First turn complete.");

await fetch(`${apiUrl}/v1/interviews/${interviewId}/complete`, {
  method: "POST",
  headers: { ...authHeaders, "Content-Length": "0" },
}).then((r) => { if (!r.ok) throw new Error(`POST .../complete ${r.status}`); });

const interviewFinal = await fetch(`${apiUrl}/v1/interviews/${interviewId}`, {
  headers: authHeaders,
}).then((r) => r.json()) as { intake_artifact_id?: string | null };
const intakeId = interviewFinal.intake_artifact_id;
if (!intakeId) throw new Error("Interview did not complete cleanly.");

// 4. POST /v1/generations. Enum values are case-sensitive strings;
//    integer ordinals still work for back-compat.
const startGen = await fetch(`${apiUrl}/v1/generations`, {
  method: "POST",
  headers: { ...authHeaders, "Content-Type": "application/json" },
  body: JSON.stringify({ intake_id: intakeId, review_profile: "Fast" }),
});
if (!startGen.ok) throw new Error(`POST /v1/generations ${startGen.status}`);
const generation = await startGen.json() as { id: string };
console.log(`Generation ${generation.id}`);

// A mid-generation clarification pauses the run (state
// PausedAwaitingClarification). Programmatic callers fetch the questions
// and answer them here — no UI round-trip required.
async function answerClarifications(): Promise<void> {
  // Answers every pending clarification, then returns. No-op when the
  // generation isn't paused, so it's safe to call on every poll tick.
  const pending = await fetch(
    `${apiUrl}/v1/generations/${generation.id}/clarifications`,
    { headers: authHeaders });
  if (!pending.ok) throw new Error(`GET .../clarifications ${pending.status}`);
  const { clarifications } = await pending.json() as {
    clarifications: { question: string; proposedDefault: string | null }[];
  };
  if (clarifications.length === 0) return;   // not paused, or already answered.
  const answers = clarifications.map((c) => ({
    question: c.question,
    // proposedDefault is the agent's safe-but-generic fallback (and may be
    // null) — replace it with a real decision in production.
    answer: c.proposedDefault ?? "Use your best judgment for v1.",
  }));
  // All-or-nothing: must cover every pending clarification, and each
  // question text must match the GET response verbatim.
  const post = await fetch(
    `${apiUrl}/v1/generations/${generation.id}/clarifications/answers`,
    {
      method: "POST",
      headers: { ...authHeaders, "Content-Type": "application/json" },
      body: JSON.stringify({ answers }),
    });
  if (!post.ok) throw new Error(`POST .../clarifications/answers ${post.status}`);
}

// 5. Poll until terminal. Honour Retry-After on a 429.
//    progress_percent is a COARSE signal — phase_detail, the ETA,
//    billing_state, and the retry fields are the higher-trust indicators
//    that make a 4-32 min paid run legible (healthy, not hung).
let delayMs = 15_000;
let packageId: string | null = null;
while (!packageId) {
  const poll = await fetch(`${apiUrl}/v1/generations/${generation.id}`, {
    headers: authHeaders,
  });
  if (poll.status === 429) {
    const wait = Number(poll.headers.get("Retry-After") ?? "30");
    await sleep(wait * 1_000);
    continue;
  }
  if (!poll.ok) throw new Error(`GET /v1/generations/${generation.id} ${poll.status}`);
  const body = await poll.json() as {
    state: string; package_id?: string | null; progress_percent?: number;
    phase_detail?: string | null; progress_explanation?: string | null;
    running_cost_usd?: number | null; billing_state?: string | null;
    estimated_time_remaining_seconds?: number | null;
    estimated_completion_at?: string | null; retry_count?: number;
    recoverable_error_category?: string | null; next_retry_at?: string | null;
    host_restart_resume_count?: number;
  };
  console.log(
    `state=${body.state} ` +
    `phase=${fmt(body.phase_detail)} ` +
    `progress=${body.progress_percent ?? 0}% ` +
    `(${fmt(body.progress_explanation)}) ` +
    `cost=$${fmt(body.running_cost_usd, "0")} ` +
    `billing=${fmt(body.billing_state)} ` +
    `eta=${fmt(body.estimated_time_remaining_seconds, "estimating…")}s ` +
    `complete_at=${fmt(body.estimated_completion_at)} ` +
    `retries=${body.retry_count ?? 0} ` +
    `err_cat=${fmt(body.recoverable_error_category)} ` +
    `next_retry=${fmt(body.next_retry_at)} ` +
    `host_resumes=${body.host_restart_resume_count ?? 0}`);
  if (body.state === "Complete") { packageId = body.package_id ?? null; break; }
  if (TERMINAL.has(body.state)) throw new Error(`Generation ${body.state}`);
  if (body.state === "PausedAwaitingClarification") {
    // Answer the agent's questions and keep polling — the orchestrator
    // resumes the run on its next dispatcher tick.
    await answerClarifications();
  }
  await sleep(delayMs);
}

// 6. Download the package zip. /v1/packages/{id}/zip redirects to a
//    short-lived SAS URL — fetch follows the 302 automatically.
const zipResponse = await fetch(`${apiUrl}/v1/packages/${packageId}/zip`, {
  headers: authHeaders,
});
if (!zipResponse.ok) throw new Error(`GET /v1/packages/${packageId}/zip ${zipResponse.status}`);
const zipPath = `${generation.id}.zip`;
await writeFile(zipPath, new Uint8Array(await zipResponse.arrayBuffer()));
console.log(`Package downloaded to ${zipPath}`);

C#

Top-level statements + nullable enabled. Targets .NET 8 / 9.

// End-to-end SpecStep flow: interview → generation → download package.

using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

var apiKey = Environment.GetEnvironmentVariable("SPECSTEP_API_KEY")
    ?? throw new InvalidOperationException("Set SPECSTEP_API_KEY (sf_...).");
var apiUrl = Environment.GetEnvironmentVariable("SPECSTEP_API_URL") ?? "https://specstep.com";

using var http = new HttpClient { BaseAddress = new Uri(apiUrl) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

// Render a nullable status field. The status projection leaves
// forecast/retry fields null between phases — show a dash, not empty.
static string Fmt(object? value, string missing = "—") =>
    value?.ToString() ?? missing;

// 1. Verify the key.
(await http.GetAsync("/v1/me")).EnsureSuccessStatusCode();

// 2. POST /v1/interviews — no body. Returns interview id + opening turn.
using var emptyBody = new StringContent(string.Empty);
var startInterview = await http.PostAsync("/v1/interviews", emptyBody);
startInterview.EnsureSuccessStatusCode();
var interview = await startInterview.Content.ReadFromJsonAsync<Interview>()
    ?? throw new InvalidOperationException("Empty response.");
Console.WriteLine($"Interview {interview.Id}");

// 3. Submit interview turns. Turns are ASYNC: 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".
async Task<InterviewSnapshot> SubmitTurn(string message)
{
    // Submits one turn and blocks until the async turn job is terminal.
    // Returns the completed interview snapshot; throws on failure.
    var request = new HttpRequestMessage(
        HttpMethod.Post, $"/v1/interviews/{interview.Id}/turns")
    {
        Content = JsonContent.Create(new TurnRequest(message)),
    };
    // Optional: an Idempotency-Key makes a retry safe — any unique token,
    // 1..128 chars of [A-Za-z0-9._:-].
    // request.Headers.Add("Idempotency-Key", "...");
    var post = await http.SendAsync(request);
    post.EnsureSuccessStatusCode();
    var queued = await post.Content.ReadFromJsonAsync<TurnJob>()
        ?? throw new InvalidOperationException("Empty response.");
    if (queued.JobId is null)
        // Idempotency cached-replay (a retry with the same key): the original
        // result is inlined as "snapshot" — there's no job to poll.
        return queued.Snapshot
            ?? throw new InvalidOperationException("Cached replay had no snapshot.");
    while (true)
    {
        var turn = await http.GetFromJsonAsync<TurnJob>(
            $"/v1/interviews/turns/{queued.JobId}")
            ?? throw new InvalidOperationException("Empty response.");
        if (turn.Status == "completed")
            return turn.Snapshot
                ?? throw new InvalidOperationException("Completed turn had no snapshot.");
        if (turn.Status == "failed")
            // Re-submit when IsRetryable (e.g. INTERVIEW_TURN_TIMEOUT).
            throw new InvalidOperationException(
                $"Turn failed: {turn.ErrorCode} (retryable={turn.IsRetryable})");
        await Task.Delay(TimeSpan.FromSeconds(3));   // queued | running.
    }
}

// A typical interview is 5-15 turns. In practice you read the agent's
// reply from each returned snapshot and send the next message before
// completing — the call below shows the single-turn minimum.
await SubmitTurn("I want to build a customer-feedback portal.");
Console.WriteLine("First turn complete.");

using var completeBody = new StringContent(string.Empty);
(await http.PostAsync($"/v1/interviews/{interview.Id}/complete", completeBody))
    .EnsureSuccessStatusCode();
var interviewFinal = await http
    .GetFromJsonAsync<Interview>($"/v1/interviews/{interview.Id}")
    ?? throw new InvalidOperationException("Empty response.");
var intakeId = interviewFinal.IntakeArtifactId
    ?? throw new InvalidOperationException("Interview did not complete cleanly.");

// 4. POST /v1/generations. Enum values are case-sensitive strings;
//    integer ordinals still work for back-compat.
var startGen = await http.PostAsJsonAsync(
    "/v1/generations",
    new StartGenerationRequest(intakeId, "Fast"));
startGen.EnsureSuccessStatusCode();
var generation = await startGen.Content.ReadFromJsonAsync<GenerationStatus>()
    ?? throw new InvalidOperationException("Empty response.");
Console.WriteLine($"Generation {generation.Id}");

// A mid-generation clarification pauses the run (state
// PausedAwaitingClarification). Programmatic callers fetch the questions
// and answer them here — no UI round-trip required.
async Task AnswerClarifications()
{
    // Answers every pending clarification, then returns. No-op when the
    // generation isn't paused, so it's safe to call on every poll tick.
    var pending = await http.GetFromJsonAsync<ClarificationsResponse>(
        $"/v1/generations/{generation.Id}/clarifications")
        ?? throw new InvalidOperationException("Empty response.");
    if (pending.Clarifications.Length == 0)
        return;   // not paused, or already answered — nothing to send.
    var answers = Array.ConvertAll(pending.Clarifications, c =>
        // proposedDefault is the agent's safe-but-generic fallback (and may
        // be null) — replace it with a real decision in production.
        new ClarificationAnswer(c.Question, c.ProposedDefault ?? "Use your best judgment for v1."));
    // All-or-nothing: must cover every pending clarification, and each
    // question text must match the GET response verbatim.
    (await http.PostAsJsonAsync(
        $"/v1/generations/{generation.Id}/clarifications/answers",
        new ClarificationAnswers(answers)))
        .EnsureSuccessStatusCode();
}

// 5. Poll until terminal. Honour Retry-After on a 429.
//    ProgressPercent is a COARSE signal — PhaseDetail, the ETA,
//    BillingState, and the retry fields are the higher-trust indicators
//    that make a 4-32 min paid run legible (healthy, not hung).
var delay = TimeSpan.FromSeconds(15);
Guid? packageId = null;
while (packageId is null)
{
    using var poll = await http.GetAsync($"/v1/generations/{generation.Id}");
    if (poll.StatusCode == HttpStatusCode.TooManyRequests)
    {
        await Task.Delay(poll.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(30));
        continue;
    }
    poll.EnsureSuccessStatusCode();
    var body = await poll.Content.ReadFromJsonAsync<GenerationStatus>()
        ?? throw new InvalidOperationException("Empty response.");
    Console.WriteLine(
        $"state={body.State} " +
        $"phase={Fmt(body.PhaseDetail)} " +
        $"progress={body.ProgressPercent ?? 0}% " +
        $"({Fmt(body.ProgressExplanation)}) " +
        $"cost=${Fmt(body.RunningCostUsd, "0")} " +
        $"billing={Fmt(body.BillingState)} " +
        $"eta={Fmt(body.EstimatedTimeRemainingSeconds, "estimating…")}s " +
        $"complete_at={Fmt(body.EstimatedCompletionAt)} " +
        $"retries={body.RetryCount} " +
        $"err_cat={Fmt(body.RecoverableErrorCategory)} " +
        $"next_retry={Fmt(body.NextRetryAt)} " +
        $"host_resumes={body.HostRestartResumeCount}");
    if (body.State == "Complete") { packageId = body.PackageId; break; }
    if (body.State is "Failed" or "Cancelled")
        throw new InvalidOperationException($"Generation {body.State}");
    if (body.State == "PausedAwaitingClarification")
        // Answer the agent's questions and keep polling — the orchestrator
        // resumes the run on its next dispatcher tick.
        await AnswerClarifications();
    await Task.Delay(delay);
}

// 6. Download the package zip. /v1/packages/{id}/zip redirects to a
//    short-lived SAS URL — HttpClient follows the 302 automatically.
using var zipResponse = await http.GetAsync(
    $"/v1/packages/{packageId}/zip", HttpCompletionOption.ResponseHeadersRead);
zipResponse.EnsureSuccessStatusCode();
var zipPath = $"{generation.Id}.zip";
await using (var file = File.Create(zipPath))
{
    await zipResponse.Content.CopyToAsync(file);
}
Console.WriteLine($"Package downloaded to {zipPath}");

internal sealed record TurnRequest(
    [property: JsonPropertyName("message")] string Message);

internal sealed record TurnJob(
    [property: JsonPropertyName("status")] string Status,
    // Nullable: the async POST returns job_id=null on an idempotency
    // cached-replay (result inlined in snapshot); the GET job-status
    // always carries a non-null job_id.
    [property: JsonPropertyName("job_id")] Guid? JobId,
    [property: JsonPropertyName("interview_id")] Guid InterviewId,
    [property: JsonPropertyName("snapshot")] InterviewSnapshot? Snapshot,
    [property: JsonPropertyName("error_code")] string? ErrorCode,
    [property: JsonPropertyName("error_message")] string? ErrorMessage,
    [property: JsonPropertyName("is_retryable")] bool? IsRetryable);

internal sealed record InterviewSnapshot(
    [property: JsonPropertyName("id")] Guid Id,
    [property: JsonPropertyName("status")] string Status);

internal sealed record ClarificationsResponse(
    [property: JsonPropertyName("state")] string State,
    [property: JsonPropertyName("clarifications")] Clarification[] Clarifications);

internal sealed record Clarification(
    [property: JsonPropertyName("agent")] string Agent,
    [property: JsonPropertyName("section")] string? Section,
    [property: JsonPropertyName("question")] string Question,
    [property: JsonPropertyName("why")] string? Why,
    [property: JsonPropertyName("proposedDefault")] string? ProposedDefault);

internal sealed record ClarificationAnswers(
    [property: JsonPropertyName("answers")] ClarificationAnswer[] Answers);

internal sealed record ClarificationAnswer(
    [property: JsonPropertyName("question")] string Question,
    [property: JsonPropertyName("answer")] string Answer);

internal sealed record StartGenerationRequest(
    [property: JsonPropertyName("intake_id")] Guid IntakeId,
    [property: JsonPropertyName("review_profile")] string ReviewProfile);

internal sealed record Interview(
    [property: JsonPropertyName("id")] Guid Id,
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("intake_artifact_id")] Guid? IntakeArtifactId);

internal sealed record GenerationStatus(
    [property: JsonPropertyName("id")] Guid Id,
    [property: JsonPropertyName("state")] string State,
    [property: JsonPropertyName("package_id")] Guid? PackageId,
    [property: JsonPropertyName("progress_percent")] int? ProgressPercent,
    [property: JsonPropertyName("phase_detail")] string? PhaseDetail,
    [property: JsonPropertyName("progress_explanation")] string? ProgressExplanation,
    [property: JsonPropertyName("running_cost_usd")] decimal? RunningCostUsd,
    [property: JsonPropertyName("billing_state")] string? BillingState,
    [property: JsonPropertyName("estimated_time_remaining_seconds")] decimal? EstimatedTimeRemainingSeconds,
    [property: JsonPropertyName("estimated_completion_at")] DateTimeOffset? EstimatedCompletionAt,
    [property: JsonPropertyName("retry_count")] int RetryCount,
    [property: JsonPropertyName("recoverable_error_category")] string? RecoverableErrorCategory,
    [property: JsonPropertyName("next_retry_at")] DateTimeOffset? NextRetryAt,
    [property: JsonPropertyName("host_restart_resume_count")] int HostRestartResumeCount);