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);