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.
| Status | Typical error.code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field. |
401 | unauthorized | Invalid or expired app session. Get a new token (step 1). |
402 | payment_required | Insufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token. |
403 | forbidden | The 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. |
404 | not_found | Unknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token. |
409 | conflict | The Idempotency-Key was already used with a different body. |
429 | rate_limited | Rate 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".
| task | When | Reply lines |
|---|---|---|
open | The first interviewer turn: a short greeting naming the role, then question 1. | turn (kind question), one plan per competency, end. |
answer | The 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. |
debrief | The 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.
| Field | Type | Meaning |
|---|---|---|
task | string | open | answer | debrief. |
job_post | string | The job advert. The app keeps up to 6,000 characters: the opening and closing parts, with the middle replaced by [... N characters omitted ...]. |
resume | string | The candidate's résumé as text, or empty. Up to 5,000 characters, same marker. |
interview_type | string | mixed | behavioral | skills | resume. |
level | string | entry | mid | senior. |
competencies_json | string (JSON array) | The competency map, e.g. "[{\"id\":\"c1\",\"label\":\"Write SQL\"}]". Up to 6; these ids are the only ones that exist. |
question_index | number | The number of the question about to be asked: 1 for open, then one more than the questions asked so far. |
questions_total | number | How many questions the interview has (the app offers 4, 6 or 8). |
transcript | string | The interview so far, one line per entry: Q2 INTERVIEWER [c3] question: ..., A2 CANDIDATE (85 s): ..., A2 CANDIDATE skipped. See How an interview works. |
last_question | string | answer: the interviewer's most recent question, verbatim. |
last_competency | string | answer: the competency id that question targeted. |
answer | string | answer: the candidate's answer (up to 3,000 characters), empty when skipped. |
answer_seconds | number | answer: how long the candidate took. |
skipped | number | answer: 1 when the candidate skipped the question, else 0. |
facts_json | string (JSON object) | debrief: what you measured - per competency the number of questions, skips and answer words; questions asked, answers, skips and total seconds. |
clip_note | string | What was cut before sending, in plain words; empty when nothing was. |
retry_note | string | Optional: 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.
- Build the competency map (ids
c1... with a label) from the job post's duties and requirements, plus a behaviour competency or two. - Send
openwithquestion_index1. Show theturn'ssay. - On each answer, send
answerwith the transcript window,last_question,last_competency, the answer and the nextquestion_index. Keep thenoteline private until the candidate asks; show theturn. - When the reply's kind is
close(or the candidate wants to stop), senddebriefwith the FULL transcript andfacts_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.
| k | Fields | In |
|---|---|---|
turn | task, kind: question | followup | close, competency (a map id; empty on close), say, optional routed: "fallback". | open, answer. |
plan | competency, angle (what will be probed). | open, one per competency. |
note | competency, signal: strong | solid | thin | missing, evidence (copied from the answer), tip. | answer. |
summary | verdict: strong hire | hire | lean hire | lean no hire | no hire, overall (1-5), say. | debrief. |
score | competency, score 0-5 (0 = not covered), evidence, why, improve. | debrief, one per competency. |
strength, gap, practice | text. | debrief, 1-3 each. |
rewrite | question (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
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?
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
/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
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
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"}