Returns Desk

Case, grade, action, fraud, disposition, refund — called like a returns operations manager.

Back to SkillSafe

Work return cases from your own code

Everything the Returns Desk app does goes through the SkillSafe App API: plain JSON over HTTPS, one bearer token, no SDK required. Paste a case in, get back a worked assessment — the case classified, the item graded, one next action called, the fraud signals read, the disposition argued and the refund figure stated.

This page is the whole surface: the routes, the exact fields the app itself sends, the output contract the reply is parsed from, and the error codes. Every step below is shown in eight languages — pick one and the whole page follows.

Base URL and the envelope

Base URL: https://api.skillsafe.ai/v1/app-api. Send Authorization: Bearer <token> on every call except /guest, and Content-Type: application/json on every body.

Every response is wrapped the same way:

{"ok": true,  "data": { ... }, "meta": { ... }}
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}

Check ok, then read data. The examples below all unwrap it in their client helper.

The routes

There is no /apps/{slug}/ path segment. The slug is bound to the token once, at POST /guest; after that the token is the app. If you find yourself building a URL with returns-desk in the path, that is the 404 you are about to get.

RouteWhat it does
POST /v1/app-api/guestBody {"slug":"returns-desk"}. Returns token, guest_id. No auth header needed — this is what mints one.
GET /v1/app-api/mesubject_type ("user" or "guest"), subject_id, credits.
POST /v1/app-api/estimateFree. No job, no charge, no hold. Returns model, model_alias, markup_bps, hold_credits, min_credits, sponsor_enabled.
POST /v1/app-api/runReturns job_id. Accepts Idempotency-Key.
GET /v1/app-api/jobs/{job_id}Poll until status is succeeded or failed.
POST /v1/app-api/run-streamThe same run, answered as Server-Sent Events. Accepts Idempotency-Key.

The input object

The body of /estimate and /run is the input object directly — it is not wrapped in {"input": …}.

FieldTypeWhat it carries
casestring, required The pasted return case: the RMA ticket, the customer emails, the order lines and values, the receiving and inspection notes, serials, the account's return history, the warranty terms. Messy dumps beat polished summaries — the dates, serials and values are what the decision is argued from. The app clips this at 40,000 characters by dropping the middle and keeping both ends, leaving an in-band [case truncated: N characters removed from the middle…] marker so the model knows what it cannot see. Do the same rather than a blind head slice.
contextstring, optional Policy in force (window, fees, receipt rules), channel and category, customer standing, and what decision is actually needed. Clipped the same way at 6,000 characters. Whatever you leave out comes back as an open question rather than an assumption.
factsstring, optional Deterministic text the app's in-browser calculator produces: the category and grade selected, the item value, the restocking fee and net refund, the ten-signal fraud score with its band, the standard disposition, the vendor recovery band, and any input sitting near a threshold. It is a hint, not a fact — it reflects what somebody ticked, and the assessment is free to contradict it (the app surfaces exactly that disagreement in its reconciliation panel).
retry_notestring, optional App-internal. The app sends it only on its automatic one-shot reformat retry, when the first reply failed to parse; it restates the required output shape. A direct API caller normally omits it.

Error codes

Failures come back as {"ok": false, "error": {"code", "message", "details"}}. Branch on code, not on the message text.

CodeHTTPWhat to do
unauthorized401Missing or expired token. Mint a new guest token, or re-copy your personal one from the token page.
not_found404Wrong route. You probably inserted an /apps/{slug}/ segment that does not exist — or the job id is wrong.
payment_required402Not enough credits for the hold. Top up, or check min_credits from /estimate first.
validation_error400Bad input shape. Usually a missing case, or the input wrapped in {"input": …} when it should be sent directly.
rate_limited429Back off and retry — exponential, with jitter. Reuse the same Idempotency-Key so the retry cannot double-bill.
sponsor_exhausted402The day's sponsored guest allowance is used up. Sign in and run on your own credits.

1. A tiny client helper

Every call below is one HTTP request with a bearer token and a JSON body, so start with a small helper that adds the header, sends the body and unwraps the envelope. Later steps reuse it. Browsers enforce CORS on this API — run these from a server, a script or a terminal, not from another site's frontend.

API="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"          # step 2 mints one

# Every call is one of these two shapes:
#   curl -s "$API/me" -H "Authorization: Bearer $TOKEN"
#   curl -s -X POST "$API/run" -H "Authorization: Bearer $TOKEN" \
#        -H "Content-Type: application/json" -d @input.json
#
# Success:  {"ok":true,"data":{ ... },"meta":{ ... }}
# Failure:  {"ok":false,"error":{"code":"...","message":"...","details":{ ... }}}
#
# jq '.data' pulls the payload out of the envelope.
import json, urllib.request, urllib.error

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # step 2 mints one


def api(method, path, body=None, headers=None):
    data = None if body is None else json.dumps(body).encode("utf-8")
    req = urllib.request.Request(API + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    req.add_header("Content-Type", "application/json")
    for k, v in (headers or {}).items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req) as res:
            payload = json.load(res)
    except urllib.error.HTTPError as exc:
        payload = json.load(exc)
    if not payload.get("ok"):
        err = payload.get("error") or {}
        raise RuntimeError(err.get("code", "error") + ": " + err.get("message", ""))
    return payload["data"]
// Node 18+ (built-in fetch), or any server-side runtime you control.
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // step 2 mints one

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 payload = await res.json();
  if (!payload.ok) {
    const err = payload.error || {};
    throw new Error(`${err.code || res.status}: ${err.message || res.statusText}`);
  }
  return payload.data;
}
package main

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

const (
	API   = "https://api.skillsafe.ai/v1/app-api"
	Token = "YOUR_TOKEN" // step 2 mints one
)

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

func call(method, path string, body, out any, extra map[string]string) error {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return err
		}
	}
	req, err := http.NewRequest(method, API+path, &buf)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+Token)
	req.Header.Set("Content-Type", "application/json")
	for k, v := range extra {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return err
	}
	if !env.OK {
		return fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair it with your JSON library of choice
// (Jackson, Gson, org.json) 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;
import java.util.Map;

public final class ReturnsDesk {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = "YOUR_TOKEN"; // step 2 mints one
    static final HttpClient HTTP = HttpClient.newHttpClient();

    /** Returns the raw envelope: {"ok":true,"data":{...}} or {"ok":false,"error":{...}}. */
    static String api(String method, String path, String jsonBody, Map<String, String> extra)
            throws Exception {
        var builder = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody));
        extra.forEach(builder::header);
        var res = HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) {
            throw new RuntimeException("api " + method + " " + path + ": " + res.body());
        }
        return res.body();
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # step 2 mints one

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  extra.each { |k, v| req[k] = v }
  req.body = JSON.generate(body) if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  unless payload["ok"]
    err = payload["error"] || {}
    raise "#{err["code"]}: #{err["message"]}"
  end
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // step 2 mints one

function api(string $method, string $path, ?array $body = null, array $extra = []): mixed {
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer " . TOKEN,
            "Content-Type: application/json",
        ], $extra),
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new RuntimeException(($payload["error"]["code"] ?? "error") . ": " .
            ($payload["error"]["message"] ?? "request failed"));
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

static class ReturnsDesk
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    const string Token = "YOUR_TOKEN"; // step 2 mints one
    static readonly HttpClient Http = new();

    static ReturnsDesk() =>
        Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);

    public static async Task<JsonElement> ApiAsync(
        HttpMethod method, string path, object? body = null,
        (string Name, string Value)? extra = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        if (extra.HasValue) req.Headers.Add(extra.Value.Name, extra.Value.Value);

        var res = await Http.SendAsync(req);
        var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!payload.GetProperty("ok").GetBoolean())
        {
            var err = payload.GetProperty("error");
            throw new Exception($"{err.GetProperty("code")}: {err.GetProperty("message")}");
        }
        return payload.GetProperty("data");
    }
}

2. Get a token

Two ways, and neither involves a browser console.

The slug is bound to the token here. That is the only place returns-desk appears in the whole API — there is no /apps/{slug}/ path segment on any other route.

# Guest token - the slug is bound to the token at this call and nowhere else.
TOKEN=$(curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"returns-desk"}' | jq -r '.data.token')

echo "${TOKEN:0:8}..."   # never echo the whole thing into a log

# Personal token instead: https://returns-desk.skillsafe.ai/tokens.html
# -> sign in -> "Copy shell export" -> paste the export line into this shell.
import json, urllib.request

req = urllib.request.Request(
    API + "/guest",
    data=json.dumps({"slug": "returns-desk"}).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(req) as res:
    guest = json.load(res)["data"]

TOKEN = guest["token"]          # rebind the module constant used by api()
print("guest id:", guest["guest_id"])

# For a personal token: https://returns-desk.skillsafe.ai/tokens.html
# -> sign in -> "Copy token", and paste it into TOKEN instead.
// /guest takes no Authorization header - it is what mints one.
const res = await fetch(`${API}/guest`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "returns-desk" }),
});
const { ok, data: guest, error } = await res.json();
if (!ok) throw new Error(`${error.code}: ${error.message}`);

console.log(guest.guest_id);
// guest.token is the bearer token every later call sends.
// Personal token instead: https://returns-desk.skillsafe.ai/tokens.html
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}

// /guest ignores the Authorization header call() sends; that is harmless.
if err := call("POST", "/guest", map[string]string{"slug": "returns-desk"}, &guest, nil); err != nil {
	log.Fatal(err)
}
fmt.Println("guest id:", guest.GuestID)

// guest.Token is the bearer token. For a personal token, visit
// https://returns-desk.skillsafe.ai/tokens.html and copy it from there.
String envelope = ReturnsDesk.api("POST", "/guest",
    "{\"slug\":\"returns-desk\"}", Map.of());

// data.token   - the bearer token every later call sends
// data.guest_id - identifies the guest wallet
//
// Personal token instead: https://returns-desk.skillsafe.ai/tokens.html
// -> sign in -> "Copy token", and use it as ReturnsDesk.TOKEN.
guest = api("POST", "/guest", { slug: "returns-desk" })

token = guest["token"]
puts "guest id: #{guest["guest_id"]}"

# Personal token instead: https://returns-desk.skillsafe.ai/tokens.html
# -> sign in -> "Copy shell export".
$guest = api("POST", "/guest", ["slug" => "returns-desk"]);

$token = $guest["token"];              // the bearer token
echo "guest id: {$guest["guest_id"]}\n";

// Personal token instead: https://returns-desk.skillsafe.ai/tokens.html
var guest = await ReturnsDesk.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "returns-desk" });

var token = guest.GetProperty("token").GetString();
Console.WriteLine($"guest id: {guest.GetProperty("guest_id")}");

// Personal token instead: https://returns-desk.skillsafe.ai/tokens.html

3. Check who you are and what you can spend

GET /me tells you which kind of token you are holding and what it can spend: subject_type is "user" for a signed-in account or "guest" for a guest token, and credits is the balance. Worth checking before a batch.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'

# {"subject_type":"guest","subject_id":"gst_...","credits":0}
me = api("GET", "/me")
print(me["subject_type"], me["credits"])

if me["subject_type"] == "guest":
    print("guest runs depend on the day's sponsorship budget - see step 4")
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);

if (me.subject_type === "guest") {
  console.log("guest runs depend on the day's sponsorship budget - see step 4");
}
var me struct {
	SubjectType string  `json:"subject_type"`
	SubjectID   string  `json:"subject_id"`
	Credits     float64 `json:"credits"`
}
if err := call("GET", "/me", nil, &me, nil); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s: %.2f credits\n", me.SubjectType, me.SubjectID, me.Credits)
String envelope = ReturnsDesk.api("GET", "/me", null, Map.of());

// data.subject_type - "user" or "guest"
// data.subject_id
// data.credits      - spendable balance
System.out.println(envelope);
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"

puts "guest runs depend on the day's sponsorship budget" if me["subject_type"] == "guest"
$me = api("GET", "/me");
echo "{$me["subject_type"]}: {$me["credits"]} credits\n";

if ($me["subject_type"] === "guest") {
    echo "guest runs depend on the day's sponsorship budget - see step 4\n";
}
var me = await ReturnsDesk.ApiAsync(HttpMethod.Get, "/me");

Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

4. Price the run before you make it

POST /estimate takes the same body as /run. It is free: it creates no job, charges nothing, and holds nothing. Use it to see which model you are pointed at and what the run would cost before you commit to it.

Response fieldWhat it is
modelThe exact model id the run would use.
model_aliasThe stable alias the app asked for.
markup_bpsPlatform markup in basis points.
hold_creditsWorst-case cost — what a run would place on hold.
min_creditsFloor you must have available for the run to start.
sponsor_enabledTrue when a guest token can run on the day's sponsored allowance.
cat > input.json <<'JSON'
{
  "case": "RMA-88213. Ordered 2026-03-02, delivered 2026-03-09, return opened 2026-03-11.\nCustomer: \"camera is defective, shutter sticks\". Receiving notes: body serial VR-8841203 does not match the shipped serial VR-8839117; 21,438 shutter actuations logged; box opened, foam missing.\nOrder line: 1 x mirrorless body, $1,249.00. Account has 6 returns in 12 months.",
  "context": "30-day window, 15% restocking fee outside 14 days, fee waived on genuine defects. Channel: web. Category: electronics. Customer is a 2-year account, $8k lifetime. Decision needed: refund or deny by Friday.",
  "facts": "Category: Electronics. Grade: Grade C. Item value: $1,249.00. Restocking fee 15% = $187.35, net refund $1,061.65. Fraud score 72 of 100 (flag for review). Standard disposition: refurbish. Vendor recovery band: always pursue (over $500)."
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json \
  | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits, sponsor_enabled}'

# Free: no job is created and nothing is charged.
INPUT = {
    # required - paste the record, not a summary
    "case": open("case.txt", encoding="utf-8").read(),
    # optional - policy in force, channel, category, customer standing, decision needed
    "context": "30-day window, 15% restocking fee outside 14 days, waived on genuine defects. "
               "Electronics, web channel, 2-year account. Refund or deny by Friday.",
    # optional - a HINT, not a fact. The app fills this from its in-browser calculator.
    "facts": "Category: Electronics. Grade: Grade C. Item value: $1,249.00. "
             "Fraud score 72 of 100 (flag for review). Standard disposition: refurbish.",
}

est = api("POST", "/estimate", INPUT)

assert est["model"], "no model resolved"
assert est["model_alias"], "no model alias resolved"
assert isinstance(est["markup_bps"], int), "markup_bps should be an integer"

print(est["model"], "via", est["model_alias"], "markup", est["markup_bps"], "bps")
print("hold:", est["hold_credits"], "min:", est["min_credits"],
      "sponsored:", est["sponsor_enabled"])
# Nothing was charged and no job exists - /estimate is free.
const input = {
  // required - the pasted return case
  case: await readFile("case.txt", "utf8"),
  // optional - policy in force, channel and category, customer standing, decision needed
  context:
    "30-day window, 15% restocking fee outside 14 days, waived on genuine defects. " +
    "Electronics, web channel, 2-year account. Refund or deny by Friday.",
  // optional - a HINT, not a fact: the app derives it from its in-browser calculator
  facts:
    "Category: Electronics. Grade: Grade C. Item value: $1,249.00. " +
    "Fraud score 72 of 100 (flag for review). Standard disposition: refurbish.",
};

const est = await api("POST", "/estimate", input);

if (!est.model || !est.model_alias) throw new Error("no model resolved");
console.log(est.model, est.model_alias, est.markup_bps);
console.log("hold", est.hold_credits, "min", est.min_credits, "sponsored", est.sponsor_enabled);
// Free: no job, no charge, no hold.
type Input struct {
	Case      string `json:"case"`
	Context   string `json:"context,omitempty"`
	Facts     string `json:"facts,omitempty"`
	RetryNote string `json:"retry_note,omitempty"` // app-internal; leave empty
}

input := Input{
	Case:    string(caseBytes),
	Context: "30-day window, 15% restocking fee outside 14 days, waived on genuine defects.",
	Facts:   "Category: Electronics. Grade: Grade C. Fraud score 72 of 100 (flag for review).",
}

var est struct {
	Model          string  `json:"model"`
	ModelAlias     string  `json:"model_alias"`
	MarkupBps      int     `json:"markup_bps"`
	HoldCredits    float64 `json:"hold_credits"`
	MinCredits     float64 `json:"min_credits"`
	SponsorEnabled bool    `json:"sponsor_enabled"`
}
if err := call("POST", "/estimate", input, &est, nil); err != nil {
	log.Fatal(err)
}
if est.Model == "" || est.ModelAlias == "" {
	log.Fatal("no model resolved")
}
fmt.Printf("%s (%s) markup %d bps, hold %.2f\n",
	est.Model, est.ModelAlias, est.MarkupBps, est.HoldCredits)
// Free: /estimate creates no job and charges nothing.
// Build the body with your JSON library; the input object goes in DIRECTLY,
// it is not wrapped in {"input": ...}.
String body = """
    {
      "case": %s,
      "context": %s,
      "facts": %s
    }""".formatted(json(caseText), json(contextText), json(factsText));

String envelope = ReturnsDesk.api("POST", "/estimate", body, Map.of());

// data.model          - exact model id
// data.model_alias    - stable alias
// data.markup_bps     - platform markup, basis points
// data.hold_credits   - worst-case cost
// data.min_credits    - floor needed to start
// data.sponsor_enabled - guest tokens can run free today
//
// /estimate is free: no job is created and nothing is charged.
System.out.println(envelope);
input = {
  # required
  "case" => File.read("case.txt"),
  # optional
  "context" => "30-day window, 15% restocking fee outside 14 days, waived on genuine defects. " \
               "Electronics, web channel, 2-year account. Refund or deny by Friday.",
  # optional - a HINT, not a fact
  "facts" => "Category: Electronics. Grade: Grade C. Item value: $1,249.00. " \
             "Fraud score 72 of 100 (flag for review)."
}

est = api("POST", "/estimate", input)

raise "no model resolved" unless est["model"] && est["model_alias"]
puts "#{est["model"]} (#{est["model_alias"]}) markup #{est["markup_bps"]} bps"
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} sponsored #{est["sponsor_enabled"]}"
# Free: no job, no charge.
$input = [
    // required
    "case" => file_get_contents("case.txt"),
    // optional
    "context" => "30-day window, 15% restocking fee outside 14 days, waived on genuine defects. "
        . "Electronics, web channel, 2-year account. Refund or deny by Friday.",
    // optional - a HINT, not a fact
    "facts" => "Category: Electronics. Grade: Grade C. Item value: $1,249.00. "
        . "Fraud score 72 of 100 (flag for review).",
];

$est = api("POST", "/estimate", $input);

if (empty($est["model"]) || empty($est["model_alias"])) {
    throw new RuntimeException("no model resolved");
}
echo "{$est["model"]} ({$est["model_alias"]}) markup {$est["markup_bps"]} bps\n";
echo "hold {$est["hold_credits"]} min {$est["min_credits"]}\n";
// Free: no job is created and nothing is charged.
var input = new {
    // required
    @case = File.ReadAllText("case.txt"),
    // optional
    context = "30-day window, 15% restocking fee outside 14 days, waived on genuine defects. " +
              "Electronics, web channel, 2-year account. Refund or deny by Friday.",
    // optional - a HINT, not a fact
    facts = "Category: Electronics. Grade: Grade C. Item value: $1,249.00. " +
            "Fraud score 72 of 100 (flag for review).",
};

var est = await ReturnsDesk.ApiAsync(HttpMethod.Post, "/estimate", input);

if (est.GetProperty("model").GetString() is not { Length: > 0 } model ||
    est.GetProperty("model_alias").GetString() is not { Length: > 0 } alias)
{
    throw new Exception("no model resolved");
}
Console.WriteLine($"{model} ({alias}) markup {est.GetProperty("markup_bps")} bps");
Console.WriteLine($"hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
// Free: /estimate creates no job and charges nothing.

@case / "case" is a reserved word in several languages — quote or escape the key rather than renaming it. The wire field is case.

5. Run it, then poll the job

POST /run takes the same body, places a credit hold and returns a job_id. Poll GET /jobs/{job_id} every second or two until status is succeeded or failed. The assessment text lands at output.output as a plain string — no code fence, no JSON wrapper.

Always send an Idempotency-Key header. The app derives its key from a hash of case + context + facts plus an attempt counter, so a dropped connection or its own one-shot reformat retry can never bill twice. Do the same: one stable key per logical run.

KEY="returns-desk-$(shasum -a 256 input.json | cut -c1-16)-a1"

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

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  case "$STATUS" in succeeded|failed) break ;; esac
  sleep 2
done

echo "$JOB" | jq -r '.data.output.output'   # the assessment, as plain text
import hashlib, json, time

basis = INPUT["case"] + " " + INPUT.get("context", "") + " " + INPUT.get("facts", "")
key = "returns-desk-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16] + "-a1"

started = api("POST", "/run", INPUT, {"Idempotency-Key": key})
job_id = started["job_id"]

while True:
    job = api("GET", "/jobs/" + job_id)
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error") or "run failed")

out = job.get("output") or {}
text = out.get("output") if isinstance(out, dict) else out
print(text)                       # the assessment - see step 7 for parsing
print("charged:", job.get("charged_credits"))
import { createHash } from "node:crypto";

const basis = [input.case, input.context ?? "", input.facts ?? ""].join(" ");
const key = "returns-desk-" + createHash("sha256").update(basis).digest("hex").slice(0, 16) + "-a1";

const { job_id } = await api("POST", "/run", input, { "Idempotency-Key": key });

let job;
for (;;) {
  job = await api("GET", `/jobs/${job_id}`);
  if (job.status === "succeeded" || job.status === "failed") break;
  await new Promise((r) => setTimeout(r, 1500));
}
if (job.status === "failed") throw new Error(job.error ?? "run failed");

const text = typeof job.output === "string" ? job.output : job.output?.output ?? "";
console.log(text);                 // the assessment - see step 7 for parsing
console.log("charged", job.charged_credits);
basis := input.Case + " " + input.Context + " " + input.Facts
sum := sha256.Sum256([]byte(basis))
key := fmt.Sprintf("returns-desk-%x-a1", sum[:8])

var started struct {
	JobID string `json:"job_id"`
}
if err := call("POST", "/run", input, &started,
	map[string]string{"Idempotency-Key": key}); err != nil {
	log.Fatal(err)
}

var job struct {
	Status         string `json:"status"`
	Error          string `json:"error"`
	ChargedCredits float64 `json:"charged_credits"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job, nil); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}
fmt.Println(job.Output.Output) // the assessment - see step 7 for parsing
var digest = java.security.MessageDigest.getInstance("SHA-256");
var basis = caseText + " " + contextText + " " + factsText;
var hex = java.util.HexFormat.of().formatHex(digest.digest(basis.getBytes("UTF-8")));
var key = "returns-desk-" + hex.substring(0, 16) + "-a1";

String started = ReturnsDesk.api("POST", "/run", body, Map.of("Idempotency-Key", key));
String jobId = /* data.job_id, via your JSON library */ null;

String job;
String status;
while (true) {
    job = ReturnsDesk.api("GET", "/jobs/" + jobId, null, Map.of());
    status = /* data.status */ null;
    if ("succeeded".equals(status) || "failed".equals(status)) break;
    Thread.sleep(1500);
}
// On success the assessment text is at data.output.output - a plain string.
// On failure data.error carries the reason.
require "digest"

basis = [input["case"], input["context"], input["facts"]].join(" ")
key = "returns-desk-#{Digest::SHA256.hexdigest(basis)[0, 16]}-a1"

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

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise(job["error"] || "run failed") if job["status"] == "failed"

text = job["output"].is_a?(Hash) ? job["output"]["output"] : job["output"]
puts text                       # the assessment - see step 7 for parsing
puts "charged: #{job["charged_credits"]}"
$basis = $input["case"] . " " . ($input["context"] ?? "") . " " . ($input["facts"] ?? "");
$key = "returns-desk-" . substr(hash("sha256", $basis), 0, 16) . "-a1";

$started = api("POST", "/run", $input, ["Idempotency-Key: $key"]);

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

if ($job["status"] === "failed") {
    throw new RuntimeException($job["error"] ?? "run failed");
}

$text = is_array($job["output"]) ? ($job["output"]["output"] ?? "") : $job["output"];
echo $text;                       // the assessment - see step 7 for parsing
echo "\ncharged: {$job["charged_credits"]}\n";
var basis = string.Join(" ", input.@case, input.context, input.facts);
var hash = Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(basis)));
var key = $"returns-desk-{hash[..16].ToLowerInvariant()}-a1";

var started = await ReturnsDesk.ApiAsync(HttpMethod.Post, "/run", input,
    ("Idempotency-Key", key));
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await ReturnsDesk.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}
if (job.GetProperty("status").GetString() == "failed")
    throw new Exception(job.GetProperty("error").GetString());

var text = job.GetProperty("output").GetProperty("output").GetString();
Console.WriteLine(text); // the assessment - see step 7 for parsing

6. Or stream it

POST /run-stream is the same call with the same body and the same Idempotency-Key, answered as text/event-stream. That is what the app itself uses, which is why the verdict appears line by line. Events:

EventPayload
job{"job_id": "…"} — fired once, as soon as the job exists.
delta{"text": "…"} — append it; the concatenation is the whole reply.
done{"job_id", "status", "charged_credits", "truncated", "output"}
error{"code", "message", "job_id"} — the stream ends here.

An idempotent replay is answered as ordinary JSON rather than SSE, so check the response's Content-Type before you start parsing events.

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

# event: job
# data: {"job_id":"job_01H..."}
#
# event: delta
# data: {"text":"CASE: Fraud review\n"}
#
# event: done
# data: {"job_id":"job_01H...","status":"succeeded","charged_credits":18.4,"output":{"output":"CASE: ..."}}
# pip install requests
import json, requests

with requests.post(API + "/run-stream", json=INPUT, stream=True, headers={
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
}) as res:
    if "text/event-stream" not in res.headers.get("content-type", ""):
        # idempotent replay - a plain JSON envelope, not a stream
        text = res.json()["data"]["output"]["output"]
    else:
        event, chunks, done = "message", [], None
        for line in res.iter_lines(decode_unicode=True):
            if line is None or line == "":
                event = "message"
                continue
            if line.startswith("event:"):
                event = line[6:].strip()
            elif line.startswith("data:"):
                payload = json.loads(line[5:].strip())
                if event == "delta":
                    chunks.append(payload.get("text", ""))
                elif event == "done":
                    done = payload
                elif event == "error":
                    raise RuntimeError(payload["code"] + ": " + payload["message"])
        text = "".join(chunks)

print(text)
const res = await fetch(`${API}/run-stream`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(input),
});

if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
  // idempotent replay - a plain JSON envelope, not a stream
  const payload = await res.json();
  console.log(payload.data.output.output);
} else {
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let text = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let i;
    while ((i = buffer.indexOf("\n\n")) >= 0) {
      const frame = buffer.slice(0, i);
      buffer = buffer.slice(i + 2);
      let event = "message";
      let data = "";
      for (const line of frame.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (!data) continue;
      const payload = JSON.parse(data);
      if (event === "delta") text += payload.text || "";
      else if (event === "error") throw new Error(`${payload.code}: ${payload.message}`);
    }
  }
  console.log(text);
}
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
	// idempotent replay - a plain JSON envelope, not a stream
	log.Fatal("replayed; decode the envelope instead")
}

var text strings.Builder
event := "message"
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
	line := scanner.Text()
	switch {
	case line == "":
		event = "message"
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var payload struct {
			Text    string `json:"text"`
			Code    string `json:"code"`
			Message string `json:"message"`
		}
		json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &payload)
		switch event {
		case "delta":
			text.WriteString(payload.Text)
		case "error":
			log.Fatalf("%s: %s", payload.Code, payload.Message)
		}
	}
}
fmt.Println(text.String())
var req = HttpRequest.newBuilder(URI.create(ReturnsDesk.API + "/run-stream"))
    .header("Authorization", "Bearer " + ReturnsDesk.TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

var res = ReturnsDesk.HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
var text = new StringBuilder();
var event = new String[] { "message" };

res.body().forEach(line -> {
    if (line.isEmpty()) {
        event[0] = "message";
    } else if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        // parse `data` with your JSON library
        if (event[0].equals("delta")) text.append(/* payload.text */ "");
        else if (event[0].equals("error")) throw new RuntimeException(data);
    }
});
System.out.println(text);
require "net/http"
require "json"

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(input)

text = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    unless res["content-type"].to_s.include?("text/event-stream")
      # idempotent replay - a plain JSON envelope, not a stream
      text = JSON.parse(res.body).dig("data", "output", "output").to_s
      next
    end
    event = "message"
    buffer = +""
    res.read_body do |chunk|
      buffer << chunk
      while (i = buffer.index("\n"))
        line = buffer.slice!(0, i + 1).chomp
        if line.empty?
          event = "message"
        elsif line.start_with?("event:")
          event = line[6..].strip
        elsif line.start_with?("data:")
          payload = JSON.parse(line[5..].strip)
          case event
          when "delta" then text << payload["text"].to_s
          when "error" then raise "#{payload["code"]}: #{payload["message"]}"
          end
        end
      end
    end
  end
end

puts text
$text = "";
$event = "message";
$buffer = "";

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . TOKEN,
        "Content-Type: application/json",
        "Idempotency-Key: $key",
        "Accept: text/event-stream",
    ],
    CURLOPT_POSTFIELDS => json_encode($input),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$event, &$buffer) {
        $buffer .= $chunk;
        while (($i = strpos($buffer, "\n")) !== false) {
            $line = rtrim(substr($buffer, 0, $i), "\r");
            $buffer = substr($buffer, $i + 1);
            if ($line === "") {
                $event = "message";
            } elseif (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $payload = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") {
                    $text .= $payload["text"] ?? "";
                } elseif ($event === "error") {
                    throw new RuntimeException($payload["code"] . ": " . $payload["message"]);
                }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

echo $text;
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
{
    Content = JsonContent.Create(input),
};
req.Headers.Add("Idempotency-Key", key);

using var res = await ReturnsDesk.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
    // idempotent replay - a plain JSON envelope, not a stream
    var replay = await res.Content.ReadFromJsonAsync<JsonElement>();
    Console.WriteLine(replay.GetProperty("data").GetProperty("output").GetProperty("output"));
    return;
}

using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new System.Text.StringBuilder();
var evt = "message";
while (await reader.ReadLineAsync() is { } line)
{
    if (line.Length == 0) { evt = "message"; continue; }
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); continue; }
    if (!line.StartsWith("data:")) continue;

    var payload = JsonDocument.Parse(line[5..].Trim()).RootElement;
    if (evt == "delta") text.Append(payload.GetProperty("text").GetString());
    else if (evt == "error")
        throw new Exception($"{payload.GetProperty("code")}: {payload.GetProperty("message")}");
}
Console.WriteLine(text);

7. Parse the reply back into fields

The reply is plain text in a fixed shape — not JSON, and never wrapped in a code fence. All five header lines and all six sections are required, in this order. Each section carries - bullets and nothing else, except ## Open questions, which may be the single line None.

CASE: <Standard return | Policy exception | Fraud review | Warranty claim | Vendor recovery | Recall return>
GRADE: <Grade A | Grade B | Grade C | Grade D | Not graded>
ACTION: <Approve the refund | Approve exchange or store credit | Approve with restocking fee | Deny the return | Hold for fraud review | Route to warranty | File a vendor claim | Insufficient information>
CONFIDENCE: <integer 0-100>
SUMMARY: <2 to 4 sentences>

## Immediate actions
- ...

## Eligibility and policy
- ...

## Grading and disposition
- ...

## Fraud check
- ...

## Refund and recovery
- ...

## Open questions
- ...        (or the single line: None.)

Parsing it is two regexes: one for the header lines, one to split on ^## . If a header line is missing, treat the reply as malformed rather than guessing — that is exactly when the app sends its one-shot retry_note reformat request.

# Header lines:
echo "$TEXT" | sed -n 's/^CASE: //p'
echo "$TEXT" | sed -n 's/^GRADE: //p'
echo "$TEXT" | sed -n 's/^ACTION: //p'
echo "$TEXT" | sed -n 's/^CONFIDENCE: //p'

# SUMMARY may wrap over several lines, so take everything up to the first "## ":
echo "$TEXT" | awk '/^SUMMARY: /{f=1} /^## /{f=0} f'

# One section, bullets only:
echo "$TEXT" | awk '/^## Immediate actions$/{f=1;next} /^## /{f=0} f'

# All six section headings, in order:
echo "$TEXT" | grep '^## '
import re

HEADERS = ("CASE", "GRADE", "ACTION", "CONFIDENCE")
SECTIONS = ("Immediate actions", "Eligibility and policy", "Grading and disposition",
            "Fraud check", "Refund and recovery", "Open questions")


def parse(text):
    out = {}
    for name in HEADERS:
        m = re.search(r"^" + name + r":[ \t]*(.+)$", text, re.M)
        if not m:
            raise ValueError("malformed reply: no " + name + " line")
        out[name.lower()] = m.group(1).strip()
    out["confidence"] = int(out["confidence"])

    m = re.search(r"^SUMMARY:[ \t]*(.*?)(?=\n##\s|\Z)", text, re.M | re.S)
    if not m:
        raise ValueError("malformed reply: no SUMMARY line")
    out["summary"] = " ".join(m.group(1).split())

    body = {}
    for heading, chunk in re.findall(r"^##[ \t]+(.+?)[ \t]*\n(.*?)(?=\n##\s|\Z)",
                                     text, re.M | re.S):
        lines = [ln.strip() for ln in chunk.strip().splitlines() if ln.strip()]
        body[heading] = [ln[2:].strip() for ln in lines if ln.startswith("- ")] or lines
    missing = [s for s in SECTIONS if s not in body]
    if missing:
        raise ValueError("malformed reply: missing " + ", ".join(missing))
    out["sections"] = body
    return out


verdict = parse(text)
print(verdict["case"], "/", verdict["grade"], "/", verdict["action"],
      "(" + str(verdict["confidence"]) + ")")
for bullet in verdict["sections"]["Immediate actions"]:
    print(" -", bullet)
const HEADERS = ["CASE", "GRADE", "ACTION", "CONFIDENCE"];
const SECTIONS = [
  "Immediate actions", "Eligibility and policy", "Grading and disposition",
  "Fraud check", "Refund and recovery", "Open questions",
];

function parse(text) {
  const out = {};
  for (const name of HEADERS) {
    const m = text.match(new RegExp(`^${name}:[ \t]*(.+)$`, "m"));
    if (!m) throw new Error(`malformed reply: no ${name} line`);
    out[name.toLowerCase()] = m[1].trim();
  }
  out.confidence = Number.parseInt(out.confidence, 10);

  const sm = text.match(/^SUMMARY:[ \t]*([\s\S]*?)(?=\n##\s|$)/m);
  if (!sm) throw new Error("malformed reply: no SUMMARY line");
  out.summary = sm[1].trim().replace(/\s+/g, " ");

  out.sections = {};
  const re = /^##[ \t]+(.+?)[ \t]*\n([\s\S]*?)(?=\n##\s|$)/gm;
  for (const m of text.matchAll(re)) {
    const lines = m[2].trim().split("\n").map((s) => s.trim()).filter(Boolean);
    const bullets = lines.filter((s) => s.startsWith("- ")).map((s) => s.slice(2).trim());
    out.sections[m[1]] = bullets.length ? bullets : lines;
  }
  const missing = SECTIONS.filter((s) => !(s in out.sections));
  if (missing.length) throw new Error(`malformed reply: missing ${missing.join(", ")}`);
  return out;
}

const verdict = parse(text);
console.log(verdict.case, verdict.grade, verdict.action, verdict.confidence);
console.log(verdict.sections["Immediate actions"]);
var (
	headerRe  = regexp.MustCompile(`(?m)^(CASE|GRADE|ACTION|CONFIDENCE):[ \t]*(.+)$`)
	sectionRe = regexp.MustCompile(`(?ms)^##[ \t]+(.+?)[ \t]*\n(.*?)(?:\n##\s|\z)`)
)

var wanted = []string{
	"Immediate actions", "Eligibility and policy", "Grading and disposition",
	"Fraud check", "Refund and recovery", "Open questions",
}

type Verdict struct {
	Case, Grade, Action, Summary string
	Confidence                   int
	Sections                     map[string][]string
}

func parse(text string) (*Verdict, error) {
	v := &Verdict{Sections: map[string][]string{}}
	for _, m := range headerRe.FindAllStringSubmatch(text, -1) {
		switch m[1] {
		case "CASE":
			v.Case = strings.TrimSpace(m[2])
		case "GRADE":
			v.Grade = strings.TrimSpace(m[2])
		case "ACTION":
			v.Action = strings.TrimSpace(m[2])
		case "CONFIDENCE":
			v.Confidence, _ = strconv.Atoi(strings.TrimSpace(m[2]))
		}
	}
	if v.Case == "" || v.Grade == "" || v.Action == "" {
		return nil, errors.New("malformed reply: missing a header line")
	}
	for _, m := range sectionRe.FindAllStringSubmatch(text, -1) {
		for _, line := range strings.Split(strings.TrimSpace(m[2]), "\n") {
			line = strings.TrimSpace(line)
			if strings.HasPrefix(line, "- ") {
				v.Sections[m[1]] = append(v.Sections[m[1]], strings.TrimSpace(line[2:]))
			}
		}
	}
	for _, name := range wanted {
		if _, ok := v.Sections[name]; !ok {
			return nil, fmt.Errorf("malformed reply: missing section %q", name)
		}
	}
	return v, nil
}
import java.util.*;
import java.util.regex.*;

record Verdict(String caseType, String grade, String action, int confidence,
               String summary, Map<String, List<String>> sections) {}

static final List<String> SECTIONS = List.of(
    "Immediate actions", "Eligibility and policy", "Grading and disposition",
    "Fraud check", "Refund and recovery", "Open questions");

static String header(String text, String name) {
    var m = Pattern.compile("(?m)^" + name + ":[ \\t]*(.+)$").matcher(text);
    if (!m.find()) throw new IllegalArgumentException("malformed reply: no " + name + " line");
    return m.group(1).trim();
}

static Verdict parse(String text) {
    var sections = new LinkedHashMap<String, List<String>>();
    var m = Pattern.compile("(?ms)^##[ \\t]+(.+?)[ \\t]*\\n(.*?)(?=\\n##\\s|\\z)").matcher(text);
    while (m.find()) {
        var bullets = new ArrayList<String>();
        for (String line : m.group(2).strip().split("\\n")) {
            line = line.strip();
            if (line.startsWith("- ")) bullets.add(line.substring(2).strip());
        }
        sections.put(m.group(1), bullets);
    }
    for (String name : SECTIONS) {
        if (!sections.containsKey(name)) {
            throw new IllegalArgumentException("malformed reply: missing " + name);
        }
    }
    var summary = Pattern.compile("(?ms)^SUMMARY:[ \\t]*(.*?)(?=\\n##\\s|\\z)").matcher(text);
    return new Verdict(header(text, "CASE"), header(text, "GRADE"), header(text, "ACTION"),
        Integer.parseInt(header(text, "CONFIDENCE")),
        summary.find() ? summary.group(1).replaceAll("\\s+", " ").strip() : "",
        sections);
}
SECTIONS = [
  "Immediate actions", "Eligibility and policy", "Grading and disposition",
  "Fraud check", "Refund and recovery", "Open questions"
].freeze

def parse(text)
  verdict = {}
  %w[CASE GRADE ACTION CONFIDENCE].each do |name|
    m = text[/^#{name}:[ \t]*(.+)$/, 1]
    raise "malformed reply: no #{name} line" unless m
    verdict[name.downcase.to_sym] = m.strip
  end
  verdict[:confidence] = verdict[:confidence].to_i

  summary = text[/^SUMMARY:[ \t]*(.*?)(?=\n\#\#\s|\z)/m, 1]
  raise "malformed reply: no SUMMARY line" unless summary
  verdict[:summary] = summary.split.join(" ")

  sections = {}
  text.scan(/^\#\#[ \t]+(.+?)[ \t]*\n(.*?)(?=\n\#\#\s|\z)/m) do |heading, chunk|
    sections[heading] = chunk.lines.map(&:strip).reject(&:empty?)
                             .select { |l| l.start_with?("- ") }
                             .map { |l| l[2..].strip }
  end
  missing = SECTIONS.reject { |s| sections.key?(s) }
  raise "malformed reply: missing #{missing.join(", ")}" unless missing.empty?

  verdict[:sections] = sections
  verdict
end

v = parse(text)
puts "#{v[:case]} / #{v[:grade]} / #{v[:action]} (#{v[:confidence]})"
v[:sections]["Immediate actions"].each { |b| puts " - #{b}" }
<?php
const SECTIONS = [
    "Immediate actions", "Eligibility and policy", "Grading and disposition",
    "Fraud check", "Refund and recovery", "Open questions",
];

function parseVerdict(string $text): array {
    $verdict = [];
    foreach (["CASE", "GRADE", "ACTION", "CONFIDENCE"] as $name) {
        if (!preg_match("/^$name:[ \t]*(.+)$/m", $text, $m)) {
            throw new RuntimeException("malformed reply: no $name line");
        }
        $verdict[strtolower($name)] = trim($m[1]);
    }
    $verdict["confidence"] = (int) $verdict["confidence"];

    if (!preg_match('/^SUMMARY:[ \t]*(.*?)(?=\n##\s|\z)/ms', $text, $m)) {
        throw new RuntimeException("malformed reply: no SUMMARY line");
    }
    $verdict["summary"] = preg_replace('/\s+/', " ", trim($m[1]));

    $sections = [];
    preg_match_all('/^##[ \t]+(.+?)[ \t]*\n(.*?)(?=\n##\s|\z)/ms', $text, $all, PREG_SET_ORDER);
    foreach ($all as $m) {
        $bullets = [];
        foreach (preg_split('/\n/', trim($m[2])) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "- ")) $bullets[] = trim(substr($line, 2));
        }
        $sections[$m[1]] = $bullets;
    }
    $missing = array_diff(SECTIONS, array_keys($sections));
    if ($missing) {
        throw new RuntimeException("malformed reply: missing " . implode(", ", $missing));
    }
    $verdict["sections"] = $sections;
    return $verdict;
}

$v = parseVerdict($text);
echo "{$v["case"]} / {$v["grade"]} / {$v["action"]} ({$v["confidence"]})\n";
using System.Text.RegularExpressions;

static readonly string[] Sections = {
    "Immediate actions", "Eligibility and policy", "Grading and disposition",
    "Fraud check", "Refund and recovery", "Open questions",
};

record Verdict(string Case, string Grade, string Action, int Confidence,
               string Summary, Dictionary<string, List<string>> Sections);

static string Header(string text, string name)
{
    var m = Regex.Match(text, $"^{name}:[ \t]*(.+)$", RegexOptions.Multiline);
    if (!m.Success) throw new Exception($"malformed reply: no {name} line");
    return m.Groups[1].Value.Trim();
}

static Verdict Parse(string text)
{
    var sections = new Dictionary<string, List<string>>();
    foreach (Match m in Regex.Matches(text, @"^\#\#[ \t]+(.+?)[ \t]*\n(.*?)(?=\n\#\#\s|\z)",
                 RegexOptions.Multiline | RegexOptions.Singleline))
    {
        var bullets = m.Groups[2].Value.Trim()
            .Split('\n').Select(s => s.Trim())
            .Where(s => s.StartsWith("- ")).Select(s => s[2..].Trim()).ToList();
        sections[m.Groups[1].Value] = bullets;
    }
    var missing = Sections.Where(s => !sections.ContainsKey(s)).ToList();
    if (missing.Count > 0) throw new Exception("malformed reply: missing " + string.Join(", ", missing));

    var sm = Regex.Match(text, @"^SUMMARY:[ \t]*(.*?)(?=\n\#\#\s|\z)",
        RegexOptions.Multiline | RegexOptions.Singleline);

    return new Verdict(Header(text, "CASE"), Header(text, "GRADE"), Header(text, "ACTION"),
        int.Parse(Header(text, "CONFIDENCE")),
        sm.Success ? Regex.Replace(sm.Groups[1].Value.Trim(), @"\s+", " ") : "",
        sections);
}