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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large body). |
404 | Unknown job or record id. |
429 | Rate limited — back off and retry. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1 — 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:
| Field | Type | Meaning |
|---|---|---|
headline | string | Title for the audit, sizes included. |
posture | enum | healthy · trim-recommended · oversized. |
verdict | string | One sentence justifying the posture; answers goal when one was sent. |
exec_summary | string | 2-3 paragraphs, blank-line separated. |
assumptions, open_questions | string[] | Explicit inferences and the questions that would change the plan. |
findings[] | array | id, 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[] | array | order, action, detail, command (copy-pastable or empty), impact_kb (int or null), effort, risk. Never empty. |
coverage_check[] | array | One entry per report.flags id you sent: {id, addressed, note}. |
est_total_savings_kb | int/null | Deduplicated total; never exceeds the measured bytes. |
summary | string | The 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.