Vendor Desk — API

Paste the vendor proposal, get the procurement review.

API tokens Open the app

Run the vendor review from your own tools

Send vendor material — a proposal, a quote or order form, a renewal notice, contract or SLA excerpts, or notes covering two vendors — and get back one JSON object: a total-cost-of-ownership table whose every line shows its arithmetic, year-1 and 3-year totals, a risk assessment with likelihood, impact and a concrete mitigation per risk, strengths and concerns pinned to verbatim quotes, an optional dimension-by-dimension comparison matrix, a proceed / negotiate / pass recommendation and the negotiation points to bring to the table. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it to the inbox that receives renewal notices, run it from a procurement workflow, or gate a purchase approval on the recommendation. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug vendor-desk. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The review is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one paste of vendor material in, one review out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest submitting a very large paste).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

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

Step 0 — A tiny client

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

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

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

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

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

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

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

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

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

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

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

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = 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))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

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

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered review runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"vendor-desk"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "vendor-desk"})["token"]
const { token } = await api("POST", "/guest", { slug: "vendor-desk" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "vendor-desk"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"vendor-desk"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "vendor-desk" })["token"]
$token = api("POST", "/guest", ["slug" => "vendor-desk"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "vendor-desk" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:vendor-desk, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before sending a long document.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are piping a long contract in and want a ceiling before spending credits.

Input fieldTypeNotes
documentstring, requiredThe pasted vendor material: proposal, quote or order form, renewal notice, contract or SLA excerpts, pricing-page text, or your own notes — for a comparison, both vendors' material in one paste. This is the model's only evidence about the vendor; nothing else is fetched. A document clipped in the middle should carry a [... clipped ...] line where the cut is.
review_typestringnew (evaluating a new vendor) | renewal (renew, renegotiate or replace an incumbent) | comparison (two or more vendors side by side). Only comparison produces a non-null comparison object in the reply.
vendor_namestringThe vendor under review; may be the empty string, in which case a name is derived from the document.
contextstring, optionalYour situation in prose — seat count, current spend and incumbent pricing, budget ceiling, compliance requirements, renewal deadline. Unit prices become your real totals only when you state your numbers here.
current_datetimestringYour current date, ISO 8601 with offset plus the weekday in parentheses: 2026-08-05T14:12:00-04:00 (Wednesday). Renewal windows and notice deadlines are judged against this — send the real clock or an "urgent" deadline may be judged wrongly.
prescan_factsobject, optionalWhat a client-side scanner mechanically matched in the text: {"figures": [], "flags": []}. Each entry is {id, label}. Figure ids look like money:1, pct:2, term:1, compliance:1; flag ids are the deterministic checks that fired — no-pricing:1, auto-renew:1, escalator:1 (one per price-change phrase), no-sla:1, no-term:1, no-exit:1, no-compliance:1, short-doc:1. Every flag id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the two empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
cat > proposal.txt <<'DOC'
Acme Monitor - Proposal
Pricing: $500/month flat, unlimited seats, month-to-month.
SLA: 99.9% uptime with service credits.
Security: SOC 2 Type II report attached.
DOC

jq -n --rawfile doc proposal.txt \
  '{document: $doc,
    review_type: "new",
    vendor_name: "Acme Monitor",
    context: "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
    current_datetime: "2026-08-05T14:12:00-04:00 (Wednesday)",
    prescan_facts: {figures: [], flags: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
DOC = """Acme Monitor - Proposal
Pricing: $500/month flat, unlimited seats, month-to-month.
SLA: 99.9% uptime with service credits.
Security: SOC 2 Type II report attached.
"""

payload = {
    "document": DOC,
    "review_type": "new",
    "vendor_name": "Acme Monitor",
    "context": "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
    "current_datetime": "2026-08-05T14:12:00-04:00 (Wednesday)",
    "prescan_facts": {"figures": [], "flags": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const doc = [
  "Acme Monitor - Proposal",
  "Pricing: $500/month flat, unlimited seats, month-to-month.",
  "SLA: 99.9% uptime with service credits.",
  "Security: SOC 2 Type II report attached.",
].join("\n");

const payload = {
  document: doc,
  review_type: "new",
  vendor_name: "Acme Monitor",
  context: "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
  current_datetime: "2026-08-05T14:12:00-04:00 (Wednesday)",
  prescan_facts: { figures: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const doc = "Acme Monitor - Proposal\n" +
	"Pricing: $500/month flat, unlimited seats, month-to-month.\n" +
	"SLA: 99.9% uptime with service credits.\n" +
	"Security: SOC 2 Type II report attached.\n"

payload := map[string]any{
	"document":         doc,
	"review_type":      "new",
	"vendor_name":      "Acme Monitor",
	"context":          "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
	"current_datetime": "2026-08-05T14:12:00-04:00 (Wednesday)",
	"prescan_facts": map[string]any{
		"figures": []any{}, "flags": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String doc = """
    Acme Monitor - Proposal
    Pricing: $500/month flat, unlimited seats, month-to-month.
    SLA: 99.9% uptime with service credits.
    Security: SOC 2 Type II report attached.
    """;

String jsonPayload = """
    {"document": %s,
     "review_type": "new",
     "vendor_name": "Acme Monitor",
     "context": "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
     "current_datetime": "2026-08-05T14:12:00-04:00 (Wednesday)",
     "prescan_facts": {"figures": [], "flags": []}}
    """.formatted(toJsonString(doc));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
DOC = <<~DOC
  Acme Monitor - Proposal
  Pricing: $500/month flat, unlimited seats, month-to-month.
  SLA: 99.9% uptime with service credits.
  Security: SOC 2 Type II report attached.
DOC

payload = { document: DOC,
            review_type: "new",
            vendor_name: "Acme Monitor",
            context: "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
            current_datetime: "2026-08-05T14:12:00-04:00 (Wednesday)",
            prescan_facts: { figures: [], flags: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$doc = <<<'DOC'
Acme Monitor - Proposal
Pricing: $500/month flat, unlimited seats, month-to-month.
SLA: 99.9% uptime with service credits.
Security: SOC 2 Type II report attached.
DOC;

$payload = [
    "document"         => $doc,
    "review_type"      => "new",
    "vendor_name"      => "Acme Monitor",
    "context"          => "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
    "current_datetime" => "2026-08-05T14:12:00-04:00 (Wednesday)",
    "prescan_facts"    => ["figures" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var doc = """
    Acme Monitor - Proposal
    Pricing: $500/month flat, unlimited seats, month-to-month.
    SLA: 99.9% uptime with service credits.
    Security: SOC 2 Type II report attached.
    """;

var payload = new {
    document = doc,
    review_type = "new",
    vendor_name = "Acme Monitor",
    context = "Replacing a $900/month incumbent; 40 people; SOC 2 required.",
    current_datetime = "2026-08-05T14:12:00-04:00 (Wednesday)",
    prescan_facts = new {
        figures = Array.Empty<object>(), flags = Array.Empty<object>(),
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

prescan_facts.flags is how you make the review answer for things you already know about. Send {"figures": [], "flags": [{"id": "auto-renew:1", "label": "“renews automatically”"}]} and every flag id comes back in coverage_check — addressed by a section of the review, or set aside with the reason. Nothing you flag is silently dropped, which makes it the field to assert on in an automated check.

Step 4 — Run the review and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s, since the reply carries a full cost table, a risk table and the negotiation points). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the recommendation, the cost lines with their arithmetic, the risks and the negotiation points, then save the whole object to review.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: vd-$(date +%s)" \
  -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')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# unwrap the review once, then read it
echo "$JOB" | jq -r '.data.output.output' > review.json

jq -r '
  "\(.review_title) [\(.recommendation)]: \(.verdict)",
  "",
  "COST (\(.cost_analysis.currency))",
  (.cost_analysis.line_items[] |
     "  \(.component) [\(.period)]: \(.amount // "not stated")  <= \(.basis)"),
  "  year 1: \(.cost_analysis.total_year1 // "n/a")   3-year: \(.cost_analysis.total_3yr // "n/a")",
  "",
  "RISKS",
  (.risks[] | "  [\(.likelihood)/\(.impact)] \(.risk) -> \(.mitigation)"),
  "",
  "NEGOTIATION",
  (.negotiation_points[] | "  - \(.point): ask for \(.target)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "ok" else "SET ASIDE" end) - \(.note)"),
  "",
  "NEXT",
  (.next_steps[] | "  - \(.)")' \
  review.json

# only auto-approve when the review says proceed
jq -e '.recommendation == "proceed"' review.json > /dev/null \
  || { echo "needs a human before signing"; exit 1; }
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "vd-001"})["job_id"]

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

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

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
review = json.loads(raw) if isinstance(raw, str) else raw

print(f'{review["review_title"]} [{review["recommendation"]}]: {review["verdict"]}')
ca = review["cost_analysis"]
for li in ca["line_items"]:
    amt = li["amount"] if li["amount"] is not None else "not stated"
    print(f'  {li["component"]:<30} [{li["period"]}] {amt}  <= {li["basis"]}')
print(f'  year 1: {ca["total_year1"]}   3-year: {ca["total_3yr"]} ({ca["currency"]})')
for k in review["risks"]:
    print(f'  [{k["likelihood"]}/{k["impact"]}] {k["risk"]} -> {k["mitigation"]}')
for s in review["strengths"]:
    print("  +", s["point"])
for c in review["concerns"]:
    print("  -", c["point"])
if review["comparison"]:
    for row in review["comparison"]["rows"]:
        print(f'  {row["dimension"]}: edge {row["edge"]}')
for n in review["negotiation_points"]:
    print(f'  ask: {n["point"]} -> {n["target"]}')
for c in review["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)

if review["recommendation"] != "proceed":
    raise SystemExit(f'recommendation is {review["recommendation"]} — review before signing')
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

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

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const review = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${review.review_title} [${review.recommendation}]: ${review.verdict}`);
for (const li of review.cost_analysis.line_items) {
  console.log(`  ${li.component} [${li.period}]: ${li.amount ?? "not stated"}  <= ${li.basis}`);
}
console.log(`  year 1: ${review.cost_analysis.total_year1}  3-year: ${review.cost_analysis.total_3yr}`);
for (const k of review.risks) {
  console.log(`  [${k.likelihood}/${k.impact}] ${k.risk} -> ${k.mitigation}`);
}
if (review.comparison) {
  for (const row of review.comparison.rows) {
    console.log(`  ${row.dimension}: edge ${row.edge}`);
  }
}
for (const n of review.negotiation_points) {
  console.log(`  ask: ${n.point} -> ${n.target}`);
}
for (const c of review.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("review.json", JSON.stringify(review, null, 2));

if (review.recommendation !== "proceed") process.exitCode = 1;
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Review struct {
	ReviewTitle    string   `json:"review_title"`
	ReviewType     string   `json:"review_type"`
	Recommendation string   `json:"recommendation"`
	Verdict        string   `json:"verdict"`
	ExecSummary    string   `json:"exec_summary"`
	Assumptions    []string `json:"assumptions"`
	OpenQuestions  []string `json:"open_questions"`
	CostAnalysis   struct {
		Currency  string `json:"currency"`
		LineItems []struct {
			Component, Period, Basis string
			Amount                   *float64 `json:"amount"`
			SourceQuote              string   `json:"source_quote"`
		} `json:"line_items"`
		TotalYear1 *float64 `json:"total_year1"`
		Total3Yr   *float64 `json:"total_3yr"`
		Notes      string   `json:"notes"`
	} `json:"cost_analysis"`
	Risks []struct {
		Risk, Likelihood, Impact, Mitigation string
		SourceQuote                          string `json:"source_quote"`
	} `json:"risks"`
	Strengths []struct{ Point, SourceQuote string } `json:"strengths"`
	Concerns  []struct{ Point, SourceQuote string } `json:"concerns"`
	Comparison *struct {
		Vendors []string `json:"vendors"`
		Rows    []struct {
			Dimension string   `json:"dimension"`
			Cells     []string `json:"cells"`
			Edge      string   `json:"edge"`
		} `json:"rows"`
		RecommendedVendor string `json:"recommended_vendor"`
	} `json:"comparison"`
	NegotiationPoints []struct{ Point, Leverage, Target string } `json:"negotiation_points"`
	CoverageCheck     []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	NextSteps []string `json:"next_steps"`
	Summary   string   `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var review Review
json.Unmarshal([]byte(wrapper.Output), &review)

fmt.Printf("%s [%s]: %s\n", review.ReviewTitle, review.Recommendation, review.Verdict)
for _, li := range review.CostAnalysis.LineItems {
	fmt.Printf("  %s [%s]: %v <= %s\n", li.Component, li.Period, li.Amount, li.Basis)
}
for _, k := range review.Risks {
	fmt.Printf("  [%s/%s] %s -> %s\n", k.Likelihood, k.Impact, k.Risk, k.Mitigation)
}
os.WriteFile("review.json", []byte(wrapper.Output), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The reply is at data.output.output as a JSON string — parse it again, then read
// review_title, review_type, recommendation (proceed|negotiate|pass), verdict,
// exec_summary, assumptions[], open_questions[],
// cost_analysis {currency, line_items[] (component/period/amount/basis/source_quote),
//   total_year1, total_3yr, notes} — amount is a number or null,
// risks[] (risk/likelihood/impact/mitigation/source_quote),
// strengths[] and concerns[] ({point, source_quote}),
// comparison (null, or {vendors[], rows[] {dimension, cells[], edge}, recommended_vendor}),
// negotiation_points[] (point/leverage/target),
// coverage_check[] (id/addressed/note), next_steps[] and summary.
// Finally keep it on disk:
//   Files.writeString(Path.of("review.json"), reviewJson);
started = api("POST", "/run", payload)

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"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
review = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{review["review_title"]} [#{review["recommendation"]}]: #{review["verdict"]}"
ca = review["cost_analysis"]
ca["line_items"].each do |li|
  puts "  #{li["component"]} [#{li["period"]}]: #{li["amount"] || "not stated"}  <= #{li["basis"]}"
end
puts "  year 1: #{ca["total_year1"]}   3-year: #{ca["total_3yr"]} (#{ca["currency"]})"
review["risks"].each { |k| puts "  [#{k["likelihood"]}/#{k["impact"]}] #{k["risk"]} -> #{k["mitigation"]}" }
review["negotiation_points"].each { |n| puts "  ask: #{n["point"]} -> #{n["target"]}" }
review["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("review.json", JSON.pretty_generate(review))
exit 1 unless review["recommendation"] == "proceed"
$started = api("POST", "/run", $payload);

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

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

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$review = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$review['review_title']} [{$review['recommendation']}]: {$review['verdict']}\n";
foreach ($review["cost_analysis"]["line_items"] as $li) {
    $amt = $li["amount"] ?? "not stated";
    echo "  {$li['component']} [{$li['period']}]: $amt  <= {$li['basis']}\n";
}
echo "  year 1: {$review['cost_analysis']['total_year1']}"
   . "   3-year: {$review['cost_analysis']['total_3yr']}\n";
foreach ($review["risks"] as $k) {
    echo "  [{$k['likelihood']}/{$k['impact']}] {$k['risk']} -> {$k['mitigation']}\n";
}
foreach ($review["negotiation_points"] as $n) {
    echo "  ask: {$n['point']} -> {$n['target']}\n";
}
foreach ($review["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

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

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var review = doc.RootElement;

Console.WriteLine($"{review.GetProperty("review_title")} " +
                  $"[{review.GetProperty("recommendation")}]: {review.GetProperty("verdict")}");
foreach (var li in review.GetProperty("cost_analysis").GetProperty("line_items").EnumerateArray())
{
    Console.WriteLine($"  {li.GetProperty("component")} [{li.GetProperty("period")}]: " +
                      $"{li.GetProperty("amount")}  <= {li.GetProperty("basis")}");
}
foreach (var k in review.GetProperty("risks").EnumerateArray())
{
    Console.WriteLine($"  [{k.GetProperty("likelihood")}/{k.GetProperty("impact")}] " +
                      $"{k.GetProperty("risk")} -> {k.GetProperty("mitigation")}");
}
foreach (var n in review.GetProperty("negotiation_points").EnumerateArray())
{
    Console.WriteLine($"  ask: {n.GetProperty("point")} -> {n.GetProperty("target")}");
}

await File.WriteAllTextAsync("review.json", rawText!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The reply object — output schema

One JSON object, always the same shape. Every claim in it is grounded in what you sent: prices, terms, SLAs and certifications come from document and context alone, never from invention. Where an inference fills a gap — an assumed term length, a typical implementation effort — it is marked as an assumption in the line's basis and repeated in assumptions, with a question in open_questions when the answer would change the recommendation. cost_analysis.line_items is never empty, risks always carries at least two entries, and the recommendation must follow from the analysis — a pass is never softened into a negotiate.

FieldTypeMeaning
review_titlestringA short title — e.g. Northlight Analytics — new vendor review.
review_typestringEchoes your input: new | renewal | comparison.
recommendationstringproceed | negotiate | pass. See the table below.
verdictstringOne sentence justifying the recommendation with the decisive fact.
exec_summarystringTwo or three short paragraphs, separated by blank lines: what is being bought, what it really costs, what decides the call.
assumptionsstring[]Explicit inferences that fill gaps the document left open. Read these first: a wrong assumption invalidates every total built on it.
open_questionsstring[]Questions for the vendor whose answers would change the recommendation.
cost_analysisobjectThe core deliverable{currency, line_items, total_year1, total_3yr, notes}. Columns of line_items are listed below. total_year1 and total_3yr are numbers, or null only when no line item carries an amount.
risksarray{risk, likelihood, impact, mitigation, source_quote} — at least two entries, each with a mitigation the buyer can act on. likelihood and impact are high | medium | low.
strengthsarray{point, source_quote} — what genuinely works in the buyer's favour, each backed by a verbatim quote (or an empty quote when the point is about the document as a whole).
concernsarray{point, source_quote} — what should worry the buyer, including absences ("no pricing disclosed"), where source_quote is the empty string and the text says so.
comparisonobject | nullNon-null only for review_type: "comparison": {vendors, rows, recommended_vendor} with one row per dimension and cells aligned to vendors. edge names the winning vendor or tie; recommended_vendor appears in vendors.
negotiation_pointsarray{point, leverage, target} — the term to move, why the vendor should concede it, and the concrete outcome to ask for. At least one entry when the recommendation is negotiate; may be empty for proceed and pass.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below.
next_stepsstring[]The buyer's concrete next actions, with any deadline named — e.g. "Send non-renewal notice before 17 August (45 days ahead of the 1 October renewal)".
summarystringClosing paragraph: the recommendation in one breath.

The three recommendation values:

recommendationWhat it means
proceedThe terms are acceptable as written and the risks are manageable. This is the case to gate an auto-approval on.
negotiateThe vendor is right but specific terms must move first — each one is in negotiation_points with the leverage and the target outcome.
passThe risks or costs are disqualifying, or a compared alternative wins. What would have to change sits in open_questions, not in a softened ask.

Each entry in cost_analysis.line_items:

ColumnMeaning
componentWhat the money buys — License (40 seats), Implementation, Premium support.
periodone-time | annual | monthly | usage. Year-1 totals count one-time lines once and multiply monthly lines by 12.
amountA plain JSON number — no currency symbols, no strings — or null when the document states no figure for a material cost. A null amount always comes with a basis saying what is missing.
basisThe arithmetic or quote that produced the number — 40 seats x $50/mo x 12. This is the column to audit; the app re-adds the lines in the browser and flags totals that disagree.
source_quoteThe shortest verbatim fragment of the paste behind the line, or the empty string for an assumption the model supplied.

coverage_check semantics:

CaseWhat you get
Every flag id you sentEach prescan_facts.flags id appears in coverage_check exactly once. Nothing you flagged is silently dropped, which makes this the field to assert on in an automated check. Ids in prescan_facts.figures are not reconciled here — they feed the cost table instead.
addressed: trueThe flag is resolved by a section of the review; note names which one — a risk row covering the auto-renewal window, a concern recording the missing SLA, an open question asking for the undisclosed price.
addressed: falseThe flag was deliberately set aside; note gives the reason — a check that fired but is not a real problem for this document (month-to-month terms tripping the auto-renew matcher, a short paste that is nonetheless complete).
Nothing sentOmit prescan_facts, or send the two empty arrays, and coverage_check comes back empty. The rest of the reply is unaffected.

A small, realistic result for the proposal above (long strings wrapped for readability):

{
  "review_title": "Acme Monitor — new vendor review",
  "review_type": "new",
  "recommendation": "proceed",
  "verdict": "Flat $500/month on a month-to-month term with a credited 99.9% SLA and SOC 2
              Type II undercuts the $900/month incumbent with no lock-in to unwind.",
  "exec_summary": "Acme Monitor is offered at $500/month flat with unlimited seats,
                   month-to-month, replacing a $900/month incumbent — a $4,800 annual saving
                   before migration effort.

                   The terms carry unusually little contractual risk: no annual commitment, a
                   99.9% uptime SLA backed by service credits, and a SOC 2 Type II report
                   supplied up front. The residual risks are the unpriced migration and the
                   flip side of month-to-month: the vendor can reprice on short notice too.",
  "assumptions": [
    "Migration effort is assumed to be internal time only, as the proposal prices no
     onboarding service."
  ],
  "open_questions": [
    "Can Acme cap price changes for the first 12 months in a side letter, given
     month-to-month terms cut both ways?"
  ],
  "cost_analysis": {
    "currency": "USD",
    "line_items": [
      { "component": "Subscription (unlimited seats)", "period": "annual", "amount": 6000,
        "basis": "$500/month x 12",
        "source_quote": "$500/month flat, unlimited seats" }
    ],
    "total_year1": 6000,
    "total_3yr": 18000,
    "notes": "Totals cover the subscription only; migration is internal effort and no other
              fees are stated."
  },
  "risks": [
    { "risk": "Month-to-month terms let the vendor raise the price at any renewal",
      "likelihood": "medium", "impact": "medium",
      "mitigation": "Ask for a 12-month price hold in a side letter before cutover",
      "source_quote": "month-to-month" },
    { "risk": "Unpriced migration from the incumbent absorbs the first months' savings",
      "likelihood": "medium", "impact": "low",
      "mitigation": "Time-box the migration and run both tools in parallel for one billing
                     cycle",
      "source_quote": "" }
  ],
  "strengths": [
    { "point": "SLA has teeth: 99.9% uptime with service credits",
      "source_quote": "99.9% uptime SLA with service credits" },
    { "point": "SOC 2 Type II supplied up front", "source_quote": "SOC 2 Type II attached" }
  ],
  "concerns": [
    { "point": "No onboarding or migration support is priced in the proposal",
      "source_quote": "" }
  ],
  "comparison": null,
  "negotiation_points": [],
  "coverage_check": [
    { "id": "no-term:1", "addressed": true,
      "note": "The term is month-to-month — recorded as both a strength (no lock-in) and a
               repricing risk." }
  ],
  "next_steps": [
    "Ask Acme for a 12-month price hold, then give the incumbent notice per its own terms
     once cutover is scheduled."
  ],
  "summary": "Proceed: cheaper than the incumbent, credited SLA, SOC 2 in hand, and
              month-to-month means the exit costs nothing if it disappoints."
}

This is an AI-generated review of pasted text, not financial or legal advice: it sees only the document you sent, never the vendor's books or the full contract, and it cannot know about the clause that was not pasted. Check assumptions, audit the basis column of every cost line, and let a human read the contract before anyone signs.

Step 5 — Stream the review as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because the cost table, the risk table and the negotiation points make for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "cost_analysis", "risks", "strengths", "negotiation_points", "coverage_check", "next_steps" and "summary" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the review from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: vd-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"review_title\":\"Acme Monitor"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":512,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "vd-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

review = json.loads(result["output"]["output"])            # authoritative
print("charged:", result["charged_credits"], "-", review["review_title"])
print("recommendation:", review["recommendation"])
for li in review["cost_analysis"]["line_items"]:
    print(f'  {li["component"]}: {li["amount"]}')
for k in review["risks"]:
    print(f'  [{k["likelihood"]}/{k["impact"]}] {k["risk"]}')
with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const review = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${review.review_title} [${review.recommendation}]`);
for (const li of review.cost_analysis.line_items) {
  console.log(`  ${li.component}: ${li.amount ?? "not stated"}`);
}
for (const k of review.risks) {
  console.log(`  [${k.likelihood}/${k.impact}] ${k.risk}`);
}
writeFileSync("review.json", JSON.stringify(review, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "vd-001")

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

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the review JSON —
// unmarshal it into the Review struct from step 4, then write it to review.json.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "vd-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// review_title, recommendation, verdict, cost_analysis (with line_items and totals),
// risks[], strengths[], concerns[], comparison, negotiation_points[],
// coverage_check[], next_steps[] and the rest.
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"] = "vd-001"
req.body = payload.to_json

event = nil
done = nil
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|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

review = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{review["review_title"]} [#{review["recommendation"]}]"
review["cost_analysis"]["line_items"].each { |li| puts "  #{li["component"]}: #{li["amount"] || "not stated"}" }
review["risks"].each { |k| puts "  [#{k["likelihood"]}/#{k["impact"]}] #{k["risk"]}" }
File.write("review.json", JSON.pretty_generate(review))
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: vd-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$review = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$review['review_title']} [{$review['recommendation']}]\n";
foreach ($review["cost_analysis"]["line_items"] as $li) {
    echo "  {$li['component']}: " . ($li["amount"] ?? "not stated") . "\n";
}
foreach ($review["risks"] as $k) {
    echo "  [{$k['likelihood']}/{$k['impact']}] {$k['risk']}\n";
}
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "vd-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var reviewDoc = JsonDocument.Parse(text!);
var review = reviewDoc.RootElement;
Console.WriteLine($"{review.GetProperty("review_title")} [{review.GetProperty("recommendation")}]");
foreach (var li in review.GetProperty("cost_analysis").GetProperty("line_items").EnumerateArray())
{
    Console.WriteLine($"  {li.GetProperty("component")}: {li.GetProperty("amount")}");
}
await File.WriteAllTextAsync("review.json", text!);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.