Decision Desk — API

Paste the decision, get the call.

API tokens Open the app

Work a decision from your own tools

Send a decision — a job offer against a counteroffer, build versus buy, a vendor selection, a pricing move, an org change — and get back one plain-text assessment: the decision restated in a line, the recommended call named as you named it, an honest stance from Clear call down to Reframe the decision, the reversibility of the recommended path on the one-way-door test, a bare confidence integer, a short summary, and six sections covering the options, the matrix, what tips it, the risks with their mitigations, the next steps and the open questions. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS, a plain-text reply — so you can wire it into an intake form, run a backlog of decisions overnight, or gate a sign-off on the stance and the reversibility. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug decision-desk. There is no /apps/{slug}/ path segment — the app is identified by the token you present (and, for POST /guest, by the slug in the body). Every other request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Estimates are free; runs are metered against your credit balance. There is a single run task — one decision in, one assessment out.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream
StatusMeaning
400Malformed body — usually the input wrapped in an extra {"input": …} layer instead of sent as the body itself.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large decision).
404Unknown job id.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff, reusing the same idempotency key.

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

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

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

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, 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("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

class Api {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String call(String method, String path, String jsonBody) throws Exception {
        var body = jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody);
        var req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Authorization", "Bearer " + TOKEN)
                .header("Content-Type", "application/json")
                .method(method, body)
                .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body();   // {"data": ...}
    }
}
require "json"
require "net/http"
require "uri"

API   = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")   # see step 1

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
            "DELETE" => Net::HTTP::Delete }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  extra.each { |k, v| req[k] = v }
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise payload.dig("error", "message").to_s unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");   // see step 1

function api(string $method, string $path, ?array $body = null, array $extra = []): array {
    global $TOKEN;
    $headers = ["Authorization: Bearer $TOKEN", "Content-Type: application/json"];
    foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $payload = json_decode($raw, true);
    if ($status >= 400) { throw new RuntimeException($payload["error"]["message"] ?? "request failed"); }
    return $payload["data"];
}
// .NET 6+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class Api {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!; // step 1
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null,
                                               (string, string)? extraHeader = null) {
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (extraHeader is var (hk, hv) && hk is not null) req.Headers.Add(hk, hv);
        if (body is not null)
            req.Content = new StringContent(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)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

Two kinds. A guest token needs no account and is enough for /me and the free /estimate. A personal token bills metered runs to your own balance — get one from the token page, which shows the token this browser already holds, lets you sign in for a personal one, and copies a ready-made export SKILLSAFE_TOKEN="…" line. You never need the DevTools console.

# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"decision-desk"}' | jq -r .data.token

# For a personal token (metered runs bill your account), open
#   https://decision-desk.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
guest = requests.post(API + "/guest", json={"slug": "decision-desk"}).json()["data"]
TOKEN = guest["token"]          # aut_...
guest_id = guest["guest_id"]    # gst_... — keep it if you later migrate the wallet on sign-in

# For a personal token (metered runs bill your account), open
#   https://decision-desk.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "decision-desk" }),
})).json();
const token = guest.data.token;

// For a personal token (metered runs bill your account), open
//   https://decision-desk.skillsafe.ai/tokens.html
// sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
guestBody, _ := json.Marshal(map[string]string{"slug": "decision-desk"})
req, _ := http.NewRequest("POST", API+"/guest", bytes.NewReader(guestBody))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var env struct {
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
token = env.Data.Token

// For a personal token, open https://decision-desk.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var req = HttpRequest.newBuilder(URI.create(Api.BASE + "/guest"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"decision-desk\"}"))
        .build();
var res = Api.HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// res.body() is {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
// — read data.token with your JSON library.

// For a personal token, open https://decision-desk.skillsafe.ai/tokens.html
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
uri = URI(API + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "decision-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
token = JSON.parse(res.body).dig("data", "token")

# For a personal token, open https://decision-desk.skillsafe.ai/tokens.html
<?php
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode(["slug" => "decision-desk"]),
]);
$guest  = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $guest["data"]["token"];

// For a personal token, open https://decision-desk.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest") {
    Content = new StringContent("{\"slug\":\"decision-desk\"}", Encoding.UTF8, "application/json"),
};
var guestRes = await new HttpClient().SendAsync(guestReq);
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guest.GetProperty("data").GetProperty("token").GetString();

// For a personal token, open https://decision-desk.skillsafe.ai/tokens.html

Treat the token like a password: anyone holding it can spend its credits through this app. Keep it in your shell environment rather than in source control.

Step 2 — Check the session and the balance

GET /me is free and tells you whether the token is a guest or a real user, and how many credits it can spend. The app calls this before enabling its run button, and so should you — a 402 after submitting is avoidable.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq .data
# { "subject_type": "user", "subject_id": "...", "credits": 184220 }
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	panic(err)
}
fmt.Println(me.SubjectType, me.Credits)
String me = Api.call("GET", "/me", null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":184220}}
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — Estimate (free, no job created)

POST /estimate takes the exact body you would send to /run and returns the model binding and the price envelope without creating a job or charging anything. The input object is the body — it is not wrapped in {"input": …}. hold_credits is a worst-case reservation, priced against the full output cap; the settled charged_credits is usually much lower. If the balance sits between min_credits and hold_credits, the run still executes with a reduced cap and comes back "truncated": true.

Input fields

FieldTypeMeaning
decisionstringRequired. The situation as pasted: background, evidence, numbers, quotes, constraints, the deadline, what happens if nothing is decided. Up to 40,000 characters. Over that, the app cuts the middle on line boundaries and keeps both ends, marking the cut in-band so the assessment knows it is not seeing everything.
optionsstringOptional. The options, one per line, including "do nothing" when it is real. Up to 4,000 characters, cut the same way.
contextstringOptional. Who is deciding, what they optimize for, hard constraints, budget, timeline, appetite for risk, what is off the table. Up to 6,000 characters, cut the same way.
factsstringOptional. The arithmetic summary produced by the app's in-browser weighted-matrix calculator: criteria with weights normalized to 100%, per-option weighted totals on the user's own 0-10 scores, the ranking, the top-two gap and a close-call flag. It is a hint, not a verdict — the assessment checks it against the decision text and says so when it disagrees. Send your own prose in the same spirit if you compute a matrix yourself.
retry_notestringOptional. The app sends it on its single automatic reformat retry, and only then: its value restates the required output shape line by line after a reply failed to parse. Do not send it on a first attempt.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"decision":"Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.","options":"Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March","context":"Optimizing for growth, not headline pay. Two-week notice period.","facts":"Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%."}' | jq .data

# { "model": "...", "model_alias": "...", "markup_bps": 1000,
#   "hold_credits": 2960, "min_credits": 380, "sponsor_enabled": false }
# Read `model` from this response — never hardcode a model name in your own copy.
payload = {
    "decision": ("Take the Northgate offer at 148k, or stay for the 140k counteroffer. "
                 "Answer due Friday. The team I would join ships weekly; my current team has "
                 "shipped twice this year."),
    "options": "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
    "context": "Optimizing for growth, not headline pay. Two-week notice period.",
    "facts": "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
}
est = api("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const payload = {
  decision:
    "Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday. " +
    "The team I would join ships weekly; my current team has shipped twice this year.",
  options: "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
  context: "Optimizing for growth, not headline pay. Two-week notice period.",
  facts: "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
};
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
payload := map[string]any{
	"decision": "Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.",
	"options":  "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
	"context":  "Optimizing for growth, not headline pay. Two-week notice period.",
	"facts":    "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
}

var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	panic(err)
}
fmt.Println(est.Model, est.HoldCredits, est.MinCredits)
String payload = """
    {"decision":"Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.",
     "options":"Take the Northgate offer\\nStay and take the counteroffer\\nKeep interviewing until March",
     "context":"Optimizing for growth, not headline pay. Two-week notice period.",
     "facts":"Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%."}
    """;
String est = Api.call("POST", "/estimate", payload);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
payload = {
  "decision" => "Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.",
  "options"  => "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
  "context"  => "Optimizing for growth, not headline pay. Two-week notice period.",
  "facts"    => "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
}
est = api("POST", "/estimate", payload)
puts est["model"], est["hold_credits"], est["min_credits"]
<?php
$payload = [
    "decision" => "Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.",
    "options"  => "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
    "context"  => "Optimizing for growth, not headline pay. Two-week notice period.",
    "facts"    => "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
];
$est = api("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], "\n";
var payload = new {
    decision = "Take the Northgate offer at 148k, or stay for the 140k counteroffer. Answer due Friday.",
    options = "Take the Northgate offer\nStay and take the counteroffer\nKeep interviewing until March",
    context = "Optimizing for growth, not headline pay. Two-week notice period.",
    facts = "Matrix criteria (weights normalized to 100%): compensation 40%, growth 35%, stability 25%.",
};
var est = await Api.Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

What the six fields mean: model is the model the app is bound to right now and model_alias the stable name that points at it — read them from this response and print those, because the alias can be repointed without a redeploy; markup_bps is the publisher markup in basis points; hold_credits is the reservation taken before the run; min_credits is the floor below which the run will not start at all; and sponsor_enabled says whether a guest can run this app for free today. Asserting that model and markup_bps have not changed between deploys costs nothing and catches a repointed binding early.

Step 4 — Run and poll

POST /run creates a job; GET /jobs/{id} polls it to a terminal state. Always send an Idempotency-Key. The app's own key has the shape dd-<inputhash>-<nonce>: inputhash is a hash of decision, options, context and facts, so a network blip retried inside one gesture dedupes instead of billing twice; the nonce is minted fresh for every deliberate run, so an intentional identical re-run is a real second run rather than a replay of the answer you are trying to escape. The one reformat retry appends -reformat to the same key. Send one key per logical run, reuse it across transport retries of that same run, and change it whenever you actually want a new answer. The completed job's output.output is the assessment as plain text.

# One key per logical run: a hash of the input plus a per-gesture nonce.
# Retrying the SAME request reuses the key; a deliberate re-run mints a new nonce.
HASH=$(printf %s "$DECISION" | shasum -a 256 | cut -c1-8)
NONCE=$(head -c 8 /dev/urandom | xxd -p)
KEY="dd-$HASH-$NONCE"

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

# Poll until terminal.
while :; do
  J=$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(echo "$J" | jq -r .data.status)
  [ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
  sleep 2
done
echo "$J" | jq -r .data.output.output    # the assessment, as plain text
import hashlib, secrets, time

def idempotency_key(payload, nonce, reformat=False):
    blob = " ".join([payload.get("decision", ""), payload.get("options", ""),
                     payload.get("context", ""), payload.get("facts", "")])
    h = hashlib.sha256(blob.encode()).hexdigest()[:8]
    return f"dd-{h}-{nonce}" + ("-reformat" if reformat else "")

nonce = secrets.token_hex(8)          # fresh per deliberate run
key = idempotency_key(payload, nonce)
job = api("POST", "/run", payload, **{"Idempotency-Key": key})

while True:
    j = api("GET", f"/jobs/{job['job_id']}")
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

text = j["output"]["output"]          # plain text — see step 6 for the parser
print(text.splitlines()[:6])
import { createHash, randomBytes } from "node:crypto";

function idempotencyKey(p, nonce, reformat = false) {
  const blob = [p.decision ?? "", p.options ?? "", p.context ?? "", p.facts ?? ""].join(" ");
  const h = createHash("sha256").update(blob).digest("hex").slice(0, 8);
  return `dd-${h}-${nonce}` + (reformat ? "-reformat" : "");
}

const nonce = randomBytes(8).toString("hex");   // fresh per deliberate run
const key = idempotencyKey(payload, nonce);
const job = await api("POST", "/run", payload, { "Idempotency-Key": key });

let j;
do {
  await new Promise((r) => setTimeout(r, 2000));
  j = await api("GET", `/jobs/${job.job_id}`);
} while (j.status !== "succeeded" && j.status !== "failed");

const text = j.output.output;   // plain text — see step 6 for the parser
console.log(text.split("\n").slice(0, 6).join("\n"));
import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"strings"
	"time"
)

blob := strings.Join([]string{decision, options, context, facts}, " ")
sum := sha256.Sum256([]byte(blob))
nb := make([]byte, 8)
rand.Read(nb)
key := "dd-" + hex.EncodeToString(sum[:])[:8] + "-" + hex.EncodeToString(nb)

// call() with an extra header — add req.Header.Set("Idempotency-Key", key) there.
var job struct {
	JobID string `json:"job_id"`
}
if err := call("POST", "/run", payload, &job); err != nil {
	panic(err)
}

var j struct {
	Status string `json:"status"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+job.JobID, nil, &j); err != nil {
		panic(err)
	}
	if j.Status == "succeeded" || j.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
fmt.Println(j.Output.Output) // the assessment, as plain text
// Add the header inside Api.call(), or build the request inline:
var nonce = Long.toHexString(new java.security.SecureRandom().nextLong());
var hash = Integer.toHexString((decision + " " + options + " " + context + " " + facts).hashCode());
var key = "dd-" + hash + "-" + nonce;   // append "-reformat" on the reformat retry only

var runReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", key)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
var runRes = Api.HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
// read data.job_id, then poll GET /jobs/{job_id} until status is succeeded or failed.
String job = Api.call("GET", "/jobs/" + jobId, null);
System.out.println(job);   // data.output.output is the plain-text assessment
require "digest"
require "securerandom"

blob  = [payload["decision"], payload["options"], payload["context"], payload["facts"]].join(" ")
nonce = SecureRandom.hex(8)      # fresh per deliberate run
key   = "dd-#{Digest::SHA256.hexdigest(blob)[0, 8]}-#{nonce}"

job = api("POST", "/run", payload, { "Idempotency-Key" => key })

loop do
  @j = api("GET", "/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 2
end

text = @j["output"]["output"]    # plain text — see step 6 for the parser
puts text.lines.first(6)
<?php
$blob  = implode(" ", [$payload["decision"] ?? "", $payload["options"] ?? "",
                       $payload["context"] ?? "", $payload["facts"] ?? ""]);
$nonce = bin2hex(random_bytes(8));   // fresh per deliberate run
$key   = "dd-" . substr(hash("sha256", $blob), 0, 8) . "-" . $nonce;

$job = api("POST", "/run", $payload, ["Idempotency-Key" => $key]);

do {
    sleep(2);
    $j = api("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"], true));

$text = $j["output"]["output"];      // plain text — see step 6 for the parser
echo substr($text, 0, 400), "\n";
using System.Security.Cryptography;

var blob = string.Join(" ", decision, options, context, facts);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(blob)))[..8].ToLower();
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLower();
var key = $"dd-{hash}-{nonce}";   // append "-reformat" on the reformat retry only

var job = await Api.Call(HttpMethod.Post, "/run", payload, ("Idempotency-Key", key));
var jobId = job.GetProperty("job_id").GetString();

JsonElement j;
do {
    await Task.Delay(2000);
    j = await Api.Call(HttpMethod.Get, $"/jobs/{jobId}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var text = j.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine(text);

A run that comes back "truncated": true hit the output cap: the assessment is genuine but stops early, and the honest thing to do with it is what the app does — show what arrived, say which sections did not, and never fill the gaps in.

Step 5 — Stream instead (SSE)

POST /run-stream is the same call with Accept: text/event-stream. It emits an event: job frame, then a sequence of event: delta frames whose data.text carries fragments of the assessment in order, then event: done with the settled charge (or event: error). The frame name is on the event: line — the data: payload carries no type field of its own, so track the current event name as you read. This is what the app itself uses, and it is what lets a progress UI advance on real signals — the arrival of ## The matrix or ## What tips it in the stream — rather than on a timer. Concatenate every delta and parse the whole thing once at the end; the done payload's output.output is authoritative, because deltas can drop the tail. Send the same Idempotency-Key here as for /run.

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" \
  -d @payload.json

# Standard SSE frames, separated by a blank line. The frame NAME is on the
# `event:` line; the payload is on the `data:` line and carries no type field of
# its own, so you must track the current event name as you read:
#
#   event: job
#   data: {"job_id":"job_..."}
#
#   event: delta
#   data: {"text":"DECISION: Take the Northgate offer or stay..."}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":740,"output":{"output":"..."}}
#
# Concatenate every delta's `text` in order; the reply is plain text, not JSON.
# An `event: error` frame carries a failure instead.
with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": f"Bearer {TOKEN}",
                            "Accept": "text/event-stream",
                            "Idempotency-Key": key}) as res:
    raw, event, done = "", "message", None
    for line in res.iter_lines(decode_unicode=True):
        if line is None:
            continue
        if line == "":                          # blank line ends a frame
            event = "message"
            continue
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data = json.loads(line[5:].strip())
            if event == "delta":
                raw += data.get("text", "")
            elif event == "done":
                done = data
            elif event == "error":
                raise RuntimeError(data.get("message", "stream failed"))

# The done payload wins: deltas can drop the tail.
text = (done or {}).get("output", {}).get("output") or raw
print(text.splitlines()[0], (done or {}).get("charged_credits"))
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    Accept: "text/event-stream",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});

let raw = "", buf = "", done = null;
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += dec.decode(chunk.value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) >= 0) {     // frames are blank-line separated
    const frame = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    let event = "message", dataStr = "";
    for (const line of frame.split("\n")) {
      if (line.startsWith("event:")) event = line.slice(6).trim();
      else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
    }
    if (!dataStr) continue;
    const data = JSON.parse(dataStr);
    if (event === "delta") raw += data.text ?? "";
    else if (event === "done") done = data;
    else if (event === "error") throw new Error(data.message ?? "stream failed");
  }
}
const text = done?.output?.output ?? raw;   // the done payload is authoritative
console.log(text.split("\n")[0], done?.charged_credits);
import (
	"bufio"
	"strings"
)

body, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", key)

stream, _ := http.DefaultClient.Do(req)
defer stream.Body.Close()

var raw bytes.Buffer
event := "message"
sc := bufio.NewScanner(stream.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case line == "":
		event = "message" // blank line ends the frame
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(line[5:]), &d)
		if event == "delta" {
			raw.WriteString(d.Text)
		}
	}
}
fmt.Println(raw.String()) // the assessment, as plain text
var streamReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run-stream"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Accept", "text/event-stream")
        .header("Idempotency-Key", key)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();

var raw = new StringBuilder();
var event = new String[]{"message"};
Api.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.isEmpty()) {
        event[0] = "message";                 // blank line ends the frame
    } else if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library, then append the text
        raw.append(line.substring(5).trim());
    }
});
System.out.println(raw);
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Accept"]          = "text/event-stream"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)

raw   = +""
buf   = +""
event = "message"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n\n"))
        frame = buf.slice!(0, i + 2)
        frame.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip
          elsif line.start_with?("data:") && event == "delta"
            raw << (JSON.parse(line[5..].strip)["text"] || "")
          end
        end
        event = "message"   # the frame ended
      end
    end
  end
end

puts raw.lines.first(6)
<?php
$raw   = "";
$buf   = "";
$event = "message";

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Accept: text/event-stream",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$buf, &$event) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n\n")) !== false) {
            $frame = substr($buf, 0, $i);
            $buf   = substr($buf, $i + 2);
            foreach (explode("\n", $frame) as $line) {
                if (str_starts_with($line, "event:")) {
                    $event = trim(substr($line, 6));
                } elseif (str_starts_with($line, "data:") && $event === "delta") {
                    $d = json_decode(substr($line, 5), true);
                    $raw .= $d["text"] ?? "";
                }
            }
            $event = "message";   // the frame ended
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

echo substr($raw, 0, 400), "\n";
var streamReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

var streamRes = await new HttpClient().SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());

var raw = new StringBuilder();
var evt = "message";
while (await reader.ReadLineAsync() is { } line) {
    if (line.Length == 0) { evt = "message"; continue; }   // blank line ends the frame
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); continue; }
    if (!line.StartsWith("data:")) continue;
    var data = JsonDocument.Parse(line[5..]).RootElement;
    if (evt == "delta") raw.Append(data.GetProperty("text").GetString());
}
Console.WriteLine(raw.ToString());

If the stream dies mid-assessment you still hold every delta received so far. The app's salvage rule is worth copying: keep the tag lines and the sections that actually arrived, drop the final half-written bullet, label everything else as not received, and never back-fill a stance, a confidence or a section from a default.

Step 6 — Parse the reply

The reply is plain text, not JSON: six tag lines, then six ## sections in a fixed order. A parser only needs two rules — a line matching <TAG>: sets a header field, and a line starting ## opens a section whose body is - bullets until the next heading. SUMMARY: may wrap over several lines and ends at the first blank line. Treat a reply that is missing any tag line or any section as a failed parse; that is when you send the run again with retry_note set and -reformat appended to the idempotency key.

# The tag lines:
printf '%s\n' "$TEXT" | grep -E '^(DECISION|CALL|STANCE|REVERSIBILITY|CONFIDENCE|SUMMARY):'

# Every bullet, labelled with the section it came from:
printf '%s\n' "$TEXT" | awk '
  /^## /   { sec = substr($0, 4); next }
  /^[-*] / { print sec ": " substr($0, 3) }
'

# A cheap gate before you trust it — all six sections must be present:
for s in "The options" "The matrix" "What tips it" \
         "Risks and mitigations" "Next steps" "Open questions"; do
  printf '%s\n' "$TEXT" | grep -qF "## $s" || echo "MISSING: $s"
done
import re

HEADERS = ["DECISION", "CALL", "STANCE", "REVERSIBILITY", "CONFIDENCE", "SUMMARY"]
SECTIONS = ["The options", "The matrix", "What tips it",
            "Risks and mitigations", "Next steps", "Open questions"]

def parse(text):
    out = {"sections": {}}
    section = summary = None
    for line in text.splitlines():
        if line.startswith("## "):
            section, summary = line[3:].strip(), None
            out["sections"].setdefault(section, [])
            continue
        m = re.match(r"^([A-Z]+):\s*(.*)$", line.strip())
        if m and m.group(1) in HEADERS:
            key = m.group(1).lower()
            out[key] = m.group(2).strip()
            summary = key == "summary"
            section = None
            continue
        if summary:
            if not line.strip():          # SUMMARY ends at the first blank line
                summary = None
            else:
                out["summary"] += " " + line.strip()
            continue
        if section and line.lstrip().startswith(("- ", "* ")):
            out["sections"][section].append(line.lstrip()[2:].strip())
    out["confidence"] = int(out.get("confidence", "0"))   # a bare integer 0-100
    return out

r = parse(text)
missing = [s for s in SECTIONS if s not in r["sections"]]
if missing or any(h.lower() not in r for h in HEADERS):
    raise RuntimeError("failed parse — re-run with retry_note and a -reformat key")
print(r["call"], "|", r["stance"], "|", r["reversibility"], "|", r["confidence"])
# Only "Risks and mitigations" and "Open questions" may legitimately be "- None."
const HEADERS = ["DECISION", "CALL", "STANCE", "REVERSIBILITY", "CONFIDENCE", "SUMMARY"];
const SECTIONS = ["The options", "The matrix", "What tips it",
                  "Risks and mitigations", "Next steps", "Open questions"];

function parse(text) {
  const out = { sections: {} };
  let section = null, inSummary = false;
  for (const line of text.split("\n")) {
    if (line.startsWith("## ")) {
      section = line.slice(3).trim();
      inSummary = false;
      out.sections[section] ??= [];
      continue;
    }
    const m = /^([A-Z]+):\s*(.*)$/.exec(line.trim());
    if (m && HEADERS.includes(m[1])) {
      out[m[1].toLowerCase()] = m[2].trim();
      inSummary = m[1] === "SUMMARY";
      section = null;
      continue;
    }
    if (inSummary) {
      if (!line.trim()) inSummary = false;          // ends at the first blank line
      else out.summary += " " + line.trim();
      continue;
    }
    const t = line.trimStart();
    if (section && (t.startsWith("- ") || t.startsWith("* "))) {
      out.sections[section].push(t.slice(2).trim());
    }
  }
  out.confidence = parseInt(out.confidence, 10);     // a bare integer 0-100
  return out;
}

const r = parse(text);
const missing = SECTIONS.filter((s) => !(s in r.sections));
if (missing.length) throw new Error("failed parse — re-run with retry_note and a -reformat key");
console.log(r.call, r.stance, r.reversibility, r.confidence);
import (
	"regexp"
	"strconv"
	"strings"
)

var tagRe = regexp.MustCompile(`^([A-Z]+):\s*(.*)$`)

func parse(text string) (map[string]string, map[string][]string) {
	head := map[string]string{}
	secs := map[string][]string{}
	section := ""
	inSummary := false
	for _, line := range strings.Split(text, "\n") {
		if strings.HasPrefix(line, "## ") {
			section = strings.TrimSpace(line[3:])
			inSummary = false
			if _, ok := secs[section]; !ok {
				secs[section] = []string{}
			}
			continue
		}
		if m := tagRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
			head[m[1]] = strings.TrimSpace(m[2])
			inSummary = m[1] == "SUMMARY"
			section = ""
			continue
		}
		if inSummary {
			if strings.TrimSpace(line) == "" {
				inSummary = false // SUMMARY ends at the first blank line
			} else {
				head["SUMMARY"] += " " + strings.TrimSpace(line)
			}
			continue
		}
		t := strings.TrimLeft(line, " \t")
		if section != "" && (strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ")) {
			secs[section] = append(secs[section], strings.TrimSpace(t[2:]))
		}
	}
	return head, secs
}

head, secs := parse(text)
confidence, _ := strconv.Atoi(head["CONFIDENCE"]) // a bare integer 0-100
fmt.Println(head["CALL"], head["STANCE"], head["REVERSIBILITY"], confidence, len(secs))
import java.util.*;
import java.util.regex.*;

var HEADERS = Set.of("DECISION", "CALL", "STANCE", "REVERSIBILITY", "CONFIDENCE", "SUMMARY");
var SECTIONS = List.of("The options", "The matrix", "What tips it",
                       "Risks and mitigations", "Next steps", "Open questions");

Map<String, String> head = new HashMap<>();
Map<String, List<String>> secs = new LinkedHashMap<>();
Pattern tag = Pattern.compile("^([A-Z]+):\\s*(.*)$");
String section = null;
boolean inSummary = false;

for (String line : text.split("\n")) {
    if (line.startsWith("## ")) {
        section = line.substring(3).trim();
        inSummary = false;
        secs.putIfAbsent(section, new ArrayList<>());
        continue;
    }
    Matcher m = tag.matcher(line.trim());
    if (m.matches() && HEADERS.contains(m.group(1))) {
        head.put(m.group(1), m.group(2).trim());
        inSummary = m.group(1).equals("SUMMARY");
        section = null;
        continue;
    }
    if (inSummary) {
        if (line.isBlank()) inSummary = false;   // SUMMARY ends at the first blank line
        else head.merge("SUMMARY", " " + line.trim(), String::concat);
        continue;
    }
    String t = line.stripLeading();
    if (section != null && (t.startsWith("- ") || t.startsWith("* "))) {
        secs.get(section).add(t.substring(2).trim());
    }
}
int confidence = Integer.parseInt(head.getOrDefault("CONFIDENCE", "0"));
if (!secs.keySet().containsAll(SECTIONS)) throw new RuntimeException("failed parse — retry");
System.out.println(head.get("CALL") + " / " + head.get("STANCE") + " / " + confidence);
HEADERS  = %w[DECISION CALL STANCE REVERSIBILITY CONFIDENCE SUMMARY].freeze
SECTIONS = ["The options", "The matrix", "What tips it",
            "Risks and mitigations", "Next steps", "Open questions"].freeze

def parse(text)
  head    = {}
  secs    = {}
  section = nil
  summary = false
  text.each_line do |raw|
    line = raw.chomp
    if line.start_with?("## ")
      section = line[3..].strip
      summary = false
      secs[section] ||= []
      next
    end
    if (m = line.strip.match(/\A([A-Z]+):\s*(.*)\z/)) && HEADERS.include?(m[1])
      head[m[1]] = m[2].strip
      summary = m[1] == "SUMMARY"
      section = nil
      next
    end
    if summary
      line.strip.empty? ? (summary = false) : head["SUMMARY"] << " " + line.strip
      next
    end
    t = line.lstrip
    secs[section] << t[2..].strip if section && (t.start_with?("- ") || t.start_with?("* "))
  end
  [head, secs]
end

head, secs = parse(text)
raise "failed parse — retry with retry_note" unless (SECTIONS - secs.keys).empty?
puts head["CALL"], head["STANCE"], head["REVERSIBILITY"], head["CONFIDENCE"].to_i
<?php
const HEADERS  = ["DECISION", "CALL", "STANCE", "REVERSIBILITY", "CONFIDENCE", "SUMMARY"];
const SECTIONS = ["The options", "The matrix", "What tips it",
                  "Risks and mitigations", "Next steps", "Open questions"];

function parse_assessment(string $text): array {
    $head = [];
    $secs = [];
    $section = null;
    $inSummary = false;
    foreach (explode("\n", $text) as $line) {
        if (str_starts_with($line, "## ")) {
            $section = trim(substr($line, 3));
            $inSummary = false;
            $secs[$section] ??= [];
            continue;
        }
        if (preg_match('/^([A-Z]+):\s*(.*)$/', trim($line), $m) && in_array($m[1], HEADERS, true)) {
            $head[$m[1]] = trim($m[2]);
            $inSummary = $m[1] === "SUMMARY";
            $section = null;
            continue;
        }
        if ($inSummary) {
            if (trim($line) === "") { $inSummary = false; }   // ends at the first blank line
            else { $head["SUMMARY"] .= " " . trim($line); }
            continue;
        }
        $t = ltrim($line);
        if ($section !== null && (str_starts_with($t, "- ") || str_starts_with($t, "* "))) {
            $secs[$section][] = trim(substr($t, 2));
        }
    }
    return [$head, $secs];
}

[$head, $secs] = parse_assessment($text);
if (array_diff(SECTIONS, array_keys($secs))) {
    throw new RuntimeException("failed parse — re-run with retry_note and a -reformat key");
}
echo $head["CALL"], " / ", $head["STANCE"], " / ", (int) $head["CONFIDENCE"], "\n";
using System.Text.RegularExpressions;

var headers = new HashSet<string> { "DECISION", "CALL", "STANCE", "REVERSIBILITY", "CONFIDENCE", "SUMMARY" };
var wanted = new[] { "The options", "The matrix", "What tips it",
                     "Risks and mitigations", "Next steps", "Open questions" };

var head = new Dictionary<string, string>();
var secs = new Dictionary<string, List<string>>();
string? section = null;
var inSummary = false;
var tag = new Regex(@"^([A-Z]+):\s*(.*)$");

foreach (var line in text.Split('\n')) {
    if (line.StartsWith("## ")) {
        section = line[3..].Trim();
        inSummary = false;
        if (!secs.ContainsKey(section)) secs[section] = new List<string>();
        continue;
    }
    var m = tag.Match(line.Trim());
    if (m.Success && headers.Contains(m.Groups[1].Value)) {
        head[m.Groups[1].Value] = m.Groups[2].Value.Trim();
        inSummary = m.Groups[1].Value == "SUMMARY";
        section = null;
        continue;
    }
    if (inSummary) {
        if (line.Trim().Length == 0) inSummary = false;   // ends at the first blank line
        else head["SUMMARY"] += " " + line.Trim();
        continue;
    }
    var t = line.TrimStart();
    if (section is not null && (t.StartsWith("- ") || t.StartsWith("* ")))
        secs[section].Add(t[2..].Trim());
}

if (wanted.Any(s => !secs.ContainsKey(s)))
    throw new Exception("failed parse — re-run with retry_note and a -reformat key");
Console.WriteLine($"{head["CALL"]} / {head["STANCE"]} / {int.Parse(head["CONFIDENCE"])}");

On a failed parse, send the run again with retry_note set to an instruction restating the shape line by line, and with -reformat appended to the idempotency key. The suffix matters: reusing the first attempt's key would hand you back the malformed reply the retry exists to replace. It is a second, separately billed run, so do it once and then surface the raw text rather than looping.

The output contract

Plain text. Six tag lines, then six ## sections, in this order. These are the fields the app's own render path parses; anything missing makes the app fall back to showing the raw reply, so treat them all as required.

LineValueMeaning
DECISION:stringThe decision being made, restated in one line. No wrapping.
CALL:stringThe recommended option, named as the caller named it — or exactly No clear winner, Reframe the decision or Insufficient information.
STANCE:enumExactly one of Clear call, Close call, Toss-up, Needs more information, Reframe the decision.
REVERSIBILITY:enumExactly one of Reversible, Costly to reverse, One-way door, Mixed. It describes the recommended path, not the decision in general.
CONFIDENCE:integerA bare integer 0–100. No percent sign, no range, no words.
SUMMARY:stringTwo to four sentences. May wrap over several lines; ends at the first blank line.
## The optionsbulletsOne bullet per option: the name, the strongest honest case for and against, risk and effort called High/Medium/Low where the page supports it.
## The matrixbulletsThe deciding criteria with their weights, then one bullet per option with its weighted read — or a qualitative read when the page carries no numbers.
## What tips itbulletsThe small number of factors that actually decide it: the dominant criterion, the eliminating constraint, the asymmetry.
## Risks and mitigationsbulletsWhat can go wrong on the recommended path, each with a mitigation or an early-warning sign.
## Next stepsbulletsWhat to do to execute the call, in order, with who and by when where the page supports it — including the cheap test that would de-risk the choice.
## Open questionsbulletsWhat is missing that would change the call.

Only Risks and mitigations and Open questions may legitimately come back as the single bullet - None. The other four always have something to say, and an empty one is a signal the verdict is unsupported. Two cross-rules are worth asserting on as well: a Needs more information stance must list open questions, and a Close call or Toss-up must name something under What tips it.

Checking the verdict, the way the app does

The app does not paint a badge on the tag lines and stop. It confronts the verdict with the material it was argued from, and shows any disagreement above the result rather than hiding it — a check you can reproduce server-side from the same inputs. The call must name one of the options you actually sent, or it is an option the assessment introduced on its own. If you computed a matrix and put it in facts, its winner and the CALL: should agree, and where they do not, ## The matrix should say which weight or score was disputed. A Clear call on top of arithmetic whose top two sit within about half a point on the 0–10 scale is a contradiction. And a confidence at or above 75 under a stance that declines to separate the options — Toss-up, Needs more information, Reframe the decision — is not a read you should pass on unqualified. None of these are grounds to discard the assessment; they are grounds to show the caveat next to it.