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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large paste). |
404 | Unknown job or record id. |
5xx | Transient 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
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
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
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 field | Type | Notes |
|---|---|---|
document | string, required | The 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_type | string | new (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_name | string | The vendor under review; may be the empty string, in which case a name is derived from the document. |
context | string, optional | Your 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_datetime | string | Your 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_facts | object, optional | What 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_note | string, optional | Only 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
/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.
| Field | Type | Meaning |
|---|---|---|
review_title | string | A short title — e.g. Northlight Analytics — new vendor review. |
review_type | string | Echoes your input: new | renewal | comparison. |
recommendation | string | proceed | negotiate | pass. See the table below. |
verdict | string | One sentence justifying the recommendation with the decisive fact. |
exec_summary | string | Two or three short paragraphs, separated by blank lines: what is being bought, what it really costs, what decides the call. |
assumptions | string[] | Explicit inferences that fill gaps the document left open. Read these first: a wrong assumption invalidates every total built on it. |
open_questions | string[] | Questions for the vendor whose answers would change the recommendation. |
cost_analysis | object | The 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. |
risks | array | {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. |
strengths | array | {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). |
concerns | array | {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. |
comparison | object | null | Non-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_points | array | {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_check | array | {id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below. |
next_steps | string[] | 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)". |
summary | string | Closing paragraph: the recommendation in one breath. |
The three recommendation values:
| recommendation | What it means |
|---|---|
proceed | The terms are acceptable as written and the risks are manageable. This is the case to gate an auto-approval on. |
negotiate | The vendor is right but specific terms must move first — each one is in negotiation_points with the leverage and the target outcome. |
pass | The 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:
| Column | Meaning |
|---|---|
component | What the money buys — License (40 seats), Implementation, Premium support. |
period | one-time | annual | monthly | usage. Year-1 totals count one-time lines once and multiply monthly lines by 12. |
amount | A 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. |
basis | The 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_quote | The shortest verbatim fragment of the paste behind the line, or the empty string for an assumption the model supplied. |
coverage_check semantics:
| Case | What you get |
|---|---|
| Every flag id you sent | Each 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: true | The 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: false | The 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 sent | Omit 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.