NCR Workbench — API

Work a nonconformance from your own code.

API tokens Open the app

Use the workbench from your own code

Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS, so you can script it from any language: batch a backlog of open NCRs, wire it into a QMS export, or re-run an assessment nightly as new inspection data lands. This page walks through each call with examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"ok":true,"data":…} on success, {"error":{"code","message"}} on failure.

There is no /apps/ncr-workbench/ path segment. The app is bound to the token itself, which is minted at POST /v1/app-api/guest with the slug — every later call is just /me, /estimate, /run or /run-stream.

CodeMeaningWhat to do
unauthorizedMissing, stale or revoked tokenMint a new guest token, or sign in for a personal one
not_foundThe route does not existCheck for a stray /apps/<slug>/ segment — that is the usual cause
payment_requiredBalance below the run minimumTop up; /estimate tells you the reserve and the minimum in advance
validation_errorBody is not the input objectSend the fields at the top level, not wrapped in {"input": …}
rate_limitedToo many requestsBack off and retry; do not tight-loop
/me, /estimate and /guest are free. /run and /run-stream spend credits.

The input this app sends

These are the exact fields the app's own run path submits.

FieldTypeNotes
ncrstring, requiredThe nonconformance material as pasted: form dump, inspection results, an SPC alert, a complaint thread. The app cuts the middle at 40,000 characters and keeps both ends, announcing the cut in-band; do the same rather than truncating the tail, because the ask and the history live at the end.
contextstring, optionalUp to 6,000 characters. The framework in force, the program, the decision needed, who reads the answer. Also where the app folds in answers to a previous run's open questions.
spcstring, optionalA plain-text I-MR block: n, mean, MRbar, sigma, control limits, Western Electric hits, Cp/Cpk and the raw values. Treated by the prompt as a hint to verify, never as fact. The app computes it in the browser; you can compute it yourself or omit it.
retry_notestring, optionalOnly used on the app's one automatic reformat retry: a verbatim restatement of the output shape, sent when the first reply failed to parse. Omit it on a first attempt.
Send an Idempotency-Key header on every run. The app derives it from a hash of the input plus the attempt number, so a dropped connection replays the same job instead of billing a second one.

The output contract

The reply is plain text — no JSON, no code fence around the whole response. A reply that breaks any of these rules is rejected by the app's parser, which then retries once.

SEVERITY: Critical | Major | Minor
DISPOSITION: Use as is | Rework | Repair | Scrap | Return to supplier | Insufficient information
CONFIDENCE: <integer 0-100, bare, no percent sign>
SUMMARY: <2-4 sentences, may wrap, ends at the first blank line>

## Containment
- …
## Root cause analysis
- …
## Disposition rationale
- …
## CAPA plan
- …
## Regulatory and escalation
- …
## Open questions
- …   (or the single bullet "- None.")
All four tag lines and all six headings are required, in that order. Every line inside a section is a - bullet; a bullet may wrap onto indented continuation lines. A section with nothing to report carries the single bullet - None.

1. Get a token

A guest token is one call and needs no account. For a personal token that bills your own credits, open the token page and sign in — it shows the token, copies it, and copies a ready-made shell export. You never need the browser developer console.

POST /v1/app-api/guest
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"

# Or mint a guest token, no account needed:
TOKEN=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug":"ncr-workbench"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["token"])')
echo "${TOKEN:0:8}..."
import json, urllib.request

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"   # or leave blank and mint a guest token below

def call(path, body=None, token=None, method=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"))
    req.add_header("Content-Type", "application/json")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        return json.load(r)["data"]

if TOKEN == "YOUR_TOKEN":
    TOKEN = call("/guest", {"slug": "ncr-workbench"})["token"]
print(TOKEN[:8] + "...")
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN";   // or mint a guest token below

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

if (TOKEN === "YOUR_TOKEN") {
  TOKEN = (await call("/guest", { slug: "ncr-workbench" })).token;
}
console.log(TOKEN.slice(0, 8) + "...");
package main

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

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

var token = "YOUR_TOKEN"

func call(path string, body any, out any) error {
    var buf *bytes.Buffer = bytes.NewBuffer(nil)
    method := "GET"
    if body != nil {
        method = "POST"
        b, _ := json.Marshal(body)
        buf = bytes.NewBuffer(b)
    }
    req, _ := http.NewRequest(method, API+path, buf)
    req.Header.Set("Content-Type", "application/json")
    if token != "YOUR_TOKEN" {
        req.Header.Set("Authorization", "Bearer "+token)
    }
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    var env struct {
        Data json.RawMessage `json:"data"`
    }
    if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
        return err
    }
    return json.Unmarshal(env.Data, out)
}

func main() {
    var guest struct{ Token string `json:"token"` }
    if token == "YOUR_TOKEN" {
        _ = call("/guest", map[string]string{"slug": "ncr-workbench"}, &guest)
        token = guest.Token
    }
    fmt.Println(token[:8] + "...")
}
import java.net.URI;
import java.net.http.*;

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

  static String call(String path, String body) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Content-Type", "application/json");
    if (!token.equals("YOUR_TOKEN")) b.header("Authorization", "Bearer " + token);
    b = body == null ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(body));
    return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
  }

  public static void main(String[] args) throws Exception {
    if (token.equals("YOUR_TOKEN")) {
      String res = call("/guest", "{\"slug\":\"ncr-workbench\"}");
      token = res.replaceAll(".*\"token\"\\s*:\\s*\"([^\"]+)\".*", "$1");
    }
    System.out.println(token.substring(0, 8) + "...");
  }
}
require "json"
require "net/http"

API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"

def call(path, body = nil, token: nil, method: nil)
  uri = URI(API.to_s + path)
  req = if body || method == "POST"
          Net::HTTP::Post.new(uri)
        else
          Net::HTTP::Get.new(uri)
        end
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}" if token
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  JSON.parse(res.body)["data"]
end

token = TOKEN == "YOUR_TOKEN" ? call("/guest", { slug: "ncr-workbench" })["token"] : TOKEN
puts token[0, 8] + "..."
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$token = "YOUR_TOKEN";

function call(string $path, ?array $body = null, ?string $token = null, string $method = null): array {
    $headers = ["Content-Type: application/json"];
    if ($token) { $headers[] = "Authorization: Bearer $token"; }
    $ch = curl_init(API . $path);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    } elseif ($method) {
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    }
    $raw = curl_exec($ch);
    curl_close($ch);
    return json_decode($raw, true)["data"];
}

if ($token === "YOUR_TOKEN") {
    $token = call("/guest", ["slug" => "ncr-workbench"])["token"];
}
echo substr($token, 0, 8) . "...\n";
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

const string API = "https://api.skillsafe.ai/v1/app-api";
string token = "YOUR_TOKEN";
var http = new HttpClient();

async Task<JsonElement> Call(string path, object? body = null, string? method = null) {
    var req = new HttpRequestMessage(
        body != null ? HttpMethod.Post : new HttpMethod(method ?? "GET"), API + path);
    if (body != null)
        req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    if (token != "YOUR_TOKEN")
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    var res = await http.SendAsync(req);
    var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    return doc.RootElement.GetProperty("data");
}

if (token == "YOUR_TOKEN") {
    var guest = await Call("/guest", new { slug = "ncr-workbench" });
    token = guest.GetProperty("token").GetString()!;
}
Console.WriteLine(token[..8] + "...");

2. Check who you are and what you can spend

Free. Returns subject_type (user or guest), subject_id and credits.

GET /v1/app-api/me
curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $TOKEN"
# {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
me = call("/me", token=TOKEN)
print(me["subject_type"], me["credits"])
const me = await call("/me");
console.log(me.subject_type, me.credits);
var me struct {
    SubjectType string `json:"subject_type"`
    Credits     int    `json:"credits"`
}
_ = call("/me", nil, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("/me", null));
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']}"
$me = call("/me", null, $token);
echo $me["subject_type"] . " " . $me["credits"] . "\n";
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

3. Estimate before you spend

Free, creates no job and charges nothing. hold_credits is what gets reserved, not the price — the actual charge is usually far lower, because the hold prices the full output cap. If your balance sits between min_credits and hold_credits the run still executes with a reduced cap and comes back with truncated: true. Compare the balance from step 2 against hold_credits before you submit; a 402 after the fact is avoidable.

POST /v1/app-api/estimate
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ncr":"NCR-25-0642 bore diameter drifting on 118 pcs ...","context":"IATF 16949, special characteristic","spc":""}'
# {"hold_credits":1578,"min_credits":149,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000}
payload = {
    "ncr": "NCR-25-0642 bore diameter drifting on 118 pcs ...",
    "context": "IATF 16949, special characteristic",
    "spc": "",
}
est = call("/estimate", payload, token=TOKEN)
print(est["hold_credits"], est["min_credits"], est["model"])
const payload = {
  ncr: "NCR-25-0642 bore diameter drifting on 118 pcs ...",
  context: "IATF 16949, special characteristic",
  spc: "",
};
const est = await call("/estimate", payload);
console.log(est.hold_credits, est.min_credits, est.model);
payload := map[string]string{
    "ncr":     "NCR-25-0642 bore diameter drifting on 118 pcs ...",
    "context": "IATF 16949, special characteristic",
    "spc":     "",
}
var est struct {
    HoldCredits int    `json:"hold_credits"`
    MinCredits  int    `json:"min_credits"`
    Model       string `json:"model"`
}
_ = call("/estimate", payload, &est)
fmt.Println(est.HoldCredits, est.MinCredits, est.Model)
String payload = "{\"ncr\":\"NCR-25-0642 bore diameter drifting on 118 pcs ...\","
    + "\"context\":\"IATF 16949, special characteristic\",\"spc\":\"\"}";
System.out.println(call("/estimate", payload));
payload = {
  ncr: "NCR-25-0642 bore diameter drifting on 118 pcs ...",
  context: "IATF 16949, special characteristic",
  spc: ""
}
est = call("/estimate", payload, token: token)
puts "#{est['hold_credits']} #{est['min_credits']} #{est['model']}"
$payload = [
    "ncr" => "NCR-25-0642 bore diameter drifting on 118 pcs ...",
    "context" => "IATF 16949, special characteristic",
    "spc" => "",
];
$est = call("/estimate", $payload, $token);
echo "{$est['hold_credits']} {$est['min_credits']} {$est['model']}\n";
var payload = new {
    ncr = "NCR-25-0642 bore diameter drifting on 118 pcs ...",
    context = "IATF 16949, special characteristic",
    spc = ""
};
var est = await Call("/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits")} {est.GetProperty("model")}");

4. Run it, and poll for the result

This spends credits. The body is the input object directly — not wrapped in {"input": …}. Send an Idempotency-Key: reuse the same key and the same job comes back instead of a second charge. /run returns a job_id; poll /jobs/{id} until status is succeeded or failed, then read output.output and parse it against the contract above.

POST /v1/app-api/run
GET /v1/app-api/jobs/{job_id}
KEY="ncr-workbench-$(printf '%s' "$NCR_TEXT" | shasum | cut -c1-8)-1"

JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

until curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
  -H "Authorization: Bearer $TOKEN" | grep -q '"status":"succeeded"'; do sleep 2; done

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
import hashlib, time, urllib.request

key = "ncr-workbench-" + hashlib.sha256(payload["ncr"].encode()).hexdigest()[:8] + "-1"

req = urllib.request.Request(API + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
    job_id = json.load(r)["data"]["job_id"]

while True:
    job = call("/jobs/" + job_id, token=TOKEN)
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

print(job["output"]["output"])
const enc = new TextEncoder().encode(payload.ncr);
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
  .map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 8);
const key = `ncr-workbench-${digest}-1`;

const res = await fetch(API + "/run", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});
const { data } = await res.json();

let job;
do {
  await new Promise((r) => setTimeout(r, 2000));
  job = await call("/jobs/" + data.job_id);
} while (job.status !== "succeeded" && job.status !== "failed");

console.log(job.output.output);
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "ncr-workbench-"+fingerprint+"-1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

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

var job struct {
    Status string `json:"status"`
    Output struct {
        Output string `json:"output"`
    } `json:"output"`
}
for {
    _ = call("/jobs/"+env.Data.JobID, nil, &job)
    if job.Status == "succeeded" || job.Status == "failed" {
        break
    }
    time.Sleep(2 * time.Second)
}
fmt.Println(job.Output.Output)
HttpRequest run = HttpRequest.newBuilder(URI.create(API + "/run"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", "ncr-workbench-" + fingerprint + "-1")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();
String created = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = created.replaceAll(".*\"job_id\"\\s*:\\s*\"([^\"]+)\".*", "$1");

String job;
do {
  Thread.sleep(2000);
  job = call("/jobs/" + jobId, null);
} while (!job.contains("\"status\":\"succeeded\"") && !job.contains("\"status\":\"failed\""));
System.out.println(job);
require "digest"

key = "ncr-workbench-#{Digest::SHA256.hexdigest(payload[:ncr])[0, 8]}-1"

uri = URI("#{API}/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]

loop do
  job = call("/jobs/#{job_id}", token: token)
  if %w[succeeded failed].include?(job["status"])
    puts job.dig("output", "output")
    break
  end
  sleep 2
end
$key = "ncr-workbench-" . substr(hash("sha256", $payload["ncr"]), 0, 8) . "-1";

$ch = curl_init(API . "/run");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Content-Type: application/json",
    "Authorization: Bearer $token",
    "Idempotency-Key: $key",
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);

do {
    sleep(2);
    $job = call("/jobs/$jobId", null, $token);
} while (!in_array($job["status"], ["succeeded", "failed"], true));

echo $job["output"]["output"] . "\n";
using System.Security.Cryptography;

var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload.ncr)))[..8].ToLower();
var runReq = new HttpRequestMessage(HttpMethod.Post, API + "/run") {
    Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
runReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
runReq.Headers.Add("Idempotency-Key", $"ncr-workbench-{hash}-1");
var created = JsonDocument.Parse(await (await http.SendAsync(runReq)).Content.ReadAsStringAsync());
var jobId = created.RootElement.GetProperty("data").GetProperty("job_id").GetString();

JsonElement job;
do {
    await Task.Delay(2000);
    job = await Call("/jobs/" + jobId);
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));

Console.WriteLine(job.GetProperty("output").GetProperty("output").GetString());

5. Stream it instead

This spends credits. /run-stream returns text/event-stream: delta events carry output as it generates and a final done event carries charged_credits, truncated and the authoritative full output. Prefer the done payload over the concatenated deltas — the stream can drop the tail. The same Idempotency-Key rule applies. In the browser, the vendored SDK wraps this: ss.runStream(input, { idempotencyKey, onDelta }).

POST /v1/app-api/run-stream
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json
# event: delta
# data: {"text":"SEVERITY: Major\n"}
# ...
# event: done
# data: {"job_id":"job_...","charged_credits":412,"truncated":false,"output":{"output":"SEVERITY: ..."}}
req = urllib.request.Request(API + "/run-stream", data=json.dumps(payload).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)

full = ""
with urllib.request.urlopen(req) as stream:
    for raw in stream:
        line = raw.decode().strip()
        if line.startswith("data:"):
            evt = json.loads(line[5:].strip())
            if "text" in evt:
                full += evt["text"]
            elif "output" in evt:
                full = evt["output"]["output"]   # authoritative
print(full)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", full = "";
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const lines = buf.split("\n");
  buf = lines.pop();
  for (const line of lines) {
    if (!line.startsWith("data:")) continue;
    const evt = JSON.parse(line.slice(5).trim());
    if (evt.text) full += evt.text;
    else if (evt.output) full = evt.output.output;   // authoritative
  }
}
console.log(full);
b, _ = json.Marshal(payload)
req, _ = http.NewRequest("POST", API+"/run-stream", bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

full := ""
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
    line := sc.Text()
    if !strings.HasPrefix(line, "data:") {
        continue
    }
    var evt struct {
        Text   string `json:"text"`
        Output *struct {
            Output string `json:"output"`
        } `json:"output"`
    }
    _ = json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &evt)
    if evt.Output != nil {
        full = evt.Output.Output
    } else {
        full += evt.Text
    }
}
fmt.Println(full)
HttpRequest stream = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

StringBuilder full = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body()
    .filter(l -> l.startsWith("data:"))
    .forEach(l -> full.append(l.substring(5).trim()).append("\n"));
System.out.println(full);   // parse each line as JSON; prefer the final done event
uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)

full = ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        next unless line.start_with?("data:")
        evt = JSON.parse(line[5..].strip) rescue next
        full = evt.dig("output", "output") || (full + evt.fetch("text", ""))
      end
    end
  end
end
puts full
$full = "";
$ch = curl_init(API . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Content-Type: application/json",
    "Authorization: Bearer $token",
    "Idempotency-Key: $key",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$full) {
    foreach (explode("\n", $chunk) as $line) {
        if (strpos($line, "data:") !== 0) { continue; }
        $evt = json_decode(trim(substr($line, 5)), true);
        if (isset($evt["output"]["output"])) { $full = $evt["output"]["output"]; }
        elseif (isset($evt["text"])) { $full .= $evt["text"]; }
    }
    return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
echo $full . "\n";
var streamReq = new HttpRequestMessage(HttpMethod.Post, API + "/run-stream") {
    Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Headers.Add("Idempotency-Key", key);

var resp = await http.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync());
var full = "";
while (await reader.ReadLineAsync() is string line) {
    if (!line.StartsWith("data:")) continue;
    var evt = JsonDocument.Parse(line[5..].Trim()).RootElement;
    if (evt.TryGetProperty("output", out var o))
        full = o.GetProperty("output").GetString()!;
    else if (evt.TryGetProperty("text", out var t))
        full += t.GetString();
}
Console.WriteLine(full);

6. Parse the reply

The four tag lines come first, then the six sections. A minimal, faithful parser: read SEVERITY, DISPOSITION and CONFIDENCE from the leading lines, take SUMMARY up to the first blank line, then split on ## headings and read - bullets. Reject the reply rather than guessing if a tag or a heading is missing — that is what the app does, and it is why the retry exists.

# Reject anything that does not open with the contract:
head -1 reply.txt | grep -Eq '^SEVERITY: (Critical|Major|Minor)$' || echo "contract violated"
grep -c '^## ' reply.txt   # must be 6
import re

SECTIONS = ["Containment", "Root cause analysis", "Disposition rationale",
            "CAPA plan", "Regulatory and escalation", "Open questions"]

def parse(text):
    sev = re.search(r"^SEVERITY:\s*(Critical|Major|Minor)\s*$", text, re.M)
    dis = re.search(r"^DISPOSITION:\s*(.+?)\s*$", text, re.M)
    con = re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", text, re.M)
    summ = re.search(r"^SUMMARY:\s*(.*?)(?:\n\s*\n)", text, re.M | re.S)
    if not (sev and dis and con and summ):
        return None
    out = {"severity": sev.group(1), "disposition": dis.group(1),
           "confidence": int(con.group(1)), "summary": " ".join(summ.group(1).split()),
           "sections": {}}
    for name in SECTIONS:
        body = re.search(r"^##\s+" + re.escape(name) + r"\s*$(.*?)(?=^##\s|\Z)",
                         text, re.M | re.S)
        if body is None:
            return None
        bullets = [b.strip() for b in re.findall(r"^-\s+(.*)$", body.group(1), re.M)]
        out["sections"][name] = [] if bullets == ["None."] else bullets
    return out
const SECTIONS = ["Containment", "Root cause analysis", "Disposition rationale",
                  "CAPA plan", "Regulatory and escalation", "Open questions"];

function parse(text) {
  const sev = /^SEVERITY:\s*(Critical|Major|Minor)\s*$/m.exec(text);
  const dis = /^DISPOSITION:\s*(.+?)\s*$/m.exec(text);
  const con = /^CONFIDENCE:\s*(\d{1,3})\s*$/m.exec(text);
  const summ = /^SUMMARY:\s*([\s\S]*?)\n\s*\n/m.exec(text);
  if (!sev || !dis || !con || !summ) return null;
  const sections = {};
  for (const name of SECTIONS) {
    const re = new RegExp("^##\\s+" + name + "\\s*$([\\s\\S]*?)(?=^##\\s|$)", "m");
    const body = re.exec(text);
    if (!body) return null;
    const bullets = [...body[1].matchAll(/^-\s+(.*)$/gm)].map((m) => m[1].trim());
    sections[name] = bullets.length === 1 && /^none\.?$/i.test(bullets[0]) ? [] : bullets;
  }
  return { severity: sev[1], disposition: dis[1], confidence: +con[1],
           summary: summ[1].replace(/\s+/g, " ").trim(), sections };
}
// Split on the six headings, then read "- " bullets out of each block.
sections := regexp.MustCompile(`(?m)^##\s+(.+)$`).Split(reply, -1)
names := regexp.MustCompile(`(?m)^##\s+(.+)$`).FindAllStringSubmatch(reply, -1)
if len(names) != 6 {
    return errors.New("contract violated: expected six sections")
}
for i, n := range names {
    bullets := regexp.MustCompile(`(?m)^-\s+(.*)$`).FindAllStringSubmatch(sections[i+1], -1)
    fmt.Println(n[1], len(bullets))
}
String[] names = {"Containment", "Root cause analysis", "Disposition rationale",
                  "CAPA plan", "Regulatory and escalation", "Open questions"};
for (String n : names) {
  if (!reply.contains("## " + n)) throw new IllegalStateException("missing section: " + n);
}
java.util.regex.Matcher m =
    java.util.regex.Pattern.compile("^SEVERITY:\\s*(Critical|Major|Minor)$",
        java.util.regex.Pattern.MULTILINE).matcher(reply);
if (!m.find()) throw new IllegalStateException("no SEVERITY line");
System.out.println(m.group(1));
SECTIONS = ["Containment", "Root cause analysis", "Disposition rationale",
            "CAPA plan", "Regulatory and escalation", "Open questions"].freeze

def parse(text)
  sev = text[/^SEVERITY:\s*(Critical|Major|Minor)\s*$/, 1]
  dis = text[/^DISPOSITION:\s*(.+?)\s*$/, 1]
  con = text[/^CONFIDENCE:\s*(\d{1,3})\s*$/, 1]
  return nil unless sev && dis && con

  sections = SECTIONS.to_h do |name|
    body = text[/^##\s+#{Regexp.escape(name)}\s*$(.*?)(?=^##\s|\z)/m, 1]
    return nil unless body
    bullets = body.scan(/^-\s+(.*)$/).flatten.map(&:strip)
    [name, bullets == ["None."] ? [] : bullets]
  end
  { severity: sev, disposition: dis, confidence: con.to_i, sections: sections }
end
$sections = ["Containment", "Root cause analysis", "Disposition rationale",
             "CAPA plan", "Regulatory and escalation", "Open questions"];

preg_match('/^SEVERITY:\s*(Critical|Major|Minor)\s*$/m', $reply, $sev);
preg_match('/^DISPOSITION:\s*(.+?)\s*$/m', $reply, $dis);
preg_match('/^CONFIDENCE:\s*(\d{1,3})\s*$/m', $reply, $con);
if (!$sev || !$dis || !$con) { throw new RuntimeException("contract violated"); }

$out = [];
foreach ($sections as $name) {
    $re = '/^##\s+' . preg_quote($name, '/') . '\s*$(.*?)(?=^##\s|\z)/ms';
    if (!preg_match($re, $reply, $body)) { throw new RuntimeException("missing $name"); }
    preg_match_all('/^-\s+(.*)$/m', $body[1], $bullets);
    $out[$name] = $bullets[1];
}
using System.Text.RegularExpressions;

string[] names = { "Containment", "Root cause analysis", "Disposition rationale",
                   "CAPA plan", "Regulatory and escalation", "Open questions" };

var sev = Regex.Match(reply, @"^SEVERITY:\s*(Critical|Major|Minor)\s*$", RegexOptions.Multiline);
if (!sev.Success) throw new InvalidOperationException("contract violated");

foreach (var name in names) {
    var body = Regex.Match(reply,
        $@"^##\s+{Regex.Escape(name)}\s*$(.*?)(?=^##\s|\z)",
        RegexOptions.Multiline | RegexOptions.Singleline);
    if (!body.Success) throw new InvalidOperationException($"missing section: {name}");
    var bullets = Regex.Matches(body.Groups[1].Value, @"^-\s+(.*)$", RegexOptions.Multiline);
    Console.WriteLine($"{name}: {bullets.Count}");
}
The disposition value is one of exactly six phrases. There is no seventh, and Insufficient information is a real answer — treat it as a result to act on, not as a failure to retry.