Mock Interview Room - API

A mock job interviewer, from your own code.

Run a mock job interview from your own code

Mock Interview Room is an interviewer for one job: given the job post (and optionally a résumé) and a small competency map, it asks one question at a time, follows up when an answer has a gap, keeps a private note on every answer, and ends with a scored debrief per competency that quotes the candidate's own words.

Each interviewer turn is one call to the SkillSafe App API: a flat JSON object in over HTTPS, JSON Lines out. Pick a language once and every code sample on the page follows.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every call except POST /guest sends Authorization: Bearer <token>; bodies are JSON with Content-Type: application/json. The token is bound to this app, so there is no app or slug header: the slug appears only in the POST /guest body.

Responses use one envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code","message","details"}} on failure. /estimate, /run and /run-stream take the input object itself as the body. Never wrap it as {"input":{...}}: the server only warns unknown field 'input', and the run is still charged.

Credits: 1 credit = $0.0001. /estimate is free and creates no job; /run and /run-stream hold up to hold_credits and charge charged_credits when the job finishes.

StatusTypical error.codeMeaning
400validation_errorThe body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field.
401unauthorizedInvalid or expired app session. Get a new token (step 1).
402payment_requiredInsufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token.
403forbiddenThe token may not do this. A guest token can run metered turns only while this app sponsors guests; when it does not, sign in for a personal token.
404not_foundUnknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token.
409conflictThe Idempotency-Key was already used with a different body.
429rate_limitedRate limit. Back off and retry with the same Idempotency-Key.
5xx-Transient platform error. Retry with backoff and the same Idempotency-Key.

Branch on the HTTP status and error.code, and show error.message to a person; the exact codes and wording are the platform's and can gain detail over time.

Browsers enforce CORS for this API, so run the samples from a server, script or terminal, not from another website's frontend.

The task field first

task decides which turn comes back, and each task has exactly one reply contract. If task is missing or unknown, the interviewer picks the closest one from the fields present and marks its first line "routed":"fallback".

taskWhenReply lines
openThe first interviewer turn: a short greeting naming the role, then question 1.turn (kind question), one plan per competency, end.
answerThe candidate answered (or skipped, skipped 1) last_question.note (private judgement of that answer), turn (kind question, followup, or close once question_index is past questions_total), end.
debriefThe interview is over (or ended early).summary, one score per competency in map order, 1-3 strength, 1-3 gap, one rewrite, 1-3 practice, end.

Input fields

Every field is a scalar. The competency map and the debrief facts are JSON-encoded inside string fields. Send every field the task uses; empty string or 0 when it does not apply.

FieldTypeMeaning
taskstringopen | answer | debrief.
job_poststringThe job advert. The app keeps up to 6,000 characters: the opening and closing parts, with the middle replaced by [... N characters omitted ...].
resumestringThe candidate's résumé as text, or empty. Up to 5,000 characters, same marker.
interview_typestringmixed | behavioral | skills | resume.
levelstringentry | mid | senior.
competencies_jsonstring (JSON array)The competency map, e.g. "[{\"id\":\"c1\",\"label\":\"Write SQL\"}]". Up to 6; these ids are the only ones that exist.
question_indexnumberThe number of the question about to be asked: 1 for open, then one more than the questions asked so far.
questions_totalnumberHow many questions the interview has (the app offers 4, 6 or 8).
transcriptstringThe interview so far, one line per entry: Q2 INTERVIEWER [c3] question: ..., A2 CANDIDATE (85 s): ..., A2 CANDIDATE skipped. See How an interview works.
last_questionstringanswer: the interviewer's most recent question, verbatim.
last_competencystringanswer: the competency id that question targeted.
answerstringanswer: the candidate's answer (up to 3,000 characters), empty when skipped.
answer_secondsnumberanswer: how long the candidate took.
skippednumberanswer: 1 when the candidate skipped the question, else 0.
facts_jsonstring (JSON object)debrief: what you measured - per competency the number of questions, skips and answer words; questions asked, answers, skips and total seconds.
clip_notestringWhat was cut before sending, in plain words; empty when nothing was.
retry_notestringOptional: a correction after a malformed reply. The app sends it once, on a new Idempotency-Key attempt.

How an interview works

There is no server-side session. Each interviewer turn is ONE stateless /run or /run-stream call. Your client owns the job post, the competency map and the transcript, and re-sends what the interviewer needs every turn.

  1. Build the competency map (ids c1... with a label) from the job post's duties and requirements, plus a behaviour competency or two.
  2. Send open with question_index 1. Show the turn's say.
  3. On each answer, send answer with the transcript window, last_question, last_competency, the answer and the next question_index. Keep the note line private until the candidate asks; show the turn.
  4. When the reply's kind is close (or the candidate wants to stop), send debrief with the FULL transcript and facts_json.

For answer turns the app sends a window: the opening exchange plus the last six lines, with a marker for the middle. For debrief it sends every line, shortening long answers to keep the whole transcript under about 14,000 characters.

Q1 INTERVIEWER [c1] question: Walk me through a recent piece of analysis where you wrote the SQL yourself.
A1 CANDIDATE (95 s): At Bramble Outdoor the marketing lead asked which email campaigns brought back lapsed customers...
[... 2 lines omitted ...]
Q3 INTERVIEWER [c2] followup: How would you know two weeks is enough - what would you need to work out before launching?
A3 CANDIDATE skipped

ss.sessions turns are not used: a session turn cannot be priced with the free /estimate and takes no Idempotency-Key. The app keys every attempt mock-interview-room:<task>:t<question_index>:<hash>:a<attempt>.

Reply contract

JSON Lines: one object per line, each with a "k" field, and the last line always {"k":"end"}. A reply without it was cut short - keep the lines that did arrive. Ignore any line that is not a JSON object with k.

kFieldsIn
turntask, kind: question | followup | close, competency (a map id; empty on close), say, optional routed: "fallback".open, answer.
plancompetency, angle (what will be probed).open, one per competency.
notecompetency, signal: strong | solid | thin | missing, evidence (copied from the answer), tip.answer.
summaryverdict: strong hire | hire | lean hire | lean no hire | no hire, overall (1-5), say.debrief.
scorecompetency, score 0-5 (0 = not covered), evidence, why, improve.debrief, one per competency.
strength, gap, practicetext.debrief, 1-3 each.
rewritequestion (Q2), competency, outline (a 3-5 step stronger answer).debrief.

Check the reply against what you know: every competency should be on your map, every evidence quote should appear in the candidate's answers, and a competency no question targeted should score 0. The app shows a note for each mismatch. Replies are AI-generated practice feedback, not a hiring decision.

Step 1 - A tiny client, and a token

POST /guest

Start with a short helper that adds the auth header, sends JSON and unwraps the envelope; every later step reuses it. For the token, open the token page, sign in with SkillSafe and copy the token (or the shell export line). A personal token bills metered runs to your account. Scripts with no browser at all can mint a guest token with POST /guest and the body {"slug":"mock-interview-room"}, sent with no auth header; it answers 201 with {token, guest_id, expires_at}. There is no app header on any later call.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"   # "Copy shell export" on /tokens.html sets this for you

# No browser at all? Mint a guest token (this one call sends no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"mock-interview-room"}' | jq -r '.data.token')
# 201 -> {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}

# Every later call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-H "Content-Type: application/json" -d @body.json]
# and jq reads fields out of the {"ok":true,"data":{...}} envelope.
import json, os, time, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")  # copy it from tokens.html

def api(method, path, body=None, headers=None, auth=True):
    """Send JSON (a dict, or an already-encoded string), unwrap the envelope."""
    h = {"Content-Type": "application/json", **(headers or {})}
    if auth and TOKEN:
        h["Authorization"] = f"Bearer {TOKEN}"
    data = body if isinstance(body, str) or body is None else json.dumps(body)
    res = requests.request(method, API + path, data=data, headers=h, timeout=180)
    payload = res.json()
    if not res.ok or payload.get("ok") is False:
        err = payload.get("error", {})
        raise RuntimeError(f'{res.status_code} {err.get("code")}: {err.get("message")}')
    return payload["data"]

# No browser at all? Mint a guest token (no Authorization header on this call).
if not TOKEN:
    TOKEN = api("POST", "/guest", {"slug": "mock-interview-room"}, auth=False)["token"]
// Node 18+ (built-in fetch), run as an ES module (top-level await)
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from tokens.html, or load it from your secret store

async function api(method, path, body, extraHeaders = {}, auth = true) {
  const headers = { "Content-Type": "application/json", ...extraHeaders };
  if (auth) headers.Authorization = `Bearer ${TOKEN}`;
  const res = await fetch(API + path, {
    method,
    headers,
    body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || json.ok === false) {
    const e = json.error ?? {};
    throw Object.assign(new Error(`${res.status} ${e.code}: ${e.message ?? res.statusText}`),
      { status: res.status, code: e.code, details: e.details });
  }
  return json.data;
}

// No browser at all? Mint a guest token (no Authorization header on this call).
if (TOKEN === "YOUR_TOKEN") {
  ({ token: TOKEN } = await api("POST", "/guest", { slug: "mock-interview-room" }, {}, false));
}
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // copy it from tokens.html

type envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

// call sends body (any value, or json.RawMessage for pre-encoded bytes) and
// decodes the envelope's data into out.
func call(method, path string, body any, headers map[string]string, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env envelope
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 || env.Error != nil {
		if env.Error != nil {
			return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
		}
		return fmt.Errorf("HTTP %d", res.StatusCode)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}

// No browser at all? Mint a guest token (sent while token is still empty, so no auth header).
func ensureToken() {
	if token != "" {
		return
	}
	var g struct{ Token string `json:"token"` }
	if err := call("POST", "/guest", map[string]string{"slug": "mock-interview-room"}, nil, &g); err != nil {
		log.Fatal(err)
	}
	token = g.Token
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class MockInterview {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient HTTP = HttpClient.newHttpClient();
    static String token = System.getenv("SKILLSAFE_TOKEN"); // copy it from tokens.html

    /** body: any Jackson-serialisable value, or an already-encoded JSON String. */
    static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(API + path))
            .header("Content-Type", "application/json")
            .method(method, body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body instanceof String s ? s : JSON.writeValueAsString(body)));
        if (token != null) b.header("Authorization", "Bearer " + token);
        headers.forEach(b::header);
        var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        JsonNode env = JSON.readTree(res.body());
        if (res.statusCode() >= 400 || !env.path("ok").asBoolean(true))
            throw new RuntimeException(res.statusCode() + " " + env.path("error").path("code").asText()
                + ": " + env.path("error").path("message").asText());
        return env.get("data");
    }

    // No browser at all? Mint a guest token (token is null, so no auth header is sent).
    static void ensureToken() throws Exception {
        if (token == null) token = api("POST", "/guest", Map.of("slug", "mock-interview-room"), Map.of()).get("token").asText();
    }

    // Steps 2-5 below go in main(); the static helpers they define sit beside api().
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV["SKILLSAFE_TOKEN"] # copy it from tokens.html

# body: a Hash, or an already-encoded JSON String
def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{$token}" if $token
  headers.each { |k, v| req[k] = v }
  req.body = body.is_a?(String) ? body : body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
  payload = (JSON.parse(res.body) rescue {})
  unless res.is_a?(Net::HTTPSuccess) && payload["ok"] != false
    err = payload["error"] || {}
    raise "#{res.code} #{err["code"]}: #{err["message"] || res.message}"
  end
  payload["data"]
end

# No browser at all? Mint a guest token ($token is nil, so no auth header is sent).
$token ||= api("POST", "/guest", { slug: "mock-interview-room" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: null; // copy it from tokens.html

// $body: an array, or an already-encoded JSON string
function api(string $method, string $path, $body = null, array $headers = []): array {
    global $TOKEN;
    $h = ["Content-Type: application/json"];
    if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
    foreach ($headers as $k => $v) $h[] = "$k: $v";
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $h,
        CURLOPT_TIMEOUT        => 180,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS,
            is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    }
    $payload = json_decode(curl_exec($ch), true) ?? [];
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400 || ($payload["ok"] ?? true) === false) {
        $e = $payload["error"] ?? [];
        throw new Exception("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? "HTTP error"));
    }
    return $payload["data"];
}

// No browser at all? Mint a guest token ($TOKEN is null, so no auth header is sent).
$TOKEN = $TOKEN ?: api("POST", "/guest", ["slug" => "mock-interview-room"])["token"];
// .NET 8+, Program.cs. Top-level statements come first; the helper class goes at the end of the file.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// No browser at all? Mint a guest token (Token is null, so no auth header is sent).
MockInterview.Token ??= (await MockInterview.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "mock-interview-room" })).GetProperty("token").GetString();

// ... steps 2-5 go here ...

static class MockInterview
{
    public const string Api = "https://api.skillsafe.ai/v1/app-api";
    public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
    public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"); // copy it from tokens.html

    // body: any serialisable object, or an already-encoded JSON string
    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
        Dictionary<string, string>? headers = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (Token != null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
        if (body != null)
            req.Content = new StringContent(body as string ?? JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!res.IsSuccessStatusCode)
        {
            var e = json.GetProperty("error");
            throw new Exception($"{(int)res.StatusCode} {e.GetProperty("code")}: {e.GetProperty("message")}");
        }
        return json.GetProperty("data");
    }
}

Treat a token like a password: it can spend its credits. A guest token only runs metered turns while the app sponsors guests (a 403 otherwise); a signed-in token always can.

Step 2 - Who am I, and what is my balance?

GET /me

Returns subject_type ("user" for a personal token, "guest" for a guest token), subject_id and credits. Branch on subject_type. A 401 here means the token is stale.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"guest","subject_id":"gst_...","credits":2000}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits",
      f'(${me["credits"] * 0.0001:.2f})')
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, `${me.credits} credits ($${(me.credits * 0.0001).toFixed(2)})`);
// in main():
ensureToken()
var me struct {
	SubjectType string `json:"subject_type"` // "user" or "guest"
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s: %d credits ($%.2f)\n", me.SubjectType, me.SubjectID, me.Credits, float64(me.Credits)*0.0001)
ensureToken();
JsonNode me = api("GET", "/me", null, Map.of());
System.out.printf("%s %s: %d credits%n",
    me.get("subject_type").asText(), me.get("subject_id").asText(), me.get("credits").asLong());
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await MockInterview.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");

Step 3 - Price the first question for free

POST /estimate

/estimate takes the same body as a run and creates no job. It returns hold_credits (reserved while the run is in flight), min_credits and the model binding. A run is charged charged_credits, usually far less than the hold. warnings names unknown or mistyped fields.

# The competency map lives on YOUR side. competencies_json must be a STRING, so jq's tojson encodes it.
jq -n '{
  task: "open",
  job_post: "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders",
  resume: "",
  interview_type: "mixed", level: "mid",
  competencies_json: ([{id:"c1",label:"Write SQL"},{id:"c2",label:"Design and read A/B tests"},{id:"c3",label:"Explain results"},{id:"c4",label:"Communication"}] | tojson),
  question_index: 1, questions_total: 4, clip_note: ""
}' > open.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @open.json | jq '.data | {hold_credits, min_credits, model_alias, input_checked, warnings}'
competencies = [{"id": "c1", "label": "Write SQL"}, {"id": "c2", "label": "Design and read A/B tests"},
                {"id": "c3", "label": "Explain results"}, {"id": "c4", "label": "Communication"}]
job_post = ("Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n"
            "- Strong SQL, including window functions\n- Design and read A/B tests\n"
            "- Explain results to non-technical stakeholders")
open_input = {
    "task": "open", "job_post": job_post, "resume": "",
    "interview_type": "mixed", "level": "mid",
    "competencies_json": json.dumps(competencies),  # a STRING, not an array
    "question_index": 1, "questions_total": 4, "clip_note": "",
}
est = api("POST", "/estimate", open_input)
print(est["hold_credits"], est["min_credits"], est["model_alias"], est.get("warnings"))
const competencies = [{ id: "c1", label: "Write SQL" }, { id: "c2", label: "Design and read A/B tests" },
  { id: "c3", label: "Explain results" }, { id: "c4", label: "Communication" }];
const jobPost = "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n" +
  "- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders";
const openInput = {
  task: "open", job_post: jobPost, resume: "",
  interview_type: "mixed", level: "mid",
  competencies_json: JSON.stringify(competencies), // a STRING, not an array
  question_index: 1, questions_total: 4, clip_note: "",
};
const est = await api("POST", "/estimate", openInput);
console.log(est.hold_credits, est.min_credits, est.model_alias, est.warnings);
// at package level:
var competencies = []map[string]string{{"id": "c1", "label": "Write SQL"}, {"id": "c2", "label": "Design and read A/B tests"},
	{"id": "c3", "label": "Explain results"}, {"id": "c4", "label": "Communication"}}

func openInput() map[string]any {
	comps, _ := json.Marshal(competencies) // competencies_json is a STRING
	return map[string]any{
		"task": "open", "job_post": "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders",
		"resume": "", "interview_type": "mixed", "level": "mid", "competencies_json": string(comps),
		"question_index": 1, "questions_total": 4, "clip_note": "",
	}
}

// in main():
ensureToken()
var est struct {
	Hold     int      `json:"hold_credits"`
	Min      int      `json:"min_credits"`
	Alias    string   `json:"model_alias"`
	Warnings []string `json:"warnings"`
}
if err := call("POST", "/estimate", openInput(), nil, &est); err != nil {
	log.Fatal(err)
}
fmt.Println(est.Hold, est.Min, est.Alias, est.Warnings)
static ObjectNode openInput() throws Exception {
    var comps = List.of(Map.of("id", "c1", "label", "Write SQL"), Map.of("id", "c2", "label", "Design and read A/B tests"),
        Map.of("id", "c3", "label", "Explain results"), Map.of("id", "c4", "label", "Communication"));
    ObjectNode in = JSON.createObjectNode();
    in.put("task", "open");
    in.put("job_post", "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n"
        + "- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders");
    in.put("resume", "");
    in.put("interview_type", "mixed");
    in.put("level", "mid");
    in.put("competencies_json", JSON.writeValueAsString(comps)); // a STRING, not an array
    in.put("question_index", 1);
    in.put("questions_total", 4);
    in.put("clip_note", "");
    return in;
}

// in main():
ensureToken();
JsonNode est = api("POST", "/estimate", openInput(), Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model_alias"));
competencies = [{ id: "c1", label: "Write SQL" }, { id: "c2", label: "Design and read A/B tests" },
                { id: "c3", label: "Explain results" }, { id: "c4", label: "Communication" }]
open_input = {
  task: "open",
  job_post: "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n" \
            "- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders",
  resume: "", interview_type: "mixed", level: "mid",
  competencies_json: competencies.to_json, # a STRING, not an array
  question_index: 1, questions_total: 4, clip_note: ""
}
est = api("POST", "/estimate", open_input)
puts est.values_at("hold_credits", "min_credits", "model_alias").inspect
$competencies = [["id" => "c1", "label" => "Write SQL"], ["id" => "c2", "label" => "Design and read A/B tests"],
                 ["id" => "c3", "label" => "Explain results"], ["id" => "c4", "label" => "Communication"]];
$openInput = [
    "task" => "open",
    "job_post" => "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n"
        . "- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders",
    "resume" => "", "interview_type" => "mixed", "level" => "mid",
    "competencies_json" => json_encode($competencies), // a STRING, not an array
    "question_index" => 1, "questions_total" => 4, "clip_note" => "",
];
$est = api("POST", "/estimate", $openInput);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], PHP_EOL;
var competencies = new[] { new { id = "c1", label = "Write SQL" }, new { id = "c2", label = "Design and read A/B tests" },
    new { id = "c3", label = "Explain results" }, new { id = "c4", label = "Communication" } };
var openInput = new Dictionary<string, object>
{
    ["task"] = "open",
    ["job_post"] = "Data Analyst - Growth\nWe run pricing experiments every week.\nRequirements:\n"
        + "- Strong SQL, including window functions\n- Design and read A/B tests\n- Explain results to non-technical stakeholders",
    ["resume"] = "", ["interview_type"] = "mixed", ["level"] = "mid",
    ["competencies_json"] = JsonSerializer.Serialize(competencies), // a STRING, not an array
    ["question_index"] = 1, ["questions_total"] = 4, ["clip_note"] = "",
};
var est = await MockInterview.ApiAsync(HttpMethod.Post, "/estimate", openInput);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("min_credits")} {est.GetProperty("model_alias")}");

Step 4 - Ask the first question

POST /run   GET /jobs/{job_id}

Send the body with an Idempotency-Key; a retry with the same key and body never bills twice. Poll the job until it is succeeded or failed, then read the JSON Lines from output.output.

BODY=$(jq -c . open.json)
# The app hashes the body with FNV-1a; from a shell any stable hash of the exact body works.
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
KEY="mock-interview-room:open:t1:$HASH:a1"   # <slug>:<task>:t<question_index>:<hash>:a<attempt>

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done
echo "$JOB" | jq '.data | {status, charged_credits, truncated}'

# The reply is JSON Lines: keep every line that parses as an object with "k".
echo "$JOB" | jq -r '.data.output.output' | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply.jsonl
jq -r 'select(.k == "turn") | "Interviewer [\(.competency)]: \(.say)"' reply.jsonl
tail -n 1 reply.jsonl   # {"k":"end"} - anything else means the reply was cut short
import hashlib

def idem_key(task, body, attempt=1):
    h = hashlib.sha1(body.encode()).hexdigest()[:8]   # any stable hash of the exact body
    return f"mock-interview-room:{task}:t{json.loads(body)['question_index']}:{h}:a{attempt}"

def jsonl(text):
    rows = []
    for line in (text or "").splitlines():
        try:
            o = json.loads(line)
            if isinstance(o, dict) and "k" in o:
                rows.append(o)
        except ValueError:
            pass   # prose or a code fence - ignore it
    return rows

body = json.dumps(open_input)
job = api("POST", "/run", body, {"Idempotency-Key": idem_key("open", body)})
while job.get("status") not in ("succeeded", "failed"):
    time.sleep(2)
    job = api("GET", f"/jobs/{job['job_id']}")
print(job["status"], job.get("charged_credits"), job.get("truncated"))

rows = jsonl(job["output"]["output"])
turn = next(r for r in rows if r["k"] == "turn")
print("Interviewer:", turn["say"])
import { createHash } from "node:crypto";

function idemKey(task, body, attempt = 1) {
  const h = createHash("sha1").update(body).digest("hex").slice(0, 8); // any stable hash of the exact body
  return `mock-interview-room:${task}:t${JSON.parse(body).question_index}:${h}:a${attempt}`;
}
const jsonl = (text) => String(text || "").split("\n").flatMap((l) => {
  try { const o = JSON.parse(l); return o && typeof o === "object" && "k" in o ? [o] : []; } catch { return []; }
});

const body = JSON.stringify(openInput);
let job = await api("POST", "/run", body, { "Idempotency-Key": idemKey("open", body) });
while (!["succeeded", "failed"].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 2000));
  job = await api("GET", `/jobs/${job.job_id}`);
}
const rows = jsonl(job.output.output);
const turn = rows.find((r) => r.k === "turn");
console.log(job.charged_credits, "Interviewer:", turn.say);
// at package level:
func idemKey(task string, body []byte, qi int, attempt int) string {
	h := fnv.New32a()
	h.Write(body) // any stable hash of the exact body
	return fmt.Sprintf("mock-interview-room:%s:t%d:%08x:a%d", task, qi, h.Sum32(), attempt)
}

func jsonl(text string) []map[string]any {
	var rows []map[string]any
	for _, line := range strings.Split(text, "\n") {
		var o map[string]any
		if json.Unmarshal([]byte(strings.TrimSpace(line)), &o) == nil && o["k"] != nil {
			rows = append(rows, o)
		}
	}
	return rows
}

type job struct {
	JobID   string `json:"job_id"`
	Status  string `json:"status"`
	Charged int    `json:"charged_credits"`
	Output  struct {
		Output string `json:"output"`
	} `json:"output"`
}

// in main():
body, _ := json.Marshal(openInput())
var j job
if err := call("POST", "/run", json.RawMessage(body), map[string]string{"Idempotency-Key": idemKey("open", body, 1, 1)}, &j); err != nil {
	log.Fatal(err)
}
for j.Status != "succeeded" && j.Status != "failed" {
	time.Sleep(2 * time.Second)
	call("GET", "/jobs/"+j.JobID, nil, nil, &j)
}
for _, r := range jsonl(j.Output.Output) {
	if r["k"] == "turn" {
		fmt.Println("Interviewer:", r["say"])
	}
}
static String idemKey(String task, String body, int qi, int attempt) throws Exception {
    var h = java.security.MessageDigest.getInstance("SHA-1").digest(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));
    return "mock-interview-room:" + task + ":t" + qi + ":" + java.util.HexFormat.of().formatHex(h).substring(0, 8) + ":a" + attempt;
}

static List<JsonNode> jsonl(String text) {
    var rows = new ArrayList<JsonNode>();
    for (String line : text.split("\n")) {
        try { JsonNode o = JSON.readTree(line); if (o != null && o.has("k")) rows.add(o); } catch (Exception ignored) { }
    }
    return rows;
}

// in main():
String body = JSON.writeValueAsString(openInput());
JsonNode job = api("POST", "/run", body, Map.of("Idempotency-Key", idemKey("open", body, 1, 1)));
while (!job.path("status").asText().matches("succeeded|failed")) {
    Thread.sleep(2000);
    job = api("GET", "/jobs/" + job.get("job_id").asText(), null, Map.of());
}
for (JsonNode r : jsonl(job.path("output").path("output").asText()))
    if (r.get("k").asText().equals("turn")) System.out.println("Interviewer: " + r.get("say").asText());
require "digest"

def idem_key(task, body, attempt = 1)
  "mock-interview-room:#{task}:t#{JSON.parse(body)["question_index"]}:#{Digest::SHA1.hexdigest(body)[0, 8]}:a#{attempt}"
end

def jsonl(text)
  text.to_s.lines.filter_map { |l| o = (JSON.parse(l) rescue nil); o if o.is_a?(Hash) && o.key?("k") }
end

body = open_input.to_json
job = api("POST", "/run", body, { "Idempotency-Key" => idem_key("open", body) })
until %w[succeeded failed].include?(job["status"])
  sleep 2
  job = api("GET", "/jobs/#{job["job_id"]}")
end
turn = jsonl(job.dig("output", "output")).find { |r| r["k"] == "turn" }
puts "Interviewer: #{turn["say"]}"
function idem_key(string $task, string $body, int $attempt = 1): string {
    $qi = json_decode($body, true)["question_index"];
    return "mock-interview-room:$task:t$qi:" . substr(sha1($body), 0, 8) . ":a$attempt";
}
function jsonl(?string $text): array {
    $rows = [];
    foreach (explode("\n", (string)$text) as $line) {
        $o = json_decode(trim($line), true);
        if (is_array($o) && isset($o["k"])) $rows[] = $o;
    }
    return $rows;
}

$body = json_encode($openInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$job = api("POST", "/run", $body, ["Idempotency-Key" => idem_key("open", $body)]);
while (!in_array($job["status"] ?? "", ["succeeded", "failed"])) {
    sleep(2);
    $job = api("GET", "/jobs/" . $job["job_id"]);
}
foreach (jsonl($job["output"]["output"] ?? "") as $r)
    if ($r["k"] === "turn") echo "Interviewer: ", $r["say"], PHP_EOL;
static string IdemKey(string task, string body, int qi, int attempt = 1)
{
    var h = Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
    return $"mock-interview-room:{task}:t{qi}:{h[..8]}:a{attempt}";
}
static List<JsonElement> Jsonl(string text) => text.Split('\n').Select(l =>
{
    try { var o = JsonDocument.Parse(l).RootElement; return o.ValueKind == JsonValueKind.Object && o.TryGetProperty("k", out _) ? (JsonElement?)o : null; }
    catch (JsonException) { return null; }
}).Where(o => o != null).Select(o => o!.Value).ToList();

// top-level:
var body = JsonSerializer.Serialize(openInput);
var job = await MockInterview.ApiAsync(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = IdemKey("open", body, 1) });
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"))
{
    await Task.Delay(2000);
    job = await MockInterview.ApiAsync(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
var turn = Jsonl(job.GetProperty("output").GetProperty("output").GetString()!).First(r => r.GetProperty("k").GetString() == "turn");
Console.WriteLine("Interviewer: " + turn.GetProperty("say").GetString());

Step 5 - Answer, and stream the next turn

POST /run-stream

The same body over Server-Sent Events: job, then tick heartbeats (and delta frames for some clients), then one done event carrying the whole reply. Read the reply from done. An idempotent replay answers with plain JSON instead of a stream, so check the content type first.

# Question 2: the candidate answers. Every field is re-sent; the server keeps nothing between turns.
TURN=$(jq -c 'select(.k == "turn")' reply.jsonl)
jq -c --argjson t "$TURN" '. + {
  task: "answer",
  transcript: ("Q1 INTERVIEWER [" + $t.competency + "] question: " + $t.say),
  last_question: $t.say, last_competency: $t.competency,
  answer: "At my last job I used ROW_NUMBER over each customer'"'"'s orders to find lapsed buyers, then counted who came back within 14 days of each email.",
  answer_seconds: 75, skipped: 0, question_index: 2
}' open.json > answer.json

BODY=$(cat answer.json)
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: mock-interview-room:answer:t2:$HASH:a1" \
  -d "$BODY" | tee stream.txt
# event: job    data: {"job_id":"job_..."}
# event: tick   data: {"t":4}          (a browser sees ticks; deltas only from some clients)
# event: done   data: {"status":"succeeded","charged_credits":...,"output":{"output":"...JSON Lines..."}}

# Parse the reply from the done event, never from the deltas.
sed -n '/^event: done/{n;s/^data: *//p;}' stream.txt | jq -r '(.job // .) | .output.output' \
  | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply2.jsonl
jq -r 'select(.k == "note") | "Private note (\(.signal)): \(.tip)"' reply2.jsonl
jq -r 'select(.k == "turn") | "Interviewer (\(.kind)): \(.say)"' reply2.jsonl
# When you are done, send task "debrief" with the FULL transcript and facts_json, then read
# the summary, score (one per competency), strength, gap, rewrite and practice lines.
def run_stream(body, key):
    """POST /run-stream and return the job from the done event (or the JSON of an idempotent replay)."""
    h = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}
    res = requests.post(API + "/run-stream", data=body, headers=h, stream=True, timeout=300)
    if "text/event-stream" not in res.headers.get("content-type", ""):
        return res.json()["data"]
    event = None
    for line in res.iter_lines(decode_unicode=True):
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:") and event in ("done", "error"):
            data = json.loads(line[5:])
            if event == "error":
                raise RuntimeError(data)
            return data.get("job", data)
    raise RuntimeError("stream ended without a done event")

answer_input = dict(open_input,
    task="answer",
    transcript=f"Q1 INTERVIEWER [{turn['competency']}] question: {turn['say']}",
    last_question=turn["say"], last_competency=turn["competency"],
    answer="At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, "
           "then counted who came back within 14 days of each email.",
    answer_seconds=75, skipped=0, question_index=2)
body = json.dumps(answer_input)
done = run_stream(body, idem_key("answer", body))
for r in jsonl(done["output"]["output"]):
    if r["k"] == "note":
        print(f"Private note ({r['signal']}): {r['tip']}")
    if r["k"] == "turn":
        print(f"Interviewer ({r['kind']}): {r['say']}")
async function runStream(body, key) {
  const res = await fetch(API + "/run-stream", {
    method: "POST", body,
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}`, "Idempotency-Key": key },
  });
  if (!(res.headers.get("content-type") || "").includes("text/event-stream")) return (await res.json()).data;
  const text = await res.text(); // the reply arrives whole in the done event
  for (const frame of text.split("\n\n")) {
    const ev = /^event: *(.+)$/m.exec(frame)?.[1], data = /^data: *(.+)$/m.exec(frame)?.[1];
    if (ev === "error") throw new Error(data);
    if (ev === "done") { const d = JSON.parse(data); return d.job ?? d; }
  }
  throw new Error("stream ended without a done event");
}

const answerInput = {
  ...openInput, task: "answer",
  transcript: `Q1 INTERVIEWER [${turn.competency}] question: ${turn.say}`,
  last_question: turn.say, last_competency: turn.competency,
  answer: "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email.",
  answer_seconds: 75, skipped: 0, question_index: 2,
};
const body2 = JSON.stringify(answerInput);
const done = await runStream(body2, idemKey("answer", body2));
for (const r of jsonl(done.output.output)) {
  if (r.k === "note") console.log(`Private note (${r.signal}): ${r.tip}`);
  if (r.k === "turn") console.log(`Interviewer (${r.kind}): ${r.say}`);
}
// at package level:
func runStream(body []byte, key string) (job, error) {
	req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Idempotency-Key", key)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return job{}, err
	}
	defer res.Body.Close()
	var j job
	if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
		var env struct{ Data job `json:"data"` }
		err = json.NewDecoder(res.Body).Decode(&env)
		return env.Data, err
	}
	sc := bufio.NewScanner(res.Body)
	sc.Buffer(make([]byte, 1<<20), 1<<24)
	event := ""
	for sc.Scan() {
		line := sc.Text()
		if strings.HasPrefix(line, "event:") {
			event = strings.TrimSpace(line[6:])
		} else if strings.HasPrefix(line, "data:") && event == "done" {
			err = json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &j)
			return j, err
		} else if strings.HasPrefix(line, "data:") && event == "error" {
			return j, fmt.Errorf("stream error: %s", line[5:])
		}
	}
	return j, fmt.Errorf("stream ended without a done event")
}

// in main(), after step 4 (turn is the "turn" row from step 4):
in := openInput()
in["task"] = "answer"
in["transcript"] = fmt.Sprintf("Q1 INTERVIEWER [%v] question: %v", turn["competency"], turn["say"])
in["last_question"], in["last_competency"] = turn["say"], turn["competency"]
in["answer"] = "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email."
in["answer_seconds"], in["skipped"], in["question_index"] = 75, 0, 2
body2, _ := json.Marshal(in)
done, err := runStream(body2, idemKey("answer", body2, 2, 1))
if err != nil {
	log.Fatal(err)
}
for _, r := range jsonl(done.Output.Output) {
	if r["k"] == "turn" {
		fmt.Printf("Interviewer (%v): %v\n", r["kind"], r["say"])
	}
}
static JsonNode runStream(String body, String key) throws Exception {
    var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
        .header("Content-Type", "application/json").header("Authorization", "Bearer " + token)
        .header("Idempotency-Key", key).POST(HttpRequest.BodyPublishers.ofString(body)).build();
    var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
    if (!res.headers().firstValue("content-type").orElse("").contains("text/event-stream"))
        return JSON.readTree(res.body()).get("data");
    String event = "";
    for (String line : res.body().split("\n")) {
        if (line.startsWith("event:")) event = line.substring(6).trim();
        else if (line.startsWith("data:") && event.equals("done")) {
            JsonNode d = JSON.readTree(line.substring(5).trim());
            return d.has("job") ? d.get("job") : d;
        } else if (line.startsWith("data:") && event.equals("error")) throw new RuntimeException(line);
    }
    throw new RuntimeException("stream ended without a done event");
}

// in main(), after step 4 (turn is the "turn" row from step 4):
ObjectNode answerInput = openInput();
answerInput.put("task", "answer");
answerInput.put("transcript", "Q1 INTERVIEWER [" + turn.get("competency").asText() + "] question: " + turn.get("say").asText());
answerInput.put("last_question", turn.get("say").asText());
answerInput.put("last_competency", turn.get("competency").asText());
answerInput.put("answer", "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email.");
answerInput.put("answer_seconds", 75);
answerInput.put("skipped", 0);
answerInput.put("question_index", 2);
String body2 = JSON.writeValueAsString(answerInput);
JsonNode done = runStream(body2, idemKey("answer", body2, 2, 1));
for (JsonNode r : jsonl(done.path("output").path("output").asText()))
    if (r.get("k").asText().equals("turn")) System.out.println("Interviewer (" + r.get("kind").asText() + "): " + r.get("say").asText());
def run_stream(body, key)
  uri = URI(API + "/run-stream")
  req = Net::HTTP::Post.new(uri)
  { "Content-Type" => "application/json", "Authorization" => "Bearer #{$token}", "Idempotency-Key" => key }.each { |k, v| req[k] = v }
  req.body = body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
  return JSON.parse(res.body)["data"] unless res["content-type"].to_s.include?("text/event-stream")
  event = nil
  res.body.each_line do |line|
    if line.start_with?("event:") then event = line[6..].strip
    elsif line.start_with?("data:") && event == "done"
      d = JSON.parse(line[5..]); return d["job"] || d
    elsif line.start_with?("data:") && event == "error" then raise line
    end
  end
  raise "stream ended without a done event"
end

answer_input = open_input.merge(
  task: "answer",
  transcript: "Q1 INTERVIEWER [#{turn["competency"]}] question: #{turn["say"]}",
  last_question: turn["say"], last_competency: turn["competency"],
  answer: "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email.",
  answer_seconds: 75, skipped: 0, question_index: 2
)
body2 = answer_input.to_json
done = run_stream(body2, idem_key("answer", body2))
jsonl(done.dig("output", "output")).each { |r| puts "Interviewer (#{r["kind"]}): #{r["say"]}" if r["k"] == "turn" }
function run_stream(string $body, string $key): array {
    global $TOKEN;
    $ch = curl_init(API . "/run-stream");
    curl_setopt_array($ch, [
        CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300,
        CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer $TOKEN", "Idempotency-Key: $key"],
    ]);
    $raw = curl_exec($ch);
    $type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
    curl_close($ch);
    if (strpos((string)$type, "text/event-stream") === false) return json_decode($raw, true)["data"];
    $event = "";
    foreach (explode("\n", $raw) as $line) {
        if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
        elseif (str_starts_with($line, "data:") && $event === "done") { $d = json_decode(trim(substr($line, 5)), true); return $d["job"] ?? $d; }
        elseif (str_starts_with($line, "data:") && $event === "error") throw new Exception($line);
    }
    throw new Exception("stream ended without a done event");
}

$turn = array_values(array_filter(jsonl($job["output"]["output"]), fn($r) => $r["k"] === "turn"))[0];
$answerInput = array_merge($openInput, [
    "task" => "answer",
    "transcript" => "Q1 INTERVIEWER [{$turn["competency"]}] question: {$turn["say"]}",
    "last_question" => $turn["say"], "last_competency" => $turn["competency"],
    "answer" => "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email.",
    "answer_seconds" => 75, "skipped" => 0, "question_index" => 2,
]);
$body2 = json_encode($answerInput, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$done = run_stream($body2, idem_key("answer", $body2));
foreach (jsonl($done["output"]["output"] ?? "") as $r)
    if ($r["k"] === "turn") echo "Interviewer ({$r["kind"]}): {$r["say"]}", PHP_EOL;
static async Task<JsonElement> RunStream(string body, string key)
{
    var req = new HttpRequestMessage(HttpMethod.Post, MockInterview.Api + "/run-stream")
    { Content = new StringContent(body, Encoding.UTF8, "application/json") };
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockInterview.Token);
    req.Headers.Add("Idempotency-Key", key);
    var res = await MockInterview.Http.SendAsync(req);
    var text = await res.Content.ReadAsStringAsync();
    if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
        return JsonDocument.Parse(text).RootElement.GetProperty("data");
    var ev = "";
    foreach (var line in text.Split('\n'))
    {
        if (line.StartsWith("event:")) ev = line[6..].Trim();
        else if (line.StartsWith("data:") && ev == "done")
        {
            var d = JsonDocument.Parse(line[5..]).RootElement;
            return d.TryGetProperty("job", out var j) ? j : d;
        }
        else if (line.StartsWith("data:") && ev == "error") throw new Exception(line);
    }
    throw new Exception("stream ended without a done event");
}

// top-level, after step 4:
var answerInput = new Dictionary<string, object>(openInput)
{
    ["task"] = "answer",
    ["transcript"] = $"Q1 INTERVIEWER [{turn.GetProperty("competency")}] question: {turn.GetProperty("say")}",
    ["last_question"] = turn.GetProperty("say").GetString()!, ["last_competency"] = turn.GetProperty("competency").GetString()!,
    ["answer"] = "At my last job I used ROW_NUMBER over each customer's orders to find lapsed buyers, then counted who came back within 14 days of each email.",
    ["answer_seconds"] = 75, ["skipped"] = 0, ["question_index"] = 2,
};
var body2 = JsonSerializer.Serialize(answerInput);
var done = await RunStream(body2, IdemKey("answer", body2, 2));
foreach (var r in Jsonl(done.GetProperty("output").GetProperty("output").GetString()!))
    if (r.GetProperty("k").GetString() == "turn") Console.WriteLine($"Interviewer ({r.GetProperty("kind")}): {r.GetProperty("say")}");

Worked examples

Real replies from the app's example interview for a fictional data analyst role.

open

{"k":"turn","task":"open","kind":"question","competency":"c1","say":"Hi Priya, thanks for joining - this is the interview for the Data Analyst role on the Growth team. To start, walk me through a recent piece of analysis where you wrote the SQL yourself: what was the question, and how did you structure the query?"}
{"k":"plan","competency":"c1","angle":"Hands-on SQL depth, including window functions on order data"}
{"k":"plan","competency":"c2","angle":"Designing a promotion A/B test: sample size and guardrails"}
{"k":"plan","competency":"c4","angle":"Turning a result into a recommendation for non-technical people"}
{"k":"plan","competency":"c6","angle":"Owning an outcome end to end, like the reporting automation"}
{"k":"plan","competency":"c3","angle":"Dashboard design and upkeep"}
{"k":"plan","competency":"c5","angle":"Clarity of written and spoken explanation"}
{"k":"end"}

answer - a thin answer gets a follow-up

{"k":"note","competency":"c2","signal":"thin","evidence":"I'd run it for two weeks","tip":"Name the primary metric, a guardrail such as margin per order, and how you would size the sample before choosing the duration."}
{"k":"turn","task":"answer","kind":"followup","competency":"c2","say":"Okay. How would you know two weeks is enough - what would you need to work out before launching?"}
{"k":"end"}

answer - a skipped follow-up

{"k":"note","competency":"c2","signal":"missing","evidence":"","tip":"Prepare a short answer on sample size: baseline conversion, the smallest lift worth detecting, and the traffic you have."}
{"k":"turn","task":"answer","kind":"question","competency":"c4","say":"No problem, let's move on. Tell me about a time you had to explain an analysis result to someone without a technical background and get them to act on it."}
{"k":"end"}

answer - after the last question

{"k":"note","competency":"c4","signal":"strong","evidence":"walked through three customers line by line","tip":"Close with the decision it changed, for example which retention action the managers took because of the corrected numbers."}
{"k":"turn","task":"answer","kind":"close","competency":"","say":"Thank you, Priya - that's all my questions for today. You'll get the debrief next."}
{"k":"end"}

debrief

{"k":"summary","verdict":"lean hire","overall":3.3,"say":"Your SQL story was the high point: a real business question, a window function used for the right reason, and a result that moved budget. Your explanation story showed you can win over sceptical stakeholders. The experiment design answer was the weak spot for a role that runs tests every week - you gave a split and a duration but no metric, guardrail or sample size, and you skipped the follow-up. Dashboards and ownership were not covered, so prepare examples for them."}
{"k":"score","competency":"c1","score":4,"evidence":"ROW_NUMBER over each customer's orders by date","why":"Specific, correct use of a window function tied to a business question and outcome.","improve":"Add how you validated the query and how long it took to run on the full table."}
{"k":"score","competency":"c2","score":2,"evidence":"I'd run it for two weeks","why":"Randomisation was mentioned, but no primary metric, guardrail or sample-size reasoning, and the follow-up was skipped.","improve":"Practise a 60-second test design: hypothesis, primary metric, guardrail (margin per order), minimum detectable effect, sample size, then duration."}
{"k":"score","competency":"c3","score":0,"evidence":"","why":"Not covered in this interview.","improve":"Prepare one example of a dashboard you built: who used it, what decision it supported and how you kept it accurate."}
{"k":"score","competency":"c4","score":4,"evidence":"walked through three customers line by line","why":"You diagnosed why trust was low and fixed it with concrete examples and a one-page note.","improve":"Finish with the decision the managers made because of the corrected numbers."}
{"k":"score","competency":"c5","score":0,"evidence":"","why":"Not covered in this interview.","improve":"Have a short story ready about a written summary or presentation that changed what a team did."}
{"k":"score","competency":"c6","score":0,"evidence":"","why":"Not covered in this interview.","improve":"Prepare the report-automation story from your résumé (6 hours to 1) as an ownership example with a clear result."}
{"k":"strength","text":"You anchor answers in a real question and a measurable result, such as two of nine campaigns driving about 70 percent of reactivations."}
{"k":"strength","text":"You handle stakeholder doubt by showing the data line by line rather than defending the number."}
{"k":"gap","text":"Experiment design: the job asks for sample size and guardrail metrics, and neither came up."}
{"k":"gap","text":"No Looker or dashboard example, which is a stated part of the role."}
{"k":"rewrite","question":"Q2","competency":"c2","outline":"1) Hypothesis: 10 percent off raises first-order conversion in the two test cities. 2) Randomise new visitors 50/50 by user ID. 3) Primary metric first-order conversion; guardrails margin per order and 30-day repeat rate. 4) Size it from baseline conversion [x percent] and the smallest lift worth it [y points], which gives the days needed at current traffic. 5) Decide in advance: ship, extend or stop."}
{"k":"practice","text":"Our test shows a 4 percent lift in conversion but margin per order fell. What would you recommend?"}
{"k":"practice","text":"Write a query that returns each customer's second order date and the days since their first."}
{"k":"end"}