Turn a cost export into a savings plan from your own pipeline
Send the aggregated profile of a cloud cost export and get back one JSON object: a
bloated / trimmable / lean verdict and an
A–F grade, an ordered list of levers each
carrying a monthly saving, a confidence, an effort and a risk, severity-ranked
findings with a concrete fix and a risk note apiece, a
commitment_plan in the term you said you would accept, a
tagging_plan, governance actions, a four-week plan_30_day
sequence that puts deletions before commitments, and a verdict on every waste rule the
client-side scanner matched — including the ones the review rejects as false
positives. Wire it into a monthly close to diff this month's recoverable waste against
last month's, gate a Terraform plan on a budget projection, or run the free client-side
half in CI on every cost export and only call /run when the recoverable
share crosses a threshold. 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
spend-lens. 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. Plans are produced by the gpt-terra model alias
(currently gpt-5.6-terra) at a publisher markup of
1000 bps — 10%. Credits are in units of 1/10 000 of a US
dollar, so 10 000 credits is $1.00.
POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/reports/query
Error codes
| HTTP | code | What it means and what to do |
|---|---|---|
400 | validation_error | The body is missing a required field or a field has the wrong type. error.details names it. POST /guest in particular needs slug in the body — an X-App-Slug header is not accepted. |
401 | unauthorized | No token, a malformed token, or a token that has expired. Mint a new guest token or sign in again. |
402 | payment_required | The balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits. |
404 | not_found | Unknown job id, unknown collection, or a record that belongs to another subject. Guest identities are per-token: a new guest token cannot see the previous guest's records. |
409 | conflict | An Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes. |
429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
500 | internal_error | Transient. Retry with the same Idempotency-Key so the retry cannot bill twice. |
/run and /run-stream.
/guest, /me and /estimate are free, so a client
can price a run, check the balance and prove the model binding without spending
anything.
Step 1 · Get a token
Two ways in. If you already use the app in a browser, open
the token page and press Copy shell export —
it hands you the exact export SKILLSAFE_TOKEN="…" line, with no DevTools
console involved. For a fully scripted client, POST /guest mints a guest
token with no browser at all. Guest tokens can call /me and the free
/estimate; a personal token is what bills plan runs to your own account.
# Option A — take the token this browser already has: open /tokens.html,
# press "Copy shell export", and paste the line it gives you.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"
# Option B — mint a guest token with no browser at all. Guest tokens can call
# /me and the free /estimate; sign in for a personal token to bill plan runs
# to your own account.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H 'Content-Type: application/json' \
-d '{"slug":"spend-lens"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "spend-lens"
def call(path, body=None, token=None, method=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "spend-lens-client/1.0")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["data"]
# Option A: the token from /tokens.html, kept in your environment.
token = os.environ.get("SKILLSAFE_TOKEN")
# Option B: a fresh guest token, no browser involved.
if not token:
token = call("/guest", {"slug": SLUG})["token"]
print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "spend-lens";
async function call(path, { body, token, method } = {}) {
const res = await fetch(BASE + path, {
method: method || (body ? "POST" : "GET"),
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: "Bearer " + token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (json.error) throw Object.assign(new Error(json.error.message), json.error);
return json.data;
}
// Option A: paste the token from /tokens.html (or read it from your own config).
let token = "YOUR_TOKEN";
// Option B: mint a guest token — good for /me and the free /estimate.
if (token === "YOUR_TOKEN") token = (await call("/guest", { body: { slug: SLUG } })).token;
console.log(token.slice(0, 12) + "...");
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "spend-lens"
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path, token string, body any, out any) error {
var rdr io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
if env.Error != nil {
return errors.New(env.Error.Code + ": " + env.Error.Message)
}
if out != nil {
return json.Unmarshal(env.Data, out)
}
return nil
}
func main() {
token := os.Getenv("SKILLSAFE_TOKEN")
if token == "" {
var guest struct{ Token string `json:"token"` }
if err := call("/guest", "", map[string]string{"slug": slug}, &guest); err != nil {
panic(err)
}
token = guest.Token
}
fmt.Println(token[:12] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class CiteReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "spend-lens";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String token, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null) b.header("Authorization", "Bearer " + token);
b = jsonBody == null ? b.GET()
: b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"data":...} or {"error":{...}} — parse with your JSON library
}
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_TOKEN");
if (token == null) {
// POST /guest returns {"data":{"token":"aut_..."}}
System.out.println(call("/guest", null, "{\"slug\":\"" + SLUG + "\"}"));
} else {
System.out.println(token.substring(0, 12) + "...");
}
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "spend-lens"
def call(path, body: nil, token: nil, method: nil)
uri = URI(BASE.to_s + path)
req = (method || (body ? "POST" : "GET")) == "POST" ?
Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
json = JSON.parse(res.body)
raise "#{json['error']['code']}: #{json['error']['message']}" if json["error"]
json["data"]
end
token = ENV["SKILLSAFE_TOKEN"] || call("/guest", body: { slug: SLUG })["token"]
puts token[0, 12] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "spend-lens";
function call(string $path, ?array $body = null, ?string $token = null): array {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$json = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($json["error"])) {
throw new RuntimeException($json["error"]["code"] . ": " . $json["error"]["message"]);
}
return $json["data"];
}
$token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
echo substr($token, 0, 12) . "...\n";
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
class CiteReady {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "spend-lens";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(string path, object body = null, string token = null) {
var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
return doc.RootElement.GetProperty("data");
}
static async Task Main() {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
if (token == null) {
var guest = await Call("/guest", new { slug = Slug });
token = guest.GetProperty("token").GetString();
}
Console.WriteLine(token.Substring(0, 12) + "...");
}
}
Step 2 · Check who you are and what you can spend
GET /me returns subject_type (user or
guest), subject_id and credits. Compare
credits against /estimate's min_credits
before submitting a run — a 402 after submit is a client bug, not a user
problem.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","subject_id":"usr_...","credits":184213}}
#
# subject_type is "user" for a personal token and "guest" for a guest one.
# credits is in credit units: 10 000 credits = $1.00.
me = call("/me", token=token)
print(me["subject_type"], me["credits"], "credits",
"= $%.2f" % (me["credits"] / 10000))
const me = await call("/me", { token });
console.log(me.subject_type, me.credits, "credits =",
"$" + (me.credits / 10000).toFixed(2));
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("/me", token, nil, &me); err != nil {
panic(err)
}
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
// GET /me — {"data":{"subject_type":"user","credits":184213}}
String me = call("/me", token, null);
System.out.println(me);
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']} credits = $#{'%.2f' % (me['credits'] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F2}");
Step 3 · Price the run — free, and it proves the model binding
POST /estimate takes the same body as /run, creates no job
and charges nothing. It returns model, model_alias,
markup_bps, hold_credits, min_credits and
sponsor_enabled. Present hold_credits as reserved,
never as the price: the hold covers the full output cap, and the settled
charged_credits is usually far lower.
# /estimate is free: no job is created, no credits are held, nothing is charged.
# Use it to show a price and to prove the model binding before you spend anything.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d @plan-input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
est = call("/estimate", body=plan_input, token=token)
print("model", est["model"], "alias", est["model_alias"], "markup", est["markup_bps"])
print("reserved up to $%.4f" % (est["hold_credits"] / 10000))
if me["credits"] < est["min_credits"]:
raise SystemExit("balance below the model minimum — top up before running")
const est = await call("/estimate", { body: planInput, token });
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved up to $" + (est.hold_credits / 10000).toFixed(4));
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
if err := call("/estimate", token, planInput, &est); err != nil {
panic(err)
}
fmt.Printf("%s (%s) markup %d bps, reserve $%.4f\n",
est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000)
// POST /estimate with the same body you would send to /run. Free, no job.
String est = call("/estimate", token, planInputJson);
System.out.println(est);
est = call("/estimate", body: plan_input, token: token)
puts "#{est['model']} (#{est['model_alias']}) markup #{est['markup_bps']} bps"
puts "reserved up to $#{'%.4f' % (est['hold_credits'] / 10000.0)}"
$est = call("/estimate", $plan_input, $token);
printf("%s (%s) markup %d bps, reserve $%.4f\n",
$est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"] / 10000);
var est = await Call("/estimate", planInput, token);
Console.WriteLine(est.GetProperty("model").GetString() + " / " +
est.GetProperty("model_alias").GetString() + " markup " +
est.GetProperty("markup_bps").GetInt32() + " bps");
Step 4 · Run the plan and poll for it
POST /run returns {"job_id"}; poll
GET /jobs/{id} until status is succeeded or
failed, then read data.output.output — the plan as a JSON
string. Always send Idempotency-Key, derived from the input
plus an attempt counter: a network blip or a retry after a malformed reply must never
bill the same plan twice. Reuse the key for a retry of the same input; bump the
attempt counter only when the input itself changes.
# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing twice.
KEY="spend-lens:$(shasum -a 256 plan-input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @plan-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal.
while true; do
OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
# => the plan, as one JSON object (see the output contract below).
import hashlib, time
def idem_key(inp, attempt=1):
seed = "\u0020".join(str(inp.get(k, "")) for k in
("cloud", "commitment_appetite", "workload", "constraints"))
return "spend-lens:%s:a%d" % (hashlib.sha256(seed.encode()).hexdigest()[:16], attempt)
def run_plan(inp, token, attempt=1):
data = json.dumps(inp).encode()
req = urllib.request.Request(BASE + "/run", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idem_key(inp, attempt))
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = call("/jobs/" + job_id, token=token)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
return json.loads(job["output"]["output"])
plan = run_plan(plan_input, token)
print(plan["verdict"], plan["grade"], len(plan["levers"]), "levers")
import { createHash } from "node:crypto";
function idemKey(inp, attempt = 1) {
const seed = ["cloud", "commitment_appetite", "workload", "constraints"]
.map((k) => String(inp[k] ?? "")).join(" ");
return `spend-lens:${createHash("sha256").update(seed).digest("hex").slice(0, 16)}:a${attempt}`;
}
async function runAudit(inp, token, attempt = 1) {
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": idemKey(inp, attempt),
},
body: JSON.stringify(inp),
});
const { data, error } = await res.json();
if (error) throw new Error(error.message);
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await call("/jobs/" + data.job_id, { token });
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error || "run failed");
return JSON.parse(job.output.output);
}
const plan = await runAudit(planInput, token);
console.log(plan.verdict, plan.grade, plan.levers.length + " levers");
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
)
func idemKey(inp map[string]any, attempt int) string {
parts := []string{}
for _, k := range []string{"cloud", "commitment_appetite", "workload", "constraints"} {
parts = append(parts, fmt.Sprint(inp[k]))
}
sum := sha256.Sum256([]byte(strings.Join(parts, " ")))
return fmt.Sprintf("spend-lens:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
}
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id} every two
// seconds until status is "succeeded" or "failed". job.Output.Output holds the
// plan as a JSON string; unmarshal it into your own plan struct.
func runAudit(inp map[string]any, token string) (string, error) {
b, _ := json.Marshal(inp)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(env.Data, &started)
for {
var job struct {
Status string `json:"status"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
if err := call("/jobs/"+started.JobID, token, nil, &job); err != nil {
return "", err
}
if job.Status == "succeeded" {
return job.Output.Output, nil
}
if job.Status == "failed" {
return "", errors.New("run failed")
}
time.Sleep(2 * time.Second)
}
}
// POST /run must carry Idempotency-Key, derived from the input plus an attempt
// counter, so a network retry cannot bill the plan twice.
String key = "spend-lens:" + sha256Hex(cloud + appetite + workload).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(planInputJson))
.build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// started => {"data":{"job_id":"job_..."}}
// then poll GET /jobs/{job_id} until status is succeeded or failed, and read
// data.output.output — the plan JSON as a string.
require "digest"
def idem_key(inp, attempt = 1)
seed = %w[cloud commitment_appetite workload constraints].map { |k| inp[k].to_s }.join(" ")
"spend-lens:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a#{attempt}"
end
def run_plan(inp, token)
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = idem_key(inp)
req.body = JSON.generate(inp)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("/jobs/#{job_id}", token: token)
return JSON.parse(job["output"]["output"]) if job["status"] == "succeeded"
raise "run failed" if job["status"] == "failed"
sleep 2
end
end
plan = run_plan(plan_input, token)
puts "#{plan['verdict']} #{plan['grade']} #{plan['levers'].length} levers"
function idem_key(array $inp, int $attempt = 1): string {
$seed = implode(" ", array_map(fn($k) => (string)($inp[$k] ?? ""),
["cloud", "commitment_appetite", "workload", "constraints"]));
return "spend-lens:" . substr(hash("sha256", $seed), 0, 16) . ":a" . $attempt;
}
function run_plan(array $inp, string $token): array {
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($inp),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . idem_key($inp),
],
]);
$job_id = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("/jobs/" . $job_id, null, $token);
if ($job["status"] === "succeeded") { return json_decode($job["output"]["output"], true); }
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
}
$plan = run_plan($plan_input, $token);
echo $plan["verdict"] . " " . $plan["grade"] . " " . count($plan["levers"]) . " levers" . "\n";
using System.Security.Cryptography;
using System.Text;
static string IdemKey(Dictionary<string, object> inp, int attempt = 1) {
var seed = string.Join(" ", new[] { "cloud", "commitment_appetite", "workload", "constraints" }
.Select(k => inp.TryGetValue(k, out var v) ? v?.ToString() ?? "" : ""));
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(seed))).ToLowerInvariant();
return $"spend-lens:{hash[..16]}:a{attempt}";
}
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = JsonContent.Create(planInput)
};
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", IdemKey(planInput));
var started = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();
// Poll GET /jobs/{jobId} every two seconds; on "succeeded", data.output.output is
// the plan as a JSON string.
Step 5 · Or stream it
POST /run-stream is the same call over server-sent events, which is what
the web app uses so it can show progress. The frame name arrives on the
event: line — job, delta, done — and
is not a type field inside the payload. Concatenate every
delta payload's text to rebuild the JSON, and read
charged_credits and truncated from the done frame.
If truncated is true the output cap was reduced to fit the balance: render
what parsed and tell the user, rather than presenting a clipped plan as complete.
# Server-sent events. Frame names arrive on the `event:` line, not as a field in
# the payload — `delta` carries text chunks, `job` the job id, `done` the
# settlement (charged_credits, truncated).
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @plan-input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"verdict\":\"bloated\","}
# ...
# event: done
# data: {"status":"succeeded","charged_credits":812,"truncated":false}
def run_stream(inp, token, attempt=1, on_delta=None):
data = json.dumps(inp).encode()
req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idem_key(inp, attempt))
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip() or "{}")
if event == "delta":
raw += payload.get("text", "")
if on_delta:
on_delta(payload.get("text", ""))
elif event == "done":
return json.loads(raw), payload
raise RuntimeError("stream ended without a done frame")
plan, settle = run_stream(plan_input, token)
print(plan["verdict"], "charged", settle["charged_credits"])
async function runStream(inp, token, onDelta, attempt = 1) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": idemKey(inp, attempt),
},
body: JSON.stringify(inp),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim() || "{}");
if (event === "delta") { raw += payload.text || ""; onDelta?.(payload.text || ""); }
else if (event === "done") return { plan: JSON.parse(raw), settle: payload };
}
}
}
throw new Error("stream ended without a done frame");
}
const { plan, settle } = await runStream(planInput, token, (t) => process.stdout.write(t));
console.log("\n", plan.verdict, "charged", settle.charged_credits);
// POST /run-stream and read the SSE frames. The frame name is on the `event:`
// line; `delta` payloads carry {"text":"..."} and concatenate into the plan JSON.
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw strings.Builder
event := ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
payload := strings.TrimSpace(line[5:])
if event == "delta" {
var d struct{ Text string `json:"text"` }
json.Unmarshal([]byte(payload), &d)
raw.WriteString(d.Text)
} else if event == "done" {
fmt.Println("settled:", payload)
fmt.Println("plan:", raw.String())
return
}
}
}
// POST /run-stream with BodyHandlers.ofLines() and fold the SSE frames yourself.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(planInputJson))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:") && event[0].equals("delta")) {
// parse {"text":"..."} with your JSON library and append it
raw.append(extractText(line.substring(5).trim()));
}
});
System.out.println(raw); // the plan JSON
def run_stream(inp, token, attempt = 1)
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = idem_key(inp, attempt)
req.body = JSON.generate(inp)
raw = ""
event = nil
settle = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
payload = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
raw << payload.fetch("text", "") if event == "delta"
settle = payload if event == "done"
end
end
end
end
end
[JSON.parse(raw), settle]
end
plan, settle = run_stream(plan_input, token)
puts "#{plan['verdict']} charged #{settle['charged_credits']}"
// POST /run-stream with a write callback; the frame name arrives on `event:`.
$raw = "";
$event = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($plan_input),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . idem_key($plan_input),
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$payload = json_decode(trim(substr($line, 5)), true) ?: [];
$raw .= $payload["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$plan = json_decode($raw, true);
echo $plan["verdict"] . "\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(planInput)
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Idempotency-Key", IdemKey(planInput));
using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("event:")) {
evt = line[6..].Trim();
} else if (line.StartsWith("data:")) {
var payload = JsonDocument.Parse(line[5..].Trim() is { Length: > 0 } s ? s : "{}");
if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
raw.Append(t.GetString());
else if (evt == "done")
Console.WriteLine("settled: " + payload.RootElement);
}
}
Console.WriteLine(raw.ToString());
Step 6 · Read the report history
Past reports are stored in a declared collection named reports, with
title, verdict, grade, total_cost,
identified_savings, untagged_pct, coverage_pct,
waste_count, line_items, currency and
ran_at as indexed fields — so a client can trend a bill and its
recoverable share month over month without reading every document. That month-over-month
diff is the point of the record: it is how you find out whether the levers you pulled
actually landed. Every where entry must be an operator object
({"eq": …}); a bare value is rejected. Operators:
eq ne lt lte gt gte in contains. Records are scoped to the calling subject,
and each POST /guest mints a new guest identity, so reuse one token
across writes and reads. The raw export is never stored — it is far larger than the
64 KB per-document cap — only the aggregated buckets and the plan.
POST /collections/reports/query, but the record CRUD paths sit under
/records and wrap the document in a doc envelope — a detail
worth having in writing, because guessing it costs a 404:
POST /collections/reports/records with
{"doc": {…}} → {"data":{"record":{"record_id":"rec_…"}}}
GET /collections/reports/records/{record_id} ·
PUT /collections/reports/records/{record_id} ·
DELETE /collections/reports/records/{record_id}
Declared field types are enforced on write; undeclared keys (the whole
plan and scan objects, for instance) are stored and returned
intact, they are simply not filterable or orderable. Create, two-operator query with
order_by, and delete were all exercised live against this app before it
shipped.
# The report history lives in a declared collection called `reports`, so a
# scripted client can read and write the same records the web app does.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/reports/query \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"where":{"verdict":{"in":["bloated","trimmable"]},"identified_savings":{"gte":1000}},
"order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}'
# Every `where` entry must be an operator object: {"eq":...}, not a bare value.
# Operators: eq ne lt lte gt gte in contains.
# Trend the bill and its recoverable share month over month.
res = call("/collections/reports/query", body={
"where": {"verdict": {"in": ["bloated", "trimmable"]}},
"order_by": [{"field": "ran_at", "dir": "desc"}],
"limit": 24,
}, token=token)
prev = None
for rec in reversed(res["records"]):
d = rec["doc"]
delta = "" if prev is None else " (%+.0f vs prior)" % (d["total_cost"] - prev)
print(d["ran_at"], d["grade"], d["total_cost"], d["currency"],
"recoverable", d["identified_savings"], delta)
prev = d["total_cost"]
const res = await call("/collections/reports/query", {
token,
body: {
where: { identified_savings: { gte: 1000 } },
order_by: [{ field: "ran_at", dir: "desc" }],
limit: 24,
},
});
for (const rec of res.records) {
const d = rec.doc;
console.log(d.ran_at, d.grade, d.total_cost, d.currency, "recoverable", d.identified_savings);
}
// POST /collections/reports/query with an operator object per where field.
query := map[string]any{
"where": map[string]any{"identified_savings": map[string]any{"gte": 1000}},
"order_by": []map[string]string{{"field": "ran_at", "dir": "desc"}},
"limit": 24,
}
var res struct {
Records []struct {
RecordID string `json:"record_id"`
Doc map[string]any `json:"doc"`
} `json:"records"`
}
if err := call("/collections/reports/query", token, query, &res); err != nil {
panic(err)
}
for _, r := range res.Records {
fmt.Println(r.Doc["ran_at"], r.Doc["grade"], r.Doc["total_cost"], r.Doc["identified_savings"])
}
// POST /collections/reports/query
String q = "{\"where\":{\"identified_savings\":{\"gte\":1000}}," +
"\"order_by\":[{\"field\":\"ran_at\",\"dir\":\"desc\"}],\"limit\":24}";
System.out.println(call("/collections/reports/query", token, q));
res = call("/collections/reports/query", body: {
"where" => { "identified_savings" => { "gte" => 1000 } },
"order_by" => [{ "field" => "ran_at", "dir" => "desc" }],
"limit" => 24,
}, token: token)
res["records"].each do |rec|
d = rec["doc"]
puts "#{d['ran_at']} #{d['grade']} #{d['total_cost']} #{d['currency']} recoverable #{d['identified_savings']}"
end
$res = call("/collections/reports/query", [
"where" => ["identified_savings" => ["gte" => 1000]],
"order_by" => [["field" => "ran_at", "dir" => "desc"]],
"limit" => 24,
], $token);
foreach ($res["records"] as $rec) {
$d = $rec["doc"];
echo "{$d['ran_at']} {$d['grade']} {$d['total_cost']} recoverable {$d['identified_savings']}\n";
}
var q = new {
where = new { identified_savings = new { gte = 1000 } },
order_by = new[] { new { field = "ran_at", dir = "desc" } },
limit = 24
};
var res = await Call("/collections/reports/query", q, token);
foreach (var rec in res.GetProperty("records").EnumerateArray()) {
var d = rec.GetProperty("doc");
Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("grade")} {d.GetProperty("total_cost")}");
}
The input schema
These are the exact fields the app submits. The raw cost export never
travels. The browser parses every line item locally and sends only
prescan — the aggregate — plus top_rows, a bounded
sample of the largest service/usage buckets. That is what makes a 200 000-row export
affordable: the payload is roughly the same size whether the file was 50 rows or 50
megabytes. The prescan fields are also what the model is held to:
waste_verdicts must return exactly one entry per
prescan.waste_findings id. A client that computes no prescan may send empty
objects and arrays; the plan still works, it simply has nothing to be reconciled against.
| Field | Type | Meaning |
|---|---|---|
cloud | string | aws, azure, gcp, oci or multi. Decides which provider's discount table and tooling the advice names. |
commitment_appetite | string | none, one-year or three-year. A hard limit: the plan must not recommend a longer term than this. |
workload | string | What runs on the account — what is steady-state, what is bursty, what can be restarted safely. Optional but load-bearing: nothing in a cost export says whether a queue is a nightly batch or a payment path, and that is what decides whether spot capacity is right or wrong. |
constraints | string | Data residency, regions that cannot move, teams that own resources, change-freeze windows, commitments already held. Treated as hard limits; each one must be reflected somewhere in the plan. |
current_datetime | string | The caller's local time. |
prescan | object | The browser's deterministic read. See the sub-fields below. |
prescan.currency | string | ISO code parsed from the export, or the caller's override. |
prescan.period | object | {start, end, months, label}, or null when the export had no date column. |
prescan.line_items, total_cost, excluded_cost | number | Rows parsed, the summed bill, and the part of it that is tax, support, marketplace fees and credits — real money, but not addressable by any lever here. |
prescan.grade, verdict | string | The browser's own A–F grade and bloated/trimmable/lean read, from the recoverable share alone. The plan may disagree; the page shows both. |
prescan.concentration | object | {services_to_80pct, total_services}. |
prescan.top_services, accounts, regions | array | {name, cost, share} each, largest first. |
prescan.untagged_cost, untagged_pct, tag_keys_seen, missing_tag_keys | mixed | Cost allocation. missing_tag_keys is {key, cost, pct} per required key, worst first. |
prescan.pricing_split | array | {key, label, cost, share} across on_demand, reserved, savings_plan, committed_use and spot. |
prescan.commitment_coverage_pct, commitable_cost, commit_saving_range | mixed | Coverage against eligible compute, the on-demand compute a commitment could cover, and [low, high] modelled savings at the conservative and aggressive ends of the discount range. |
prescan.waste_findings | array | {id, rule, category, severity, confidence, service, evidence, cost, assumed_recoverable_pct, est_saving, why} per matched rule. Every id here needs exactly one waste_verdicts entry back. |
prescan.storage_tiering | object | {standard_cost, archived_cost, archived_share, est_saving, save_fraction, note}, or null when the export has no object storage. |
prescan.data_transfer_cost, data_transfer_pct | number | NAT, cross-zone and egress cost isolated from the rest. |
prescan.trend | object | {granularity, span, periods, movers, anomalies, run_rate}, or null without a date column. anomalies are days more than two standard deviations above the daily mean. |
prescan.rightsizing | object | {measured, idle_cost, oversized_cost, est_saving, idle, oversized}, or null when the export carries no utilisation column — in which case rightsizing must be declared unmeasurable rather than guessed. |
prescan.identified_savings | object | {high, medium, low, total, pct, capped}. capped is true when the ledger hit its 60%-of-bill ceiling because the levers overlap. |
prescan.checklist | array | {id, group, label, pass, detail} for the fifteen items V1–V4, R1–R4, P1–P3, A1–A3 and G1. |
prescan.warnings, clipped | mixed | What the parse could not do, and exactly what was summarised rather than sent row by row. |
top_rows | array | Up to 120 {service, usage_type, region, pricing, items, cost, share} buckets, largest first. |
retry_note | string | Send only when re-asking after a malformed reply, with the same idempotency key seed and a bumped attempt counter. |
A complete body
{
"cloud": "aws",
"commitment_appetite": "one-year",
"workload": "A B2B analytics product on two accounts. Production is a steady always-on API tier plus a Postgres primary with one replica; the nightly Spark batch can be restarted safely. A separate dev/staging account is used by four engineers on Pacific hours.",
"constraints": "Production must stay in us-east-1 for a data-residency clause. The data team must approve any deletion in their account. Change freeze in the last three business days of each month.",
"current_datetime": "2026-08-06T09:15:00+00:00 (Thursday)",
"prescan": {
"currency": "USD",
"period": { "start": "2026-07-01", "end": "2026-07-14", "months": 1, "label": "2026-07" },
"line_items": 91,
"total_cost": 49023,
"excluded_cost": 600,
"format": "csv",
"grade": "F",
"verdict": "bloated",
"concentration": { "services_to_80pct": 4, "total_services": 11 },
"top_services": [
{ "name": "Amazon Elastic Compute Cloud", "cost": 24122, "share": 49.2 },
{ "name": "Amazon Simple Storage Service", "cost": 7500, "share": 15.3 }
],
"accounts": [{ "name": "481516234290", "cost": 33900, "share": 69.2 }],
"regions": [{ "name": "us-east-1", "cost": 34600, "share": 70.6 }],
"untagged_cost": 8022,
"untagged_pct": 16.4,
"tag_keys_seen": ["Environment", "Owner", "CostCenter"],
"missing_tag_keys": [{ "key": "CostCenter", "cost": 31400, "pct": 64.1 }],
"pricing_split": [
{ "key": "on_demand", "label": "On demand", "cost": 43900, "share": 89.6 },
{ "key": "reserved", "label": "Reserved / reservation", "cost": 2632, "share": 5.4 }
],
"commitment_coverage_pct": 5.2,
"commitable_cost": 23800,
"commit_saving_range": [7140, 14280],
"waste_findings": [
{ "id": "W1", "rule": "unattached-volume", "category": "waste", "severity": "high",
"confidence": "high", "service": "Amazon Elastic Block Store",
"evidence": "2 line items matched, $2,140 (4.4% of the bill)",
"cost": 2140, "assumed_recoverable_pct": 100, "est_saving": 2140,
"why": "A volume with no instance attached is billed in full and serves nothing." }
],
"storage_tiering": { "standard_cost": 7070, "archived_cost": 0, "archived_share": 0,
"est_saving": 2828, "save_fraction": 0.4,
"note": "No colder-tier line items appear at all." },
"data_transfer_cost": 8200,
"data_transfer_pct": 16.7,
"trend": { "granularity": "daily", "span": "2026-07",
"periods": [{ "label": "2026-07", "cost": 49023 }],
"movers": [], "anomalies": [],
"run_rate": { "days": 14, "per_day": 3501.64, "month_projection": 105049.29 } },
"rightsizing": null,
"identified_savings": { "high": 2540, "medium": 25260, "low": 620,
"total": 27800, "pct": 56.7, "capped": false },
"checklist": [
{ "id": "V1", "group": "Visibility", "label": "Cost allocation tags present on spend",
"pass": false, "detail": "$8,022 (16.4%) carries no tag." }
],
"warnings": ["1 row carried a negative amount (credits, refunds or savings-plan negations)."],
"clipped": ""
},
"top_rows": [
{ "service": "Amazon Elastic Compute Cloud", "usage_type": "BoxUsage:m4.2xlarge",
"region": "us-east-1", "pricing": "on_demand", "items": 14, "cost": 8883, "share": 18.1 }
]
}
The output contract
The reply is one JSON object and nothing else. Parse defensively
anyway: strip a stray code fence, take the span from the first { to the
last }, and re-ask once with a retry_note and the same
idempotency seed if it does not parse. These are the fields the app's own render
path requires, and the constraints it enforces.
| Field | Constraint |
|---|---|
verdict | Exactly one of bloated, trimmable, lean. Graded on the recoverable share, not on the size of the bill. |
grade | A single letter, one of A B C D F. The app upper-cases the first character and rejects anything else. |
headline | One sentence a finance partner would understand, naming the single biggest lever. |
summary | Two to four sentences: where the money is, what is genuinely recoverable, and what the export cannot show. |
levers | Non-empty array. {key, title, monthly_saving, confidence, effort, risk, why}. monthly_saving is expressed over the period prescan.period covers, which is not always a month — when it is not, the reply should say so and cite prescan.trend.run_rate.month_projection rather than silently rescaling. key is one of waste, commitments, tiering, rightsizing, transfer, tagging, architecture and must not repeat — a duplicate key is a hard rejection. confidence is high/medium/low, effort is hours/days/weeks, risk is none/low/medium/high. The app sorts by saving and shows the total. |
findings | Non-empty array. {id, severity, title, evidence, impact, fix, monthly_saving, risk}. severity is critical/high/medium/low. An entry with no title, impact or fix is dropped. fix should name the console page, API or IaC resource, not a principle. |
waste_verdicts | Exactly one entry per prescan.waste_findings id, and no other ids. {id, verdict, note}; verdict is confirmed, needs-check or false-positive. Empty when the prescan matched nothing. Skipped and invented ids are both reported to the user, and a false-positive subtracts its estimate from the ledger on screen. |
commitment_plan | {recommendation, coverage_target_pct, keep_on_demand, monthly_saving, caveat}. coverage_target_pct is an integer 0–100; a value more than five points below the coverage already measured is flagged on the page, while a value matching current coverage is read as "commit to nothing further" and rendered as such. The term must not exceed commitment_appetite. |
tagging_plan | {required_keys, enforcement, first_target}. The page cross-checks required_keys against the keys the caller actually required and marks any it omitted. |
governance | Array of sentences — budget alerts, anomaly detection, review cadence. May be empty. |
plan_30_day | Non-empty array of {week, actions}, covering weeks 1–4. A week with an empty actions array is dropped; if all are dropped the reply is rejected. Deletions belong before commitments. |
missing_data | Array of sentences: what the export does not contain that would sharpen the plan, and how to get it. The app appends its own parse warnings to this list. |
next_steps | Array of sentences, in order. The app appends any top-five service the plan never named. |
monthly_saving may exceed prescan.total_cost — the app
clamps it, but a number larger than the whole bill discredits the report before anyone
reads the reasoning. And waste before commitments: committing to a
multi-year term on capacity that should have been deleted is the most expensive mistake
in this discipline, so the deletions come first in plan_30_day even when the
commitment lever is worth more. If you build your own client, keep both checks in your
render path.
The free lane is client-side, and you can have it too
Everything under prescan is computed in the browser by
billscan.js, which ships with the app and calls
no network: delimiter and format detection across CSV, TSV, semicolon, pipe, markdown
tables and JSON arrays; column mapping by synonym across the AWS Cost and Usage Report,
Azure Cost Management, GCP billing export and OCI cost report naming schemes; European
and US number formats and currency signs; tag extraction from both per-key columns and a
bundled key=value column; fifteen named waste rules each with an explicit recoverable
fraction; the pricing-model split and commitment coverage; object-storage tier
classification; data-transfer isolation; the period series, the largest movers and the
two-sigma anomalous days; rightsizing from a utilisation column when one is present; the
fifteen-item checklist; the graded savings ledger with its 60% overlap cap; and Terraform
generation. It exposes window.BillScan.analyze(text, {requiredTags, currency}),
tabulate(text), clip(text, max), fmtMoney(n, currency)
and buildIaC(scan, opts). A pipeline that wants a grade on every monthly
export without spending anything can run that module alone, and call /run
only when scan.savings.pct crosses a threshold.
window stub
(global.window = global) and a Blob shim; it has no other
dependencies and does no I/O. analyze() returns
{ok: false, error} rather than throwing when the text is not a cost table,
so a CI job can fail with the reason.