Size Lens — API

Paste the Flutter size report, get the shrink plan.

API tokens Open the app

Audit your app size from CI

Send the measured facts of a Flutter build — total bytes, category byte counts, the largest items and the mechanical flags your own tooling raised — and get back one JSON object: severity-ranked findings that cite the measured evidence, a sequenced reduction plan with real flutter build flags and per-step impact estimates, store-cap math for Google Play's 200 MB download limit, and a posture: healthy, trim-recommended or oversized. Everything the app does goes through the SkillSafe App API — plain JSON over HTTPS — so the natural home for this is a release pipeline: build with --analyze-size, condense the treemap, post it here, fail the build when the posture regresses. 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 size-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. The audit is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one facts object in, one audit out, no follow-up calls and no session state to carry.

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

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

Step 0 — A tiny client

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

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1 — or export SKILLSAFE_TOKEN from /tokens.html

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

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

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

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

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

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

func api(method, path string, body any, out any) error {
    var buf bytes.Buffer
    if body != nil {
        json.NewEncoder(&buf).Encode(body)
    }
    req, _ := http.NewRequest(method, API+path, &buf)
    req.Header.Set("Authorization", "Bearer "+TOKEN)
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil { return err }
    defer res.Body.Close()
    var envelope struct {
        Data  json.RawMessage `json:"data"`
        Error *struct{ Message string } `json:"error"`
    }
    if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil { return err }
    if envelope.Error != nil { return fmt.Errorf(envelope.Error.Message) }
    return json.Unmarshal(envelope.Data, out)
}
import java.net.URI;
import java.net.http.*;

public class SizeLens {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = "YOUR_TOKEN"; // see step 1

    static String api(String method, String path, String jsonBody) throws Exception {
        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json");
        b = jsonBody == null ? b.GET()
            : b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
        HttpResponse<String> res = HttpClient.newHttpClient()
            .send(b.build(), HttpResponse.BodyHandlers.ofString());
        return res.body(); // {"data": ...} — parse with your JSON library of choice
    }
}
require "net/http"
require "json"

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

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

function api(string $method, string $path, ?array $body = null) {
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . TOKEN,
            "Content-Type: application/json",
        ],
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $payload = json_decode(curl_exec($ch), true);
    if (isset($payload["error"])) throw new Exception($payload["error"]["message"]);
    return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

class SizeLens {
    const string API = "https://api.skillsafe.ai/v1/app-api";
    const string TOKEN = "YOUR_TOKEN"; // see step 1
    static readonly HttpClient http = new();

    static async Task<JsonElement> Api(HttpMethod method, string path, object? body = null) {
        var req = new HttpRequestMessage(method, API + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);
        if (body != null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var payload = JsonDocument.Parse(await (await http.SendAsync(req)).Content.ReadAsStringAsync()).RootElement;
        if (payload.TryGetProperty("error", out var err))
            throw new Exception(err.GetProperty("message").GetString());
        return payload.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

Two kinds of token work here. A personal token is the one this site stores in your browser after you sign in — grab it from the token page (it shows session state, masks the token, and copies a ready-made export SKILLSAFE_TOKEN="…" line). A guest token needs no account at all and is enough for /me and the free /estimate; metered runs need a personal token (or a guest wallet you have topped up).

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"size-lens"}' | jq -r .data.token
# aut_... — export it:  export TOKEN="aut_..."
token = api("POST", "/guest", {"slug": "size-lens"}, token="")["token"]
print(token)  # aut_...
const { token } = await api("POST", "/guest", { slug: "size-lens" }, "");
console.log(token); // aut_...
var guest struct{ Token string `json:"token"` }
api("POST", "/guest", map[string]string{"slug": "size-lens"}, &guest)
fmt.Println(guest.Token) // aut_...
String body = api("POST", "/guest", "{\"slug\":\"size-lens\"}");
// {"data":{"token":"aut_...", ...}}
token = api("POST", "/guest", { slug: "size-lens" })["token"]
puts token # aut_...
$token = api("POST", "/guest", ["slug" => "size-lens"])["token"];
echo $token; // aut_...
var guest = await Api(HttpMethod.Post, "/guest", new { slug = "size-lens" });
Console.WriteLine(guest.GetProperty("token").GetString()); // aut_...

Step 2 — Who am I, and what can I spend?

GET /me

Returns the token's subject_type (user or guest) and its credits balance. Check it before a metered run: if credits is below the estimate's min_credits the run will 402.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq .data
# {"subject_type":"user","subject_id":"...","credits":98550,...}
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
    SubjectType string `json:"subject_type"`
    Credits     int64  `json:"credits"`
}
api("GET", "/me", nil, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = api("GET", "/me", null);
// {"data":{"subject_type":"user","credits":98550,...}}
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = api("GET", "/me");
echo $me["subject_type"] . " " . $me["credits"];
var me = await Api(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — The input: measured facts, not the raw file

The web app parses the *-code-size-analysis_*.json in the browser and sends only a condensed facts object; calling the API directly you do the same condensation yourself (the treemap file can be megabytes, and the model neither needs nor wants every node). The input object for every /estimate, /run and /run-stream call looks like this:

{
  "report": {
    "report_kind": "analyze-size-json",     // or app-thinning-report | plain-text
    "platform": "aab",                       // apk | aab | ios | macos | windows | linux | unknown
    "total_bytes": 57186200,
    "total_display": "54.5 MB",
    "categories": [                          // per-category byte totals, descending
      {"name": "Assets", "bytes": 18292000, "pct": 32.0},
      {"name": "Native libraries", "bytes": 16300000, "pct": 28.5}
    ],
    "top_items": [                           // largest items first, cap ~120
      {"path": "wanderplan-release.aab/lib/arm64-v8a/libflutter.so",
       "bytes": 11600000, "category": "native"}
    ],
    "variants": [],                          // App Thinning variants, iOS only
    "clipped": {"nodes_profiled": 27, "items_sent": 27},
    "flags": [                               // mechanical checks that fired
      {"id": "icon-font:1",
       "label": "MaterialIcons-Regular.otf — 1.6 MB (expect ~10 KB when tree-shaken)"}
    ]
  },
  "app_context": "Consumer travel planner; install size hurts ad conversion.",  // optional
  "goal": "Get the download under 30 MB."                                       // optional
}
# Condense a *-code-size-analysis_*.json into the facts object above.
def facts(treemap_path):
    tree = json.load(open(treemap_path))
    leaves = []
    def walk(node, prefix=""):
        name = str(node.get("n", node.get("name", "")))
        path = f"{prefix}/{name}" if prefix and name else (name or prefix)
        kids = node.get("children") or []
        if kids:
            for k in kids: walk(k, path)
        elif isinstance(node.get("value"), (int, float)):
            leaves.append({"path": path, "bytes": int(node["value"])})
    walk(tree)
    leaves.sort(key=lambda l: -l["bytes"])
    total = sum(l["bytes"] for l in leaves)
    return {"report_kind": "analyze-size-json", "platform": "aab",
            "total_bytes": total, "total_display": f"{total/1048576:.1f} MB",
            "categories": [], "top_items": leaves[:120], "variants": [],
            "clipped": {"nodes_profiled": len(leaves), "items_sent": min(len(leaves), 120)},
            "flags": []}

payload = {"report": facts("build/apk-code-size-analysis_01.json"),
           "app_context": "Consumer travel planner.",
           "goal": "Get the download under 30 MB."}
// Condense a *-code-size-analysis_*.json into the facts object above.
function facts(tree) {
  const leaves = [];
  (function walk(node, prefix) {
    const name = String(node.n ?? node.name ?? "");
    const path = prefix && name ? prefix + "/" + name : (name || prefix);
    if (node.children?.length) node.children.forEach((k) => walk(k, path));
    else if (typeof node.value === "number") leaves.push({ path, bytes: node.value });
  })(tree, "");
  leaves.sort((a, b) => b.bytes - a.bytes);
  const total = leaves.reduce((s, l) => s + l.bytes, 0);
  return { report_kind: "analyze-size-json", platform: "aab",
    total_bytes: total, total_display: (total / 1048576).toFixed(1) + " MB",
    categories: [], top_items: leaves.slice(0, 120), variants: [],
    clipped: { nodes_profiled: leaves.length, items_sent: Math.min(leaves.length, 120) },
    flags: [] };
}
// The input body, as Go types. Condense your treemap into TopItems the same
// way the Python/JS samples do: flatten to leaves, sort descending, cap at 120.
type Item struct {
    Path     string `json:"path"`
    Bytes    int64  `json:"bytes"`
    Category string `json:"category"`
}
type Report struct {
    ReportKind   string  `json:"report_kind"`
    Platform     string  `json:"platform"`
    TotalBytes   int64   `json:"total_bytes"`
    TotalDisplay string  `json:"total_display"`
    Categories   []any   `json:"categories"`
    TopItems     []Item  `json:"top_items"`
    Variants     []any   `json:"variants"`
    Clipped      map[string]int `json:"clipped"`
    Flags        []any   `json:"flags"`
}
type Input struct {
    Report     Report `json:"report"`
    AppContext string `json:"app_context,omitempty"`
    Goal       string `json:"goal,omitempty"`
}
// Build the same JSON with your JSON library. The shape (cURL tab) is the
// contract; only report.report_kind, report.platform, report.total_bytes,
// report.total_display and report.top_items are required in practice —
// categories, variants, clipped and flags may be empty.
String input = """
{"report": {"report_kind": "analyze-size-json", "platform": "aab",
  "total_bytes": 57186200, "total_display": "54.5 MB",
  "categories": [], "top_items": [
    {"path": "lib/arm64-v8a/libflutter.so", "bytes": 11600000, "category": "native"}],
  "variants": [], "clipped": {"nodes_profiled": 27, "items_sent": 27}, "flags": []},
 "app_context": "Consumer travel planner.",
 "goal": "Get the download under 30 MB."}
""";
# Condense a *-code-size-analysis_*.json into the facts object above.
def facts(treemap_path)
  leaves = []
  walk = lambda do |node, prefix|
    name = (node["n"] || node["name"] || "").to_s
    path = prefix.empty? || name.empty? ? (name.empty? ? prefix : name) : "#{prefix}/#{name}"
    kids = node["children"] || []
    if kids.any? then kids.each { |k| walk.call(k, path) }
    elsif node["value"].is_a?(Numeric) then leaves << { "path" => path, "bytes" => node["value"].to_i }
    end
  end
  walk.call(JSON.parse(File.read(treemap_path)), "")
  leaves.sort_by! { |l| -l["bytes"] }
  total = leaves.sum { |l| l["bytes"] }
  { "report_kind" => "analyze-size-json", "platform" => "aab",
    "total_bytes" => total, "total_display" => "#{(total / 1048576.0).round(1)} MB",
    "categories" => [], "top_items" => leaves.first(120), "variants" => [],
    "clipped" => { "nodes_profiled" => leaves.size, "items_sent" => [leaves.size, 120].min },
    "flags" => [] }
end
<?php
// Condense a *-code-size-analysis_*.json into the facts object above.
function facts(string $treemapPath): array {
    $leaves = [];
    $walk = function ($node, $prefix) use (&$walk, &$leaves) {
        $name = (string)($node["n"] ?? $node["name"] ?? "");
        $path = ($prefix && $name) ? "$prefix/$name" : ($name ?: $prefix);
        $kids = $node["children"] ?? [];
        if ($kids) { foreach ($kids as $k) $walk($k, $path); }
        elseif (is_numeric($node["value"] ?? null)) $leaves[] = ["path" => $path, "bytes" => (int)$node["value"]];
    };
    $walk(json_decode(file_get_contents($treemapPath), true), "");
    usort($leaves, fn($a, $b) => $b["bytes"] - $a["bytes"]);
    $total = array_sum(array_column($leaves, "bytes"));
    return ["report_kind" => "analyze-size-json", "platform" => "aab",
        "total_bytes" => $total, "total_display" => round($total / 1048576, 1) . " MB",
        "categories" => [], "top_items" => array_slice($leaves, 0, 120), "variants" => [],
        "clipped" => ["nodes_profiled" => count($leaves), "items_sent" => min(count($leaves), 120)],
        "flags" => []];
}
// Build the same JSON with System.Text.Json. The cURL tab is the contract;
// categories, variants, clipped and flags may be empty arrays/objects.
var input = new {
    report = new {
        report_kind = "analyze-size-json", platform = "aab",
        total_bytes = 57186200L, total_display = "54.5 MB",
        categories = Array.Empty<object>(),
        top_items = new[] { new { path = "lib/arm64-v8a/libflutter.so",
                                  bytes = 11600000L, category = "native" } },
        variants = Array.Empty<object>(),
        clipped = new { nodes_profiled = 27, items_sent = 27 },
        flags = Array.Empty<object>()
    },
    app_context = "Consumer travel planner.",
    goal = "Get the download under 30 MB."
};

report.flags ids are namespaced by family (store-cap:*, debug-build:*, multi-abi:*, big-asset:*, big-native:*, icon-font:*, big-package:*). Every id you send comes back reconciled in the audit's coverage_check — send your own CI checks as flags and the model is forced to confirm or explicitly set each one aside.

Step 4 — Estimate the cost (free)

POST /estimate

Same body as a run, no charge, no job. Returns hold_credits (the worst-case reservation), min_credits (the floor needed to start), the resolved model and the model_alias. What a run actually settles for is usually far below the hold.

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq .data
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","hold_credits":2900,...}
est = api("POST", "/estimate", payload)
print(est["model"], est["hold_credits"], est["min_credits"])
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
var est struct {
    Model       string `json:"model"`
    HoldCredits int64  `json:"hold_credits"`
    MinCredits  int64  `json:"min_credits"`
}
api("POST", "/estimate", input, &est)
fmt.Println(est.Model, est.HoldCredits, est.MinCredits)
String est = api("POST", "/estimate", input);
// {"data":{"model":"gpt-5.6-terra","hold_credits":2900,...}}
est = api("POST", "/estimate", payload)
puts "#{est["model"]} hold=#{est["hold_credits"]} min=#{est["min_credits"]}"
$est = api("POST", "/estimate", $payload);
echo "{$est["model"]} hold={$est["hold_credits"]} min={$est["min_credits"]}";
var est = await Api(HttpMethod.Post, "/estimate", input);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

Step 5 — Run the audit and poll

POST /run GET /jobs/{job_id}

POST /run reserves the hold and returns a job_id; poll GET /jobs/{job_id} until status is terminal (succeeded / failed). The audit JSON arrives in output.output as a string — parse it. Send an Idempotency-Key header derived from your input so a network retry can never double-bill.

JOB=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sizelens-$(shasum -a 256 input.json | cut -c1-16)" \
  -d @input.json | jq -r .data.job_id)

until [ "$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN" | jq -r .data.status)" != "running" ]; do
  sleep 2
done
curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN" | jq -r .data.output.output > audit.json
import hashlib, time

key = "sizelens-" + hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16]
r = requests.post(API + "/run", json=payload,
                  headers={"Authorization": "Bearer " + TOKEN, "Idempotency-Key": key})
job_id = r.json()["data"]["job_id"]

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

audit = json.loads(job["output"]["output"])
print(audit["posture"], audit["verdict"])
const key = "sizelens-" + [...JSON.stringify(payload)].reduce((h, c) =>
  (Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0), 0x811c9dc5).toString(16);
const { job_id } = await api("POST", "/run", payload); // add Idempotency-Key header via fetch opts

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

const audit = JSON.parse(job.output.output);
console.log(audit.posture, audit.verdict);
var run struct{ JobID string `json:"job_id"` }
api("POST", "/run", input, &run) // set an Idempotency-Key header in api() for retries

var job struct {
    Status string `json:"status"`
    Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
    api("GET", "/jobs/"+run.JobID, nil, &job)
    if job.Status == "succeeded" || job.Status == "failed" { break }
    time.Sleep(2 * time.Second)
}
// json.Unmarshal([]byte(job.Output.Output), &audit)
String run = api("POST", "/run", input);      // add an Idempotency-Key header for retries
String jobId = /* parse data.job_id from run */ "";
while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    // parse data.status; break on "succeeded" / "failed"; else sleep 2s
    Thread.sleep(2000);
}
// data.output.output is the audit JSON as a string — parse it.
require "digest"
key = "sizelens-" + Digest::SHA256.hexdigest(payload.to_json)[0, 16]
# pass the key as an Idempotency-Key header if you extend api(); then:
job_id = api("POST", "/run", payload)["job_id"]

loop do
  job = api("GET", "/jobs/#{job_id}")
  if %w[succeeded failed].include?(job["status"])
    audit = JSON.parse(job["output"]["output"])
    puts "#{audit["posture"]} — #{audit["verdict"]}"
    break
  end
  sleep 2
end
$jobId = api("POST", "/run", $payload)["job_id"]; // add an Idempotency-Key header for retries

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

$audit = json_decode($job["output"]["output"], true);
echo $audit["posture"] . " — " . $audit["verdict"];
var run = await Api(HttpMethod.Post, "/run", input); // add an Idempotency-Key header for retries
var jobId = run.GetProperty("job_id").GetString();

JsonElement job;
do {
    await Task.Delay(2000);
    job = await Api(HttpMethod.Get, $"/jobs/{jobId}");
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var audit = JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(audit.GetProperty("posture").GetString());

Step 6 — Or stream it

POST /run-stream

Same body, Server-Sent Events out: delta events carry output text as it generates, and a final job event carries the terminal job (including charged_credits). This is what the web app uses; for CI the poll in step 5 is usually simpler.

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: sizelens-stream-001" \
  -d @input.json
# event: delta   data: {"text":"{\"headline\":..."}
# event: job     data: {"status":"succeeded","charged_credits":412,...}
with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": "Bearer " + TOKEN,
                            "Accept": "text/event-stream",
                            "Idempotency-Key": "sizelens-stream-001"}) as r:
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("data:"):
            print(line[5:].strip())
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: { "Authorization": "Bearer " + TOKEN, "Content-Type": "application/json",
             "Accept": "text/event-stream", "Idempotency-Key": "sizelens-stream-001" },
  body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  for (const line of dec.decode(value).split("\n"))
    if (line.startsWith("data:")) console.log(line.slice(5).trim());
}
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+TOKEN)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", "sizelens-stream-001")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
    if line := sc.Text(); strings.HasPrefix(line, "data:") {
        fmt.Println(strings.TrimSpace(line[5:]))
    }
}
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Accept", "text/event-stream")
    .header("Idempotency-Key", "sizelens-stream-001")
    .POST(HttpRequest.BodyPublishers.ofString(input)).build();
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines())
    .body().filter(l -> l.startsWith("data:"))
    .forEach(l -> System.out.println(l.substring(5).trim()));
uri = URI(API + "/run-stream")
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req["Accept"] = "text/event-stream"
  req["Idempotency-Key"] = "sizelens-stream-001"
  req.body = payload.to_json
  http.request(req) do |res|
    res.read_body { |chunk| chunk.each_line { |l| puts l[5..].strip if l.start_with?("data:") } }
  end
end
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN,
        "Content-Type: application/json", "Accept: text/event-stream",
        "Idempotency-Key: sizelens-stream-001"],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) {
        foreach (explode("\n", $chunk) as $l)
            if (str_starts_with($l, "data:")) echo trim(substr($l, 5)), "\n";
        return strlen($chunk);
    },
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, API + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);
req.Headers.Accept.ParseAdd("text/event-stream");
req.Headers.Add("Idempotency-Key", "sizelens-stream-001");
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
    if (line.StartsWith("data:")) Console.WriteLine(line[5..].Trim());

The audit contract

The run output (output.output, a JSON string) always parses to one object with these fields — the same contract the web app renders:

FieldTypeMeaning
headlinestringTitle for the audit, sizes included.
postureenumhealthy · trim-recommended · oversized.
verdictstringOne sentence justifying the posture; answers goal when one was sent.
exec_summarystring2-3 paragraphs, blank-line separated.
assumptions, open_questionsstring[]Explicit inferences and the questions that would change the plan.
findings[]arrayid, severity (high/medium/low), category (code/assets/native/fonts/packaging/config/other), title, detail, evidence (verbatim paths from your input), est_savings_kb (int or null). Empty only under healthy.
plan[]arrayorder, action, detail, command (copy-pastable or empty), impact_kb (int or null), effort, risk. Never empty.
coverage_check[]arrayOne entry per report.flags id you sent: {id, addressed, note}.
est_total_savings_kbint/nullDeduplicated total; never exceeds the measured bytes.
summarystringThe recommendation in one breath, ending with the re-measure loop.

CI recipe: run the audit on each release branch, fail the pipeline when posture is oversized, warn when a high-severity finding appears, and store est_total_savings_kb as a trend metric. Rebuild with --analyze-size after applying steps and re-audit — the loop is the product.