MRR Desk — API

Reconcile the movement table and write the retention review from your finance stack, your BI job or a monthly cron.

API tokens Back to the app

Drive MRR Desk from your own code

The app is a thin client over a public REST API. Everything the page does — estimate a run, submit one, stream it, and check the reply back against the measurement — is available to you directly. Base URL https://api.skillsafe.ai/v1/app-api. Every response is the same envelope: {"data": ...} on success, {"error":{"code":"...","message":"..."}} on failure. Branch on the presence of error and then on error.code, never on the message text.

What the model is and is not asked to do

This matters before you write a line of code, because it decides what you have to send. The measurement is not the model's job. The parsing, the footing of every month against a tolerance built from the precision the row was written to, the chain check from each ending to the next beginning, net and gross dollar retention, the quick ratio, compound growth, lifetime value with its approximation error, the quarters, the cohort hazard curve, the driver simulation and every refusal are computed deterministically — in the browser in the app, and in your code if you are driving the API. What you send is that measurement, in facts, and what comes back is one cause and one note per measured fact, a headline, a trajectory paragraph and three to six actions.

The binding is two-way. facts.admissible[fact_id] lists the only causes the model may choose for that fact, and it is computed from the numbers before the run. After the run you check the reply back against the same object — step 8 — and a cause that was not on the list is an invention, not a judgement call.

One consequence worth internalising: "no cause" is a real answer. When a fact's admissible_causes is exactly ["no movement large enough to name"], the reply has to say so in those words, and a reply that reaches for a plausible-sounding driver anyway fails the check.

POST /guest GET /me POST /estimate POST /run POST /run-stream GET /jobs/{job_id}

Errors

Every failure is {"error":{"code":"...","message":"...","details":{...}}}. Branch on code, never on the message.

CodeHTTPWhat it means
VALIDATION_ERROR400The body is not the shape the app expects. error.details.violations names the offending field. A run input over 1 MB of JSON lands here — clip the excerpt, never the facts.
UNAUTHORIZED401No token, an expired one, or a token minted for a different app. Mint a guest token or sign in on the token page.
INSUFFICIENT_CREDITS402The balance is below min_credits. /estimate is free and tells you this before you submit, so a 402 after submit means the preflight was skipped.
FORBIDDEN403The token is valid but not permitted here — most often a guest token on an app whose owner has not enabled sponsorship.
NOT_FOUND404Unknown job id, or a route this release does not declare.
RATE_LIMITED429Too many requests. Back off and retry; a polling loop tighter than one second per job is the usual cause.
INTERNAL500The platform failed. Retry with the SAME Idempotency-Key: the platform replays rather than re-billing.
UNAVAILABLE503The model or the queue is temporarily unavailable. Retry with backoff, same key.

1. A tiny client

One helper covers every call. Keep the token out of source control — the token page will copy a ready-made shell export for you.

# Every call is one request to the same host. Keep the token in a shell
# variable; never commit it. Get one from /tokens.html, or step 2.
API=https://api.skillsafe.ai/v1/app-api
SKILLSAFE_TOKEN="YOUR_TOKEN"

call() {           # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$API$1" \
      -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
  fi
}
import json, urllib.request

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"          # or read it from your own secret store

def call(path, body=None, method=None, token=TOKEN, headers=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(API + path, data=data,
                                 method=method or ("POST" if data else "GET"))
    if token:
        req.add_header("Authorization", "Bearer " + token)
    if data:
        req.add_header("Content-Type", "application/json")
    for k, v in (headers or {}).items():
        req.add_header(k, v)
    with urllib.request.urlopen(req) as r:
        payload = json.load(r)
    if "error" in payload:
        raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
    return payload["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN";     // or read it from your own secret store

async function call(path, body, method, extraHeaders) {
  const res = await fetch(API + path, {
    method: method || (body ? "POST" : "GET"),
    headers: {
      ...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}),
      ...(body ? { "Content-Type": "application/json" } : {}),
      ...(extraHeaders || {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const payload = await res.json();
  if (payload.error) throw new Error(payload.error.code + ": " + payload.error.message);
  return payload.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"
)

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

var token = os.Getenv("SKILLSAFE_TOKEN")

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

func call(path string, body any, method string, hdr map[string]string) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
		if method == "" {
			method = "POST"
		}
	}
	if method == "" {
		method = "GET"
	}
	req, _ := http.NewRequest(method, api+path, rdr)
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	for k, v := range hdr {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var e envelope
	if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
		return nil, err
	}
	if e.Error != nil {
		return nil, errors.New(e.Error.Code + ": " + e.Error.Message)
	}
	return e.Data, nil
}
import java.net.URI;
import java.net.http.*;

public class MrrDesk {
  static final String API = "https://api.skillsafe.ai/v1/app-api";
  static String token = System.getenv("SKILLSAFE_TOKEN");
  static final HttpClient HTTP = HttpClient.newHttpClient();

  static String call(String path, String jsonBody) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Authorization", "Bearer " + token);
    if (jsonBody == null) {
      b.GET();
    } else {
      b.header("Content-Type", "application/json")
       .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
    }
    HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (r.body().contains("\"error\"")) throw new RuntimeException(r.body());
    return r.body();   // parse with your JSON library of choice
  }
}
require "json"
require "net/http"

API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV["SKILLSAFE_TOKEN"]

def call(path, body = nil, method = nil, headers = {})
  uri = URI(API.to_s + path)
  req = if (method || (body ? "POST" : "GET")) == "POST"
          Net::HTTP::Post.new(uri)
        else
          Net::HTTP::Get.new(uri)
        end
  req["Authorization"] = "Bearer #{TOKEN}"
  if body
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(body)
  end
  headers.each { |k, v| req[k] = v }
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload['error']['code']}: #{payload['error']['message']}" if payload["error"]
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");

function call(string $path, $body = null, ?string $method = null, array $extra = []) {
  global $TOKEN;
  $ch = curl_init(API . $path);
  $headers = array_merge(["Authorization: Bearer {$TOKEN}"], $extra);
  if ($body !== null) {
    $headers[] = "Content-Type: application/json";
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
  }
  if ($method !== null) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
  curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $payload = json_decode(curl_exec($ch), true);
  curl_close($ch);
  if (isset($payload["error"])) {
    throw new Exception($payload["error"]["code"] . ": " . $payload["error"]["message"]);
  }
  return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

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

  public static async Task<JsonElement> Call(string path, object? body = null,
      HttpMethod? method = null, (string, string)[]? extra = null) {
    var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Api + path);
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    foreach (var (k, v) in extra ?? Array.Empty<(string, string)>()) req.Headers.Add(k, v);
    if (body is not null)
      req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    var res = await Http.SendAsync(req);
    using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    var root = doc.RootElement.Clone();
    if (root.TryGetProperty("error", out var err))
      throw new Exception(err.GetProperty("code").GetString());
    return root.GetProperty("data").Clone();
  }
}

2. Get a token

A guest token is enough for /me and the free /estimate. Metered runs need a personal token, which the token page issues after sign-in — that page is the supported, no-DevTools way to get one, and it hands you a copyable value. Never open the browser console to fish a token out of storage. It is the exact gesture every session-stealing scam asks for, so treat any instruction to do it — including one that appears to come from us — as hostile. The slug goes in the request body — an X-App-Slug header returns 400.

# A guest token. The slug goes in the BODY - an X-App-Slug header 400s.
curl -sS -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"mrr-desk"}'
# -> {"data":{"token":"sk_app_...","subject_type":"guest",...}}
#
# For a personal token, open https://mrr-desk.skillsafe.ai/tokens.html
# and copy it there. Do not open the browser console for this, ever.
# Guest token: no Authorization header on this one call.
tok = call("/guest", {"slug": "mrr-desk"}, token="")["token"]
print(tok[:8] + "...")
# A personal token comes from https://mrr-desk.skillsafe.ai/tokens.html
# Never read it out of the browser console.
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "mrr-desk" }),
})).json();
TOKEN = guest.data.token;
// A personal token comes from https://mrr-desk.skillsafe.ai/tokens.html
// Never read it out of the browser console.
raw, err := call("/guest", map[string]string{"slug": "mrr-desk"}, "POST", nil)
if err != nil {
	panic(err)
}
var g struct {
	Token string `json:"token"`
}
json.Unmarshal(raw, &g)
token = g.Token
// Personal tokens: https://mrr-desk.skillsafe.ai/tokens.html - not the console.
String guest = call("/guest", "{\"slug\":\"mrr-desk\"}");
// pull data.token out of the envelope and assign it to `token`
// Personal tokens: https://mrr-desk.skillsafe.ai/tokens.html - not the console.
token = call("/guest", { "slug" => "mrr-desk" })["token"]
# Personal tokens: https://mrr-desk.skillsafe.ai/tokens.html - not the console.
$guest = call("/guest", ["slug" => "mrr-desk"]);
$TOKEN = $guest["token"];
// Personal tokens: https://mrr-desk.skillsafe.ai/tokens.html - not the console.
var guest = await MrrDesk.Call("/guest", new { slug = "mrr-desk" });
// guest.GetProperty("token").GetString()
// Personal tokens: https://mrr-desk.skillsafe.ai/tokens.html - not the console.

3. Check the session

GET /me returns subject_type (user or guest), subject_id and credits. Compare that balance against min_credits from step 5 before you submit anything.

curl -sS "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"subject_type":"user","subject_id":"...","credits":1240}}
me = call("/me")
print(me["subject_type"], me["credits"])
const me = await call("/me");
console.log(me.subject_type, me.credits);
raw, _ := call("/me", nil, "GET", nil)
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int    `json:"credits"`
}
json.Unmarshal(raw, &me)
String me = call("/me", null);
// read data.subject_type and data.credits
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await MrrDesk.Call("/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());

4. Build the input

The run input is two keys, plus one optional third. Everything numeric lives in facts; table_excerpt is the pasted movement table itself, clipped on whole-row boundaries with the header kept, so the model can see the raw rows behind the numbers. cohort_excerpt is sent only when a cohort triangle was supplied, and retry_note only on a reformat retry after a malformed reply. Keys beginning with an underscore are internal bookkeeping and are stripped before submission — do not send them.

{
  "facts": { ... the whole measurement, exactly as the engine produced it ... },
  "table_excerpt": "month,beginning_mrr,new,expansion,contraction,churn,ending_mrr\n2025-01,...",
  "cohort_excerpt": "cohort,m0,m1,m2,...\n2024-01,...",     // only when a triangle was supplied
  "retry_note": "the previous reply was not valid JSON ..."  // only on a reformat retry
}
There is no endpoint that returns facts. The measurement is computed client-side by the free engine (metrickit.js, analyze({company, table, cohorts, gross_margin})) and it is computed in the browser at no cost. A programmatic caller has two honest options: replicate the movement-table arithmetic yourself — foot each month from its own components against a precision-derived tolerance, check the chain, then derive the retention and efficiency figures — or drive the page and take the measurement JSON it exports. Anything else means sending the model numbers nothing re-counted, and the checker in step 8 has nothing to check against.

The facts object

Field for field, this is what the app sends. Anything you omit the model simply cannot use — and facts, commentary_ids, admissible and excluded_rows are what make the reply checkable, so they are not optional in practice.

{
  "ok": true,
  "measurable": true,                  // false when nothing could be reconciled at all
  "company": "Northwind Cloud / FY2025",

  // THE MEASURED FACTS. One per finding, and the unit of commentary.
  "facts": [{
    "id": "F1",
    "metric": "ndr",                   // or gdr, quick_ratio, growth, rule40, ltv,
                                       // ltv_cac, efficiency, attribution,
                                       // "integrity:does_not_foot", "refused:quick_ratio", ...
    "measured": "Net dollar retention across 2025-01 to 2025-08 is 104.2% ...",
    "band": "healthy",                 // or null where no band applies
    "admissible_causes": ["expansion", "contraction", "churned"]
  }],
  "commentary_ids": ["F1", "F2", "F3"], // EXACTLY one note per id, no more, no fewer
  "admissible": { "F1": ["expansion", "contraction", "churned"], "F2": ["no movement large enough to name"] },

  // THE WINDOW. Every figure in the review should trace back to here or to a month.
  "window": {
    "from": "2025-01", "to": "2025-08", "n": 8,
    "beginning_mrr": 412000.0, "ending_mrr": 498310.0,
    "new_mrr": 74200.0, "expansion_mrr": 51100.0, "reactivation_mrr": 6300.0,
    "contraction_mrr": 21400.0, "churned_mrr": 23890.0, "net_new": 86310.0,
    "ndr_core": 1.0721, "ndr_react": 1.0874, "gdr": 0.8901,
    "expansion_rate": 0.1240, "quick_ratio": 2.77,
    "compound_mom": 0.0242, "annualised_growth": 0.3323,
    "rule40": 0.2823, "rule40_margin": -0.0500, "rule40_margin_month": "2025-08"
  },

  // ONE ENTRY PER MONTH, already reconciled.
  "months": [{
    "id": "M1", "month": "2025-01",
    "beginning_mrr": 412000.0, "new_mrr": 9100.0, "expansion_mrr": 6200.0,
    "reactivation_mrr": 0.0, "contraction_mrr": 2600.0, "churned_mrr": 3100.0,
    "ending_mrr": 421600.0,
    "residual": 0.0,                   // stated ending minus the re-added ending
    "tol": 0.01,                       // built from the decimals the row was written to
    "foots": true,
    "ndr_core": 1.0087, "gdr": 0.9861, "quick_ratio": 2.68
  }],

  "integrity": [{ "code": "does_not_foot", "month": "2025-05", "detail": "..." }],
                                       // code is one of gap | does_not_foot | chain_break | negative_inflow
  "refusals": [{ "id": "R1", "metric": "quick_ratio", "month": "2025-03", "reason": "no churn and no contraction: the ratio is unbounded, not excellent" }],
  "excluded_rows": [{ "line": 14, "text": "Q2 subtotal", "reason": "the month could not be read" }],
  "identities": [ ... each proved relationship, e.g. NDR minus GDR equals the expansion rate ... ],
  "ltv": {
    "closed_form": 18400.0, "finite_horizon": 14120.0,
    "error_abs": 4280.0, "error_pct": 0.2326,
    "monthly_churn_rate": 0.0142, "cac": 5200.0, "payback": 11.4, "ltv_cac": 3.54,
    "refused": null                    // or the reason, with the figures null
  },
  "quarters": [{ "q": "2025-Q2", "net_new_arr": 512000.0, "burn_multiple": 1.42, "magic_number": 0.86 }],
  "cohort": { ... the retention triangle reduced to a hazard curve, or null ... },
  "attribution": { "measurable": true, ... what moved retention, by removal-and-remeasure ... },
  "data_notes": ["contraction was written negative in 3 rows and was read as a magnitude"],
  "segments": [{ "name": "Enterprise", ... }]
}

Two members are for the app's own checker rather than the model and are stripped before submission: figures, the grounding pool of every value the engine printed, and any key whose name starts with an underscore.

# The measurement is produced by the free engine. Export it from the app
# (the measurement JSON download) into facts.json, keep the raw paste in
# table.csv, and assemble the run input with jq. Nothing here is charged.
jq -n \
  --slurpfile facts facts.json \
  --rawfile table table.csv \
  '{facts: $facts[0], table_excerpt: $table}' > input.json

# Optional third key, only when you actually have a triangle:
#   jq '. + {cohort_excerpt: $c}' --rawfile c cohorts.csv input.json
import json

facts = json.load(open("facts.json"))          # from the free engine
table = open("table.csv").read()

MAX_EXCERPT, MAX_COHORT = 24000, 6000

def clip(text, cap):
    """Clip on whole-row boundaries and keep the header row."""
    if len(text) <= cap:
        return text
    lines = text.splitlines()
    head, rest = lines[0], lines[1:]
    kept, total = [], len(head)
    for line in rest:
        if total + len(line) + 1 > cap:
            break
        kept.append(line)
        total += len(line) + 1
    return "\n".join([head] + kept)

run_input = {"facts": facts, "table_excerpt": clip(table, MAX_EXCERPT)}
# cohorts are optional; send the key only when there is one
# run_input["cohort_excerpt"] = clip(cohorts, MAX_COHORT)
const MAX_EXCERPT = 24000, MAX_COHORT = 6000;

function clip(text, cap) {
  if (text.length <= cap) return text;
  const lines = text.split("\n");
  const head = lines.shift();
  const kept = [];
  let total = head.length;
  for (const line of lines) {
    if (total + line.length + 1 > cap) break;
    kept.push(line);
    total += line.length + 1;
  }
  return [head, ...kept].join("\n");
}

const runInput = { facts, table_excerpt: clip(tableText, MAX_EXCERPT) };
if (cohortText && cohortText.trim()) runInput.cohort_excerpt = clip(cohortText, MAX_COHORT);
// never send keys beginning with an underscore
const maxExcerpt, maxCohort = 24000, 6000

func clip(text string, cap int) string {
	if len(text) <= cap {
		return text
	}
	lines := strings.Split(text, "\n")
	head, kept, total := lines[0], []string{}, len(lines[0])
	for _, line := range lines[1:] {
		if total+len(line)+1 > cap {
			break
		}
		kept = append(kept, line)
		total += len(line) + 1
	}
	return head + "\n" + strings.Join(kept, "\n")
}

runInput := map[string]any{
	"facts":         facts, // decoded from the free engine's measurement JSON
	"table_excerpt": clip(tableText, maxExcerpt),
}
if strings.TrimSpace(cohortText) != "" {
	runInput["cohort_excerpt"] = clip(cohortText, maxCohort)
}
static String clip(String text, int cap) {
  if (text.length() <= cap) return text;
  String[] lines = text.split("\n", -1);
  StringBuilder out = new StringBuilder(lines[0]);
  int total = lines[0].length();
  for (int i = 1; i < lines.length; i++) {
    if (total + lines[i].length() + 1 > cap) break;
    out.append("\n").append(lines[i]);
    total += lines[i].length() + 1;
  }
  return out.toString();
}

// Then build {"facts": ..., "table_excerpt": ...} with your JSON library.
// facts comes from the free engine's measurement JSON, unaltered.
MAX_EXCERPT = 24_000
MAX_COHORT  = 6_000

def clip(text, cap)
  return text if text.length <= cap
  head, *rest = text.split("\n")
  kept = []
  total = head.length
  rest.each do |line|
    break if total + line.length + 1 > cap
    kept << line
    total += line.length + 1
  end
  ([head] + kept).join("\n")
end

run_input = { "facts" => JSON.parse(File.read("facts.json")),
              "table_excerpt" => clip(table_text, MAX_EXCERPT) }
run_input["cohort_excerpt"] = clip(cohort_text, MAX_COHORT) unless cohort_text.to_s.strip.empty?
const MAX_EXCERPT = 24000;
const MAX_COHORT  = 6000;

function clip(string $text, int $cap): string {
  if (strlen($text) <= $cap) return $text;
  $lines = explode("\n", $text);
  $head = array_shift($lines);
  $kept = [];
  $total = strlen($head);
  foreach ($lines as $line) {
    if ($total + strlen($line) + 1 > $cap) break;
    $kept[] = $line;
    $total += strlen($line) + 1;
  }
  return implode("\n", array_merge([$head], $kept));
}

$runInput = [
  "facts" => json_decode(file_get_contents("facts.json"), true),
  "table_excerpt" => clip($tableText, MAX_EXCERPT),
];
if (trim($cohortText) !== "") { $runInput["cohort_excerpt"] = clip($cohortText, MAX_COHORT); }
const int MaxExcerpt = 24000, MaxCohort = 6000;

static string Clip(string text, int cap) {
  if (text.Length <= cap) return text;
  var lines = text.Split('\n');
  var sb = new StringBuilder(lines[0]);
  var total = lines[0].Length;
  for (var i = 1; i < lines.Length; i++) {
    if (total + lines[i].Length + 1 > cap) break;
    sb.Append('\n').Append(lines[i]);
    total += lines[i].Length + 1;
  }
  return sb.ToString();
}

var runInput = new Dictionary<string, object> {
  ["facts"] = facts,                       // from the free engine, unaltered
  ["table_excerpt"] = Clip(tableText, MaxExcerpt),
};
if (!string.IsNullOrWhiteSpace(cohortText))
  runInput["cohort_excerpt"] = Clip(cohortText, MaxCohort);
Refusals are values. A quick ratio in a month with no churn and no contraction, a burn multiple on a quarter that went backwards, a lifetime value on a book that has not lost a dollar — each comes back in refusals with its metric, its period and its reason, and each becomes a measured fact in its own right. Send the refusal through as it stands. Filling it in with something plausible is the one thing this contract exists to prevent.

5. Estimate first — it is free

/estimate creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits (a worst-case reservation, priced at the full output cap) and min_credits. Only what a run actually uses is charged, so charged_credits is usually far below the hold.

call /estimate "$(cat input.json)"
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#     "markup_bps":1000,"hold_credits":...,"min_credits":...}}
# Free: no job is created and nothing is charged.
est = call("/estimate", run_input)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"])
if me["credits"] < est["min_credits"]:
    raise SystemExit("top up first - a 402 after submit is a preflight you skipped")
const est = await call("/estimate", runInput);
if (me.credits < est.min_credits) throw new Error("balance below min_credits");
console.log("reserves", est.hold_credits, "on", est.model, est.model_alias, est.markup_bps);
raw, _ = call("/estimate", runInput, "POST", nil)
var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int    `json:"hold_credits"`
	MinCredits  int    `json:"min_credits"`
}
json.Unmarshal(raw, &est)
String est = call("/estimate", inputJson);
// compare data.min_credits against the balance from /me before running;
// data.hold_credits is the worst-case reservation, not the charge
est = call("/estimate", run_input)
abort "top up first" if me["credits"] < est["min_credits"]
puts "#{est['model']} holds #{est['hold_credits']}"
$est = call("/estimate", $runInput);
if ($me["credits"] < $est["min_credits"]) { throw new Exception("top up first"); }
echo $est["model"], " holds ", $est["hold_credits"], "\n";
var est = await MrrDesk.Call("/estimate", runInput);
var hold = est.GetProperty("hold_credits").GetInt32();
var min  = est.GetProperty("min_credits").GetInt32();

6. Run it

Send an Idempotency-Key on every run: a content hash of the input plus one nonce per user gesture. A reformat retry after a malformed reply must reuse the same key, or a bad first answer costs twice. The reply is a job; poll GET /jobs/{job_id} until status is succeeded or failed, then parse output_text as JSON.

# Idempotency-Key is a content hash of the input plus one nonce per
# gesture. Reuse it for a retry and the platform replays the job it
# already has instead of billing a second one.
KEY="$(shasum -a 256 input.json | cut -c1-32)-1"
curl -sS -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json

# The reply is a job. Poll until it is terminal.
curl -sS "$API/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, json, time

key = hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:32] + "-1"
job = call("/run", run_input, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed"):
    time.sleep(1.0)
    job = call("/jobs/" + job["job_id"], method="GET")
if job["status"] == "failed":
    raise SystemExit(job.get("error") or "the run failed")
result = json.loads(job["output_text"])
const key = "run-" + Date.now().toString(36) + "-1";   // one nonce per gesture
let job = await call("/run", runInput, "POST", { "Idempotency-Key": key });
while (job.status !== "succeeded" && job.status !== "failed") {
  await new Promise((r) => setTimeout(r, 1000));
  job = await call("/jobs/" + job.job_id);
}
if (job.status === "failed") throw new Error(job.error || "the run failed");
const result = JSON.parse(job.output_text);
key := fmt.Sprintf("%x-1", sha256.Sum256(bodyBytes))[:34]
raw, _ = call("/run", runInput, "POST", map[string]string{"Idempotency-Key": key})
var job struct {
	JobID      string `json:"job_id"`
	Status     string `json:"status"`
	OutputText string `json:"output_text"`
}
json.Unmarshal(raw, &job)
for job.Status != "succeeded" && job.Status != "failed" {
	time.Sleep(time.Second)
	raw, _ = call("/jobs/"+job.JobID, nil, "GET", nil)
	json.Unmarshal(raw, &job)
}
// Add the header on the run request:
//   .header("Idempotency-Key", key)
String job = call("/run", inputJson);
// read data.job_id, then poll GET /jobs/{job_id} until status is
// "succeeded" or "failed"; the reply text is data.output_text
require "digest"

key = Digest::SHA256.hexdigest(JSON.generate(run_input))[0, 32] + "-1"
job = call("/run", run_input, "POST", { "Idempotency-Key" => key })
until %w[succeeded failed].include?(job["status"])
  sleep 1
  job = call("/jobs/#{job['job_id']}")
end
abort(job["error"] || "the run failed") if job["status"] == "failed"
result = JSON.parse(job["output_text"])
$key = substr(hash("sha256", json_encode($runInput)), 0, 32) . "-1";
$job = call("/run", $runInput, null, ["Idempotency-Key: {$key}"]);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
  sleep(1);
  $job = call("/jobs/" . $job["job_id"]);
}
$result = json_decode($job["output_text"], true);
var key = Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(
        JsonSerializer.SerializeToUtf8Bytes(runInput)))[..32] + "-1";
var job = await MrrDesk.Call("/run", runInput, null, new[] { ("Idempotency-Key", key) });
var jobId = job.GetProperty("job_id").GetString();
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
  await Task.Delay(1000);
  job = await MrrDesk.Call("/jobs/" + jobId);
}
var result = JsonDocument.Parse(job.GetProperty("output_text").GetString()!);

7. Or stream it

Same body, same key. The frame name arrives on the event: line and the payload on the following data: line — parsing only data: and guessing the type is the usual bug. A stream that ends early is still worth rendering: trim the buffer back to the last complete member, close it, and show what arrived rather than discarding it.

curl -N -sS -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json
# The frame NAME arrives on the "event:" line, the payload on "data:".
# event: delta   -> {"text":"..."}      append it
# event: done    -> the terminal job object
# event: error   -> {"code":"...","message":"..."}
import json, urllib.request

req = urllib.request.Request(API + "/run-stream",
                             data=json.dumps(run_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)

buf, event, job = "", None, None
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if line.startswith("event:"):
            event = line[6:].strip()          # the frame NAME lives here
        elif line.startswith("data:"):
            payload = json.loads(line[5:].strip())
            if event == "delta":
                buf += payload.get("text", "")
            elif event == "done":
                job = payload
            elif event == "error":
                raise RuntimeError(payload["code"] + ": " + payload["message"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + TOKEN,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", pending = "", event = null;
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  pending += dec.decode(value, { stream: true });
  const lines = pending.split("\n");
  pending = lines.pop();
  for (const line of lines) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const payload = JSON.parse(line.slice(5).trim());
      if (event === "delta") buf += payload.text || "";
      else if (event === "error") throw new Error(payload.code + ": " + payload.message);
    }
  }
}
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("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:") && event == "delta":
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d)
		buf += d.Text
	}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(inputJson))
    .build();
HttpResponse<java.util.stream.Stream<String>> res =
    HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
final String[] event = { null };
res.body().forEach(line -> {
  if (line.startsWith("event:")) event[0] = line.substring(6).trim();
  else if (line.startsWith("data:") && "delta".equals(event[0]))
    buf.append(line.substring(5).trim());   // then pull .text with your JSON library
});
uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(run_input)

buf = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:") && event == "delta"
          buf << (JSON.parse(line[5..].strip)["text"] || "")
        end
      end
    end
  end
end
$buf = "";
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runInput));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  "Authorization: Bearer {$TOKEN}",
  "Content-Type: application/json",
  "Idempotency-Key: {$key}",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$buf, &$event) {
  foreach (explode("\n", $chunk) as $line) {
    if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); }
    elseif (str_starts_with($line, "data:") && $event === "delta") {
      $d = json_decode(trim(substr($line, 5)), true);
      $buf .= $d["text"] ?? "";
    }
  }
  return strlen($chunk);
});
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8, "application/json");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string? evt = null;
while (await sr.ReadLineAsync() is { } line) {
  if (line.StartsWith("event:")) evt = line[6..].Trim();     // the frame NAME
  else if (line.StartsWith("data:") && evt == "delta") {
    using var d = JsonDocument.Parse(line[5..].Trim());
    buf.Append(d.RootElement.GetProperty("text").GetString());
  }
}

8. Check the reply before you use it

The output contract

Exactly this shape and nothing outside it. The parser reads these fields and no others; a missing cause stays null rather than being defaulted to an admissible one, because defaulting it would put a claim on the review that the model never made. An action given as a bare string is read as {"fact_id":"BOOK","action": …}.

{
  "title": "string",
  "headline": "string (3-5 sentences)",
  "notes": [
    { "fact_id": "F1",
      "cause": "string, VERBATIM from that fact's admissible_causes",
      "note": "string - one line naming a mechanism, not the measurement again" }
  ],
  "trajectory": "string",
  "actions": [
    { "fact_id": "F3 or BOOK", "action": "string a founder can act on" }
  ],
  "unverified": ["string - one entry naming each excluded row as `line N`"]
}

The rules the checker enforces

A programmatic caller is checked the same way the app checks itself. These are the rules, and they are worth copying because they catch the failure modes that matter: a measured fact quietly skipped, and a cause the numbers never supported.

RuleWhy
Exactly one entry in notes per id in facts.commentary_ids — none missing, none duplicated, and no id that is not a measured fact.Reported as three separate failures rather than one coverage verdict: a duplicate and an invented id are different bugs, fixed differently.
cause is present and appears verbatim in facts.admissible[fact_id].The admissible set is computed from the numbers before the run. A cause outside it is an invention. A missing cause is left unresolved, never filled in.
The sole-cause branch. Where admissible_causes is exactly ["no movement large enough to name"], cause must be that exact string and note must be exactly the measurement admits no cause for this (compared case-insensitively).When the measurement admits nothing, any other answer is a claim the numbers do not carry. The required wording makes that visible instead of letting a fluent sentence stand in for a finding.
Between three and six actions, each with a fact_id that is either a real measured fact id or the literal BOOK.An action attached to nothing cannot be traced back to a number. BOOK is the escape hatch for advice about the business as a whole.
Every row in facts.excluded_rows is named in unverified as line N, matched by line number.The rows the engine threw away are the single easiest thing for a review to hide, so they are tested by line number rather than by vocabulary.
Every figure in the reply traces to a value the engine measured or to one in the pasted table, at the precision it was written to.The grounding pool is deduped at six decimals, not two, so an engine's own printed 2.29% is never reported as an invention because the pool only held 2.3.
A note that only restates the fact's own measured sentence is a warning, not a failure.It is not a lie, but it is not commentary either — the note is supposed to name a mechanism the measurement does not already say.
# The reply is only usable once it has been checked against facts.
# Four checks carry almost all the weight:
#   1. exactly one entry per id in facts.commentary_ids
#   2. every cause is verbatim in facts.admissible[<that id>]
#   3. the sole-cause branch uses the required wording
#   4. every excluded row is named in unverified as "line N"
python3 - <<'PY'
import json, re
facts = json.load(open("input.json"))["facts"]
out = json.load(open("output.json"))
NONE_CAUSE = "no movement large enough to name"
NONE_NOTE = "the measurement admits no cause for this"
ids = [n["fact_id"] for n in out["notes"]]
print("missing", [i for i in facts["commentary_ids"] if i not in ids])
print("dupes", sorted({i for i in ids if ids.count(i) > 1}))
print("alien", [i for i in ids if i not in facts["commentary_ids"]])
bad = []
for n in out["notes"]:
    allowed = facts["admissible"].get(n["fact_id"], [])
    if allowed == [NONE_CAUSE]:
        if n["cause"] != NONE_CAUSE or (n["note"] or "").lower() != NONE_NOTE:
            bad.append(n["fact_id"])
    elif n["cause"] not in allowed:
        bad.append(n["fact_id"])
print("inadmissible", bad)
blob = " \n ".join(out["unverified"])
print("unnamed", [r["line"] for r in facts["excluded_rows"]
                  if not re.search(r"\bline\s+%d\b" % r["line"], blob, re.I)])
PY
import re

NONE_CAUSE = "no movement large enough to name"
NONE_NOTE = "the measurement admits no cause for this"

def check(facts, out):
    problems = []
    ids = [n["fact_id"] for n in out["notes"]]

    # 1. exactly one entry per measured fact
    for want in facts["commentary_ids"]:
        n = ids.count(want)
        if n == 0:
            problems.append("missing " + want)
        elif n > 1:
            problems.append("duplicated " + want)
    for got in ids:
        if got not in facts["commentary_ids"]:
            problems.append("not a measured fact: " + (got or "(blank)"))

    # 2 and 3. the cause, and the sole-cause branch
    for item in out["notes"]:
        allowed = facts["admissible"].get(item["fact_id"], [])
        if not item.get("cause"):
            problems.append("no cause for " + item["fact_id"])
            continue
        if allowed == [NONE_CAUSE]:
            if item["cause"] != NONE_CAUSE or item["note"].lower() != NONE_NOTE:
                problems.append("wrong wording on " + item["fact_id"])
            continue
        if item["cause"] not in allowed:
            problems.append(item["fact_id"] + " -> " + item["cause"] + " is not permitted")

    # 4. actions
    for a in out["actions"]:
        if a["fact_id"] != "BOOK" and a["fact_id"] not in facts["commentary_ids"]:
            problems.append("action on " + (a["fact_id"] or "(blank)"))
    if not 3 <= len(out["actions"]) <= 6:
        problems.append("%d actions, expected 3 to 6" % len(out["actions"]))

    # 5. excluded rows, by line number
    blob = " \n ".join(out["unverified"])
    for row in facts["excluded_rows"]:
        if not re.search(r"\bline\s+%d\b" % row["line"], blob, re.I):
            problems.append("excluded line %d is never named" % row["line"])
    return problems
const NONE_CAUSE = "no movement large enough to name";
const NONE_NOTE = "the measurement admits no cause for this";

function check(facts, out) {
  const problems = [];
  const ids = out.notes.map((n) => n.fact_id);

  for (const want of facts.commentary_ids) {
    const n = ids.filter((i) => i === want).length;
    if (n === 0) problems.push("missing " + want);
    else if (n > 1) problems.push("duplicated " + want);
  }
  for (const got of ids) {
    if (!facts.commentary_ids.includes(got)) problems.push("not a measured fact: " + (got || "(blank)"));
  }

  for (const item of out.notes) {
    const allowed = facts.admissible[item.fact_id] || [];
    if (!item.cause) { problems.push("no cause for " + item.fact_id); continue; }
    if (allowed.length === 1 && allowed[0] === NONE_CAUSE) {
      if (item.cause !== NONE_CAUSE || item.note.toLowerCase() !== NONE_NOTE)
        problems.push("wrong wording on " + item.fact_id);
      continue;
    }
    if (!allowed.includes(item.cause))
      problems.push(item.fact_id + " -> " + item.cause + " is not permitted");
  }

  for (const a of out.actions) {
    if (a.fact_id !== "BOOK" && !facts.commentary_ids.includes(a.fact_id))
      problems.push("action on " + (a.fact_id || "(blank)"));
  }
  if (out.actions.length < 3 || out.actions.length > 6)
    problems.push(out.actions.length + " actions, expected 3 to 6");

  const blob = out.unverified.join(" \n ");
  for (const row of facts.excluded_rows) {
    if (!new RegExp("\\bline\\s+" + row.line + "\\b", "i").test(blob))
      problems.push("excluded line " + row.line + " is never named");
  }
  return problems;
}
const noneCause = "no movement large enough to name"
const noneNote = "the measurement admits no cause for this"

func check(facts Facts, out Output) []string {
	var problems []string
	count := map[string]int{}
	for _, n := range out.Notes {
		count[n.FactID]++
	}
	for _, want := range facts.CommentaryIDs {
		switch count[want] {
		case 0:
			problems = append(problems, "missing "+want)
		case 1:
		default:
			problems = append(problems, "duplicated "+want)
		}
	}
	for _, n := range out.Notes {
		allowed := facts.Admissible[n.FactID]
		if n.Cause == "" {
			problems = append(problems, "no cause for "+n.FactID)
			continue
		}
		if len(allowed) == 1 && allowed[0] == noneCause {
			if n.Cause != noneCause || strings.ToLower(n.Note) != noneNote {
				problems = append(problems, "wrong wording on "+n.FactID)
			}
			continue
		}
		if !slices.Contains(allowed, n.Cause) {
			problems = append(problems, n.FactID+" -> "+n.Cause+" is not permitted")
		}
	}
	for _, a := range out.Actions {
		if a.FactID != "BOOK" && !slices.Contains(facts.CommentaryIDs, a.FactID) {
			problems = append(problems, "action on "+a.FactID)
		}
	}
	if len(out.Actions) < 3 || len(out.Actions) > 6 {
		problems = append(problems, "expected 3 to 6 actions")
	}
	blob := strings.Join(out.Unverified, " \n ")
	for _, row := range facts.ExcludedRows {
		re := regexp.MustCompile(`(?i)\bline\s+` + strconv.Itoa(row.Line) + `\b`)
		if !re.MatchString(blob) {
			problems = append(problems, fmt.Sprintf("excluded line %d is never named", row.Line))
		}
	}
	return problems
}
static final String NONE_CAUSE = "no movement large enough to name";
static final String NONE_NOTE = "the measurement admits no cause for this";

List<String> problems = new ArrayList<>();
Map<String, Long> counts = out.notes.stream()
    .collect(Collectors.groupingBy(n -> n.factId, Collectors.counting()));
for (String want : facts.commentaryIds) {
  long n = counts.getOrDefault(want, 0L);
  if (n == 0) problems.add("missing " + want);
  else if (n > 1) problems.add("duplicated " + want);
}
for (var item : out.notes) {
  List<String> allowed = facts.admissible.getOrDefault(item.factId, List.of());
  if (item.cause == null || item.cause.isBlank()) {
    problems.add("no cause for " + item.factId);
  } else if (allowed.size() == 1 && NONE_CAUSE.equals(allowed.get(0))) {
    if (!NONE_CAUSE.equals(item.cause) || !NONE_NOTE.equals(item.note.toLowerCase()))
      problems.add("wrong wording on " + item.factId);
  } else if (!allowed.contains(item.cause)) {
    problems.add(item.factId + " -> " + item.cause + " is not permitted");
  }
}
for (var a : out.actions) {
  if (!"BOOK".equals(a.factId) && !facts.commentaryIds.contains(a.factId))
    problems.add("action on " + a.factId);
}
if (out.actions.size() < 3 || out.actions.size() > 6) problems.add("expected 3 to 6 actions");
if (!problems.isEmpty()) throw new IllegalStateException(String.join("; ", problems));
NONE_CAUSE = "no movement large enough to name"
NONE_NOTE  = "the measurement admits no cause for this"

def check(facts, out)
  problems = []
  ids = out["notes"].map { |n| n["fact_id"] }

  facts["commentary_ids"].each do |want|
    n = ids.count(want)
    problems << "missing #{want}" if n.zero?
    problems << "duplicated #{want}" if n > 1
  end
  ids.each do |got|
    problems << "not a measured fact: #{got}" unless facts["commentary_ids"].include?(got)
  end

  out["notes"].each do |item|
    allowed = facts["admissible"][item["fact_id"]] || []
    if item["cause"].to_s.empty?
      problems << "no cause for #{item['fact_id']}"
    elsif allowed == [NONE_CAUSE]
      unless item["cause"] == NONE_CAUSE && item["note"].downcase == NONE_NOTE
        problems << "wrong wording on #{item['fact_id']}"
      end
    elsif !allowed.include?(item["cause"])
      problems << "#{item['fact_id']} -> #{item['cause']} is not permitted"
    end
  end

  out["actions"].each do |a|
    unless a["fact_id"] == "BOOK" || facts["commentary_ids"].include?(a["fact_id"])
      problems << "action on #{a['fact_id']}"
    end
  end
  problems << "expected 3 to 6 actions" unless (3..6).cover?(out["actions"].length)

  blob = out["unverified"].join(" \n ")
  facts["excluded_rows"].each do |row|
    problems << "excluded line #{row['line']} is never named" unless blob.match?(/\bline\s+#{row['line']}\b/i)
  end
  problems
end
const NONE_CAUSE = "no movement large enough to name";
const NONE_NOTE  = "the measurement admits no cause for this";

function check(array $facts, array $out): array {
  $problems = [];
  $ids = array_column($out["notes"], "fact_id");

  foreach ($facts["commentary_ids"] as $want) {
    $n = count(array_keys($ids, $want, true));
    if ($n === 0) { $problems[] = "missing {$want}"; }
    elseif ($n > 1) { $problems[] = "duplicated {$want}"; }
  }
  foreach ($ids as $got) {
    if (!in_array($got, $facts["commentary_ids"], true)) { $problems[] = "not a measured fact: {$got}"; }
  }

  foreach ($out["notes"] as $item) {
    $allowed = $facts["admissible"][$item["fact_id"]] ?? [];
    if (empty($item["cause"])) {
      $problems[] = "no cause for {$item['fact_id']}";
    } elseif ($allowed === [NONE_CAUSE]) {
      if ($item["cause"] !== NONE_CAUSE || strtolower($item["note"]) !== NONE_NOTE) {
        $problems[] = "wrong wording on {$item['fact_id']}";
      }
    } elseif (!in_array($item["cause"], $allowed, true)) {
      $problems[] = "{$item['fact_id']} -> {$item['cause']} is not permitted";
    }
  }

  foreach ($out["actions"] as $a) {
    if ($a["fact_id"] !== "BOOK" && !in_array($a["fact_id"], $facts["commentary_ids"], true)) {
      $problems[] = "action on {$a['fact_id']}";
    }
  }
  $n = count($out["actions"]);
  if ($n < 3 || $n > 6) { $problems[] = "{$n} actions, expected 3 to 6"; }

  $blob = implode(" \n ", $out["unverified"]);
  foreach ($facts["excluded_rows"] as $row) {
    if (!preg_match("/\\bline\\s+{$row['line']}\\b/i", $blob)) {
      $problems[] = "excluded line {$row['line']} is never named";
    }
  }
  return $problems;
}
const string NoneCause = "no movement large enough to name";
const string NoneNote  = "the measurement admits no cause for this";

var problems = new List<string>();
var ids = output.Notes.Select(n => n.FactId).ToList();

foreach (var want in facts.CommentaryIds) {
  var n = ids.Count(i => i == want);
  if (n == 0) problems.Add($"missing {want}");
  else if (n > 1) problems.Add($"duplicated {want}");
}
foreach (var got in ids)
  if (!facts.CommentaryIds.Contains(got)) problems.Add($"not a measured fact: {got}");

foreach (var item in output.Notes) {
  var allowed = facts.Admissible.GetValueOrDefault(item.FactId) ?? new List<string>();
  if (string.IsNullOrWhiteSpace(item.Cause)) {
    problems.Add($"no cause for {item.FactId}");
  } else if (allowed.Count == 1 && allowed[0] == NoneCause) {
    if (item.Cause != NoneCause || item.Note.ToLowerInvariant() != NoneNote)
      problems.Add($"wrong wording on {item.FactId}");
  } else if (!allowed.Contains(item.Cause)) {
    problems.Add($"{item.FactId} -> {item.Cause} is not permitted");
  }
}

foreach (var a in output.Actions)
  if (a.FactId != "BOOK" && !facts.CommentaryIds.Contains(a.FactId))
    problems.Add($"action on {a.FactId}");
if (output.Actions.Count < 3 || output.Actions.Count > 6)
  problems.Add("expected 3 to 6 actions");

var blob = string.Join(" \n ", output.Unverified);
foreach (var row in facts.ExcludedRows)
  if (!Regex.IsMatch(blob, $@"\bline\s+{row.Line}\b", RegexOptions.IgnoreCase))
    problems.Add($"excluded line {row.Line} is never named");

if (problems.Count > 0) throw new Exception("the reply does not honour the contract");
A reply that fails the format check can be resubmitted once with a retry_note explaining what was wrong — reusing the same Idempotency-Key is what stops a bad first answer costing twice. A reply that fails the contract checks is a different matter: do not paper over it, name the failing ids and let a human look.

What is free, and what is metered

Almost all of MRR Desk costs nothing and never touches this API. The whole measurement — the parsing, the footing of every month, the chain check, net and gross dollar retention, the quick ratio, growth, lifetime value with its approximation error, CAC payback, the magic number, the burn multiple, the Rule of 40, the cohort hazard curve, the driver simulation and every refusal — is computed in the browser at no cost and with no account. So are all four outputs built from it: the review markdown, the movements CSV, the facts CSV and the full measurement JSON. No model call, nothing charged.

Only the commentary is metered — the pass that gives each measured fact a cause and a note, and writes the headline, the trajectory and the actions. That is what /run and /run-stream buy you, and it is the only part of the app that costs a credit. If all you want is the numbers, you never need a token at all.