Spend Lens — API

Turn a cost export into a savings plan, from your own tools.

API tokens Open the app

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 AF 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

HTTPcodeWhat it means and what to do
400validation_errorThe 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.
401unauthorizedNo token, a malformed token, or a token that has expired. Mint a new guest token or sign in again.
402payment_requiredThe balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits.
404not_foundUnknown 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.
409conflictAn Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
500internal_errorTransient. Retry with the same Idempotency-Key so the retry cannot bill twice.
The one call that costs money is /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.

Writing records. The query endpoint above is 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.

FieldTypeMeaning
cloudstringaws, azure, gcp, oci or multi. Decides which provider's discount table and tooling the advice names.
commitment_appetitestringnone, one-year or three-year. A hard limit: the plan must not recommend a longer term than this.
workloadstringWhat 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.
constraintsstringData 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_datetimestringThe caller's local time.
prescanobjectThe browser's deterministic read. See the sub-fields below.
prescan.currencystringISO code parsed from the export, or the caller's override.
prescan.periodobject{start, end, months, label}, or null when the export had no date column.
prescan.line_items, total_cost, excluded_costnumberRows 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, verdictstringThe browser's own AF grade and bloated/trimmable/lean read, from the recoverable share alone. The plan may disagree; the page shows both.
prescan.concentrationobject{services_to_80pct, total_services}.
prescan.top_services, accounts, regionsarray{name, cost, share} each, largest first.
prescan.untagged_cost, untagged_pct, tag_keys_seen, missing_tag_keysmixedCost allocation. missing_tag_keys is {key, cost, pct} per required key, worst first.
prescan.pricing_splitarray{key, label, cost, share} across on_demand, reserved, savings_plan, committed_use and spot.
prescan.commitment_coverage_pct, commitable_cost, commit_saving_rangemixedCoverage 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_findingsarray{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_tieringobject{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_pctnumberNAT, cross-zone and egress cost isolated from the rest.
prescan.trendobject{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.rightsizingobject{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_savingsobject{high, medium, low, total, pct, capped}. capped is true when the ledger hit its 60%-of-bill ceiling because the levers overlap.
prescan.checklistarray{id, group, label, pass, detail} for the fifteen items V1V4, R1R4, P1P3, A1A3 and G1.
prescan.warnings, clippedmixedWhat the parse could not do, and exactly what was summarised rather than sent row by row.
top_rowsarrayUp to 120 {service, usage_type, region, pricing, items, cost, share} buckets, largest first.
retry_notestringSend 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.

FieldConstraint
verdictExactly one of bloated, trimmable, lean. Graded on the recoverable share, not on the size of the bill.
gradeA single letter, one of A B C D F. The app upper-cases the first character and rejects anything else.
headlineOne sentence a finance partner would understand, naming the single biggest lever.
summaryTwo to four sentences: where the money is, what is genuinely recoverable, and what the export cannot show.
leversNon-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.
findingsNon-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_verdictsExactly 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.
governanceArray of sentences — budget alerts, anomaly detection, review cadence. May be empty.
plan_30_dayNon-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_dataArray 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_stepsArray of sentences, in order. The app appends any top-five service the plan never named.
Two prohibitions matter most. No invented numbers: every figure must appear in the prescan or be derived from figures that do, and no single 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.

Reading the module in Node needs a 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.