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.
| Status | Meaning |
|---|---|
400 | Malformed body — usually the input wrapped in an extra {"input": …} layer instead of sent as the body itself. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large decision). |
404 | Unknown job id. |
429 | Rate limited — back off and retry. |
5xx | Transient 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
| Field | Type | Meaning |
|---|---|---|
decision | string | Required. 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. |
options | string | Optional. The options, one per line, including "do nothing" when it is real. Up to 4,000 characters, cut the same way. |
context | string | Optional. 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. |
facts | string | Optional. 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_note | string | Optional. 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.
| Line | Value | Meaning |
|---|---|---|
DECISION: | string | The decision being made, restated in one line. No wrapping. |
CALL: | string | The recommended option, named as the caller named it — or exactly No clear winner, Reframe the decision or Insufficient information. |
STANCE: | enum | Exactly one of Clear call, Close call, Toss-up, Needs more information, Reframe the decision. |
REVERSIBILITY: | enum | Exactly one of Reversible, Costly to reverse, One-way door, Mixed. It describes the recommended path, not the decision in general. |
CONFIDENCE: | integer | A bare integer 0–100. No percent sign, no range, no words. |
SUMMARY: | string | Two to four sentences. May wrap over several lines; ends at the first blank line. |
## The options | bullets | One 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 matrix | bullets | The 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 it | bullets | The small number of factors that actually decide it: the dominant criterion, the eliminating constraint, the asymmetry. |
## Risks and mitigations | bullets | What can go wrong on the recommended path, each with a mitigation or an early-warning sign. |
## Next steps | bullets | What 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 questions | bullets | What 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.