Vitals Desk — API

Band the capture, derive the budget, get the plan — from your own tools.

API tokens Open the app

Run a performance review from CI, not from a browser tab

Vitals Desk splits a performance audit in two. The deterministic half — detecting the capture format, banding every metric against the published Core Web Vitals thresholds, tallying weight by resource type, deriving the budget line by line, building a savings ledger in which each resource is claimed by exactly one rule, raising the twenty-three evidenced findings — runs in the browser and is free. This API is the other half: the metered review that decides which finding is actually holding the paint back rather than merely measuring badly, writes the fix as real code for the stack you describe, ranks the work by impact against effort, and returns a verdict on every finding including the false positives. You compute the prescan yourself (or lift the free lane's own module, see the last section) and send it in.

The raw capture never travels. A Lighthouse report is often several megabytes; the app reduces it locally to a bounded summary and sends only that. An API caller has the same contract: the endpoint takes the derived prescan, not the report.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every response is the same envelope: {"ok":true,"data":{...}} on success, {"ok":false,"error":{"code":"...","message":"...","details":{...}}} on failure. Check ok before touching data.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/audits/query POST /collections/audits/records

Money. Credits are ten-thousandths of a dollar. The app runs on the gpt-terra alias at markup_bps: 1000 and price_credits: 0 — you pay the model's metered cost plus the publisher's 10%, and nothing to open the page. /estimate is free and creates no job.

Error codes

HTTPcodeWhat to do
400VALIDATION_ERRORThe body is not the shape the app expects. On /guest this is almost always a missing slug in the body - an X-App-Slug header is not accepted. On a collection query it is almost always a bare value in where instead of an operator object.
401UNAUTHORIZEDNo bearer token, or an expired guest token. Mint a new one.
402PAYMENT_REQUIREDThe balance is below min_credits. Call /estimate first and check it against /me: a 402 after submit is a client bug.
404NOT_FOUNDWrong slug, wrong job id, or a collection this app did not declare. audits is the only declared collection.
409CONFLICTAn Idempotency-Key replay whose body differs from the first use. Change the key when the input changes.
413PAYLOAD_TOO_LARGEA collection document over 64 KB. Trim the per-resource list before you store it, which is what the app does.
422CONTRACT_ERRORThe model replied with something that is not the single JSON object the contract requires. Retry once: reuse the SAME key for a pure transport retry, and see the idempotency note in step 4 for how the app's reformat lane keys its one extra attempt.
429RATE_LIMITEDBack off and retry; do not tight-loop.
500INTERNALTransient. Retry with the same idempotency key.

Step 1 · Get a token

Two ways in. A personal token is the one this browser already holds — open /tokens.html and copy it, or the shell export line, so you never have to open a DevTools console. A guest token is minted by POST /guest with the slug in the body; guests can browse and estimate but cannot run unless the app sponsors them, and each new guest token is a new identity with an empty audits collection.

# 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_..."

# Option B - mint a guest token. The slug goes in the BODY. An X-App-Slug
# header is not accepted and answers 400 "slug is required".
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug":"vitals-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest","credits":0}}

# Every later call sends it as a bearer token:
#   -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import json, os, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "vitals-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")   # from /tokens.html, or minted below


def call(path, body=None, method=None, token=None):
    """Every endpoint in this API is JSON in, {data}/{error} out."""
    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")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        payload = json.load(r)
    if not payload.get("ok"):
        raise RuntimeError(payload.get("error", {}).get("code", "unknown"))
    return payload["data"]


if not TOKEN:
    TOKEN = call("/guest", {"slug": SLUG})["token"]   # slug in the body, not a header
print(TOKEN[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "vitals-desk";
// Read the token from a constant or an injected global - never from a Node env object.
let TOKEN = globalThis.SKILLSAFE_TOKEN || "YOUR_TOKEN";

async function call(path, body, 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 payload = await res.json();
  if (!payload.ok) throw new Error(payload.error.code + ": " + payload.error.message);
  return payload.data;
}

if (TOKEN === "YOUR_TOKEN") {
  TOKEN = "";                                  // no bearer on the guest call
  TOKEN = (await call("/guest", { slug: SLUG })).token;
}
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "vitals-desk"

var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html, or minted by guest()

type envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func call(path string, body any, method string) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
		if method == "" {
			method = "POST"
		}
	}
	if method == "" {
		method = "GET"
	}
	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 nil, err
	}
	defer res.Body.Close()
	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if !env.OK {
		return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
	}
	return env.Data, nil
}

func guest() error {
	raw, err := call("/guest", map[string]string{"slug": slug}, "")
	if err != nil {
		return err
	}
	var out struct{ Token string }
	if err := json.Unmarshal(raw, &out); err != nil {
		return err
	}
	token = out.Token
	return nil
}
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class VitalsDesk {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String SLUG = "vitals-desk";
  static String token = System.getenv("SKILLSAFE_TOKEN");   // or minted by guest()
  static final HttpClient HTTP = HttpClient.newHttpClient();

  static String call(String path, String jsonBody, String method) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Content-Type", "application/json");
    if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
    if (jsonBody != null) b.method(method == null ? "POST" : method,
        HttpRequest.BodyPublishers.ofString(jsonBody));
    else b.method(method == null ? "GET" : method, HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() >= 400) throw new RuntimeException(res.body());
    return res.body();   // {"ok":true,"data":{...}} - parse with your JSON library
  }

  static void guest() throws Exception {
    token = "";
    String body = call("/guest", "{\"slug\":\"" + SLUG + "\"}", null);
    token = body.split("\"token\":\"")[1].split("\"")[0];
  }
}
require "json"
require "net/http"

BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "vitals-desk"
TOKEN = ENV["SKILLSAFE_TOKEN"]   # from /tokens.html, or minted below

def call(path, body = nil, method: nil, token: TOKEN)
  uri = URI(BASE.to_s + path)
  klass = method == "DELETE" ? Net::HTTP::Delete : (body ? Net::HTTP::Post : Net::HTTP::Get)
  req = klass.new(uri, "Content-Type" => "application/json")
  req["Authorization"] = "Bearer #{token}" if token && !token.empty?
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
  payload["data"]
end

TOKEN2 = TOKEN || call("/guest", { "slug" => SLUG }, token: nil)["token"]
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "vitals-desk";
$token = getenv("SKILLSAFE_TOKEN") ?: "";   // from /tokens.html, or minted below

function call(string $path, $body = null, ?string $method = null) {
    global $token;
    $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,
        CURLOPT_CUSTOMREQUEST => $method ?? ($body === null ? "GET" : "POST"),
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
    }
    return $payload["data"];
}

if ($token === "") {
    $token = call("/guest", ["slug" => SLUG])["token"];   // slug in the body
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

class VitalsDesk {
  const string Base = "https://api.skillsafe.ai/v1/app-api";
  const string Slug = "vitals-desk";
  static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
  static readonly HttpClient Http = new();

  static async Task<JsonElement> CallAsync(string path, object? body = null, HttpMethod? method = null) {
    var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Base + path);
    if (body is not null)
      req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    if (Token is { Length: > 0 } and not "YOUR_TOKEN")
      req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    var res = await Http.SendAsync(req);
    var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!payload.GetProperty("ok").GetBoolean())
      throw new Exception(payload.GetProperty("error").GetProperty("code").GetString());
    return payload.GetProperty("data");
  }

  static async Task GuestAsync() {
    Token = "";
    Token = (await CallAsync("/guest", new { slug = Slug })).GetProperty("token").GetString()!;
  }
}

Step 2 · Check who you are and what you can spend

GET /me tells you the subject type, the balance, and the app's own model and markup. Compare the balance against /estimate before you submit — a 402 after submit is a client-side failure, not a user error.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{
#      "subject_type":"user","credits":184203,"app":{"slug":"vitals-desk",
#      "model":"gpt-terra","markup_bps":1000,"price_credits":0}}}
#
# credits are in ten-thousandths of a dollar: 184203 = $18.42.
# subject_type is "user" for a personal token and "guest" for a minted one.
me = call("/me", token=TOKEN)
print(me["subject_type"], me["credits"] / 10000, "USD")
const me = await call("/me");
console.log(me.subject_type, me.credits / 10000, "USD");
raw, err := call("/me", nil, "")
// raw is {"subject_type":"user","credits":184203,...}
String me = call("/me", null, null);
System.out.println(me);
me = call("/me")
puts "#{me['subject_type']} #{me['credits'] / 10_000.0} USD"
$me = call("/me");
printf("%s %.2f USD\n", $me["subject_type"], $me["credits"] / 10000);
var me = await CallAsync("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits").GetInt32() / 10000.0} USD");

Step 3 · Price the run, and prove the model binding

POST /estimate costs nothing and creates no job. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. On this app model reads gpt-5.6-terra, model_alias reads gpt-terra and markup_bps is 1000 (10%). Assert on those three in CI: they are the authoritative proof that the app is wired to the right model at the right markup. hold_credits is what gets reserved — it prices the full output cap and is usually far more than the charged_credits you end up paying.

# /estimate is free: no job is created, no credits are held, nothing is charged.
# It is also the authoritative proof of the model binding, so assert on it in CI.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d @body.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#      "markup_bps":1000,"hold_credits":3140,"min_credits":420,
#      "sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged: it prices the full output cap. What you
# pay is charged_credits on the finished job, and it is usually far lower.
# sponsor_enabled tells you whether a guest token may run at all.
est = call("/estimate", body, token=TOKEN)          # free, no job created
assert est["model"] == "gpt-5.6-terra"
assert est["model_alias"] == "gpt-terra"
assert est["markup_bps"] == 1000
print("reserved up to", est["hold_credits"] / 10000, "USD")
if me["credits"] < est["min_credits"]:
    raise SystemExit("top up first - a 402 after submit is a UI failure, not a user error")
const est = await call("/estimate", body);              // free, no job created
console.assert(est.model_alias === "gpt-terra" && est.markup_bps === 1000);
if (me.credits < est.min_credits) throw new Error("top up first");
raw, err = call("/estimate", body, "")
var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int    `json:"hold_credits"`
	MinCredits  int    `json:"min_credits"`
	Sponsored   bool   `json:"sponsor_enabled"`
}
json.Unmarshal(raw, &est)
// est.Model == "gpt-5.6-terra", est.ModelAlias == "gpt-terra", est.MarkupBps == 1000
String est = call("/estimate", bodyJson, null);
// assert est.contains("\"model_alias\":\"gpt-terra\"");
// assert est.contains("\"markup_bps\":1000");
est = call("/estimate", body)
raise "wrong model" unless est["model_alias"] == "gpt-terra" && est["markup_bps"] == 1000
puts "reserved up to #{est['hold_credits'] / 10_000.0} USD"
$est = call("/estimate", $body);
assert($est["model_alias"] === "gpt-terra" && $est["markup_bps"] === 1000);
var est = await CallAsync("/estimate", body);
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("wrong model");

Step 4 · Run the review and poll for it

POST /run is metered and returns {"job_id":"job_..."}; poll GET /jobs/{job_id} until status is succeeded or failed. Always send an Idempotency-Key derived from the input — a network blip or a retry with the same key returns the same job instead of billing twice. output.output is the single JSON object documented in The output contract below.

The key the app itself sends is vitals-desk:<inputhash>:a<attempt>, where the hash is taken over the whole input context. Because the hash comes from the input and not from a clock, every transport retry of one attempt replays the same job. The reformat-retry lane — the one extra run the app makes when a first reply is not the single JSON object the contract demands — reuses that same input-derived base and only bumps the attempt counter, so a malformed first reply can never double-bill the identical attempt, and the two runs stay separable in the ledger.

# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing twice. Derive it from the input, not from a clock.
HASH=$(python3 -c 'import hashlib;print(hashlib.sha256(open("body.json","rb").read()).hexdigest()[:16])')
KEY="vitals-desk:$HASH:a1"          # the reformat retry reuses $HASH and sends :a2

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 @body.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while :; do
  OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  ST=$(echo "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
  [ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
  sleep 2
done
echo "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
# The output field is one JSON object - the review contract documented below.
import hashlib, time

digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
key = "vitals-desk:" + digest + ":a1"
req = urllib.request.Request(BASE + "/run", data=json.dumps(body).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)          # a retry with this key never double-bills
with urllib.request.urlopen(req) as r:
    job_id = json.load(r)["data"]["job_id"]

while True:
    job = call("/jobs/" + job_id, token=TOKEN)
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

review = json.loads(job["output"]["output"])     # the review contract, below
print(review["verdict"], review["lcp_diagnosis"]["cause"])
print(len(review["optimizations"]), "optimizations, highest impact first")
print("charged", job.get("charged_credits", 0) / 10000, "USD")
const enc = new TextEncoder().encode(JSON.stringify(body));
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
  .map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);

const started = await fetch(BASE + "/run", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "vitals-desk:" + digest + ":a1"
  },
  body: JSON.stringify(body)
}).then(r => r.json());

let job;
do {
  await new Promise(r => setTimeout(r, 2000));
  job = await call("/jobs/" + started.data.job_id);
} while (job.status !== "succeeded" && job.status !== "failed");

const review = JSON.parse(job.output.output);
console.log(review.verdict, review.optimizations[0].title);
// POST /run needs the Idempotency-Key header, so build the request directly.
b, _ := json.Marshal(body)
sum := sha256.Sum256(b)
idemKey := "vitals-desk:" + hex.EncodeToString(sum[:8]) + ":a1"
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)
res, _ := http.DefaultClient.Do(req)
// decode {"data":{"job_id":"..."}} then poll GET /jobs/{id} until status is terminal
String key = "vitals-desk:" + Integer.toHexString(bodyJson.hashCode()) + ":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(bodyJson))
    .build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// extract job_id, then poll GET /jobs/{id} every two seconds until terminal
require "digest"

key = "vitals-desk:#{Digest::SHA256.hexdigest(JSON.dump(body))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
                          "Authorization" => "Bearer #{TOKEN2}",
                          "Idempotency-Key" => key)
req.body = JSON.dump(body)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]

job = nil
loop do
  job = call("/jobs/#{job_id}", token: TOKEN2)
  break if %w[succeeded failed].include?(job["status"])
  sleep 2
end
review = JSON.parse(job["output"]["output"])
puts review["verdict"]
$key = "vitals-desk:" . substr(hash("sha256", json_encode($body)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($body),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer $token",
        "Idempotency-Key: $key",
    ],
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);

do {
    sleep(2);
    $job = call("/jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$review = json_decode($job["output"]["output"], true);
var json = JsonSerializer.Serialize(body);
var key = "vitals-desk:" + Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16] + ":a1";

var run = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
  Content = new StringContent(json, Encoding.UTF8, "application/json")
};
run.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
run.Headers.Add("Idempotency-Key", key);
var started = JsonDocument.Parse(await (await Http.SendAsync(run)).Content.ReadAsStringAsync()).RootElement;
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();

JsonElement job;
do {
  await Task.Delay(2000);
  job = await CallAsync("/jobs/" + jobId);
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));

Step 5 · Or stream it

POST /run-stream is the same run over server-sent events, and it is what the app itself uses. Frame names arrive on the event: line, not as a type field inside the payload — switch on the event name. The job frame carries the job_id and arrives first; every delta frame carries a text fragment, and concatenating them in order rebuilds the JSON object; the terminal frame (done, or error when the run failed) carries status, charged_credits and truncated. If truncated is true the balance capped the output: render whatever parsed and say so, 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 - switch on the event name, not on data.type.
curl -N -s -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 @body.json
#
# event: job      data: {"job_id":"job_..."}
# event: delta    data: {"text":"{\"verdict\":\"needs-i"}
# event: delta    data: {"text":"mprovement\",\"headline\":..."}
# event: done     data: {"status":"succeeded","charged_credits":1980,"truncated":false}
#
# A failed run ends on `event: error` with {"code":"...","message":"..."} instead.
# Concatenate every delta.text in order: the result is the JSON object. If `done`
# reports truncated:true the reply was capped by the balance - render what parsed
# and tell the user, rather than presenting a clipped plan as complete.
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(body).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)

raw, event, job_id = "", None, None
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if line.startswith("event: "):
            event = line[7:].strip()             # the frame NAME lives here
        elif line.startswith("data: "):
            payload = json.loads(line[6:])
            if event == "job":
                job_id = payload.get("job_id")
            elif event == "delta":
                raw += payload.get("text", "")
            elif event == "done":
                if payload.get("truncated"):
                    print("cut short by the balance - showing what arrived")
            elif event == "error":
                raise RuntimeError(payload.get("code", "INTERNAL"))

review = json.loads(raw)
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "vitals-desk:" + digest + ":a1"
  },
  body: JSON.stringify(body)
});

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(7).trim();
    else if (line.startsWith("data: ")) {
      const p = JSON.parse(line.slice(6));
      if (event === "delta") raw += p.text || "";
      if (event === "done" && p.truncated) console.warn("truncated - keep the partial");
      if (event === "error") throw new Error(p.code + ": " + p.message);
    }
  }
}
const review = JSON.parse(raw);
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
var raw, event string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event: "):
		event = strings.TrimSpace(line[7:])
	case strings.HasPrefix(line, "data: ") && event == "delta":
		var d struct{ Text string }
		json.Unmarshal([]byte(line[6:]), &d)
		raw += d.Text
	}
}
// raw is the whole JSON object; unmarshal it into your review struct.
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(bodyJson))
    .build();

StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
  if (line.startsWith("event: ")) event[0] = line.substring(7).trim();
  else if (line.startsWith("data: ") && "delta".equals(event[0])) {
    String d = line.substring(6);
    int i = d.indexOf("\"text\":\"");
    if (i >= 0) raw.append(d.substring(i + 8, d.lastIndexOf("\"")));   // use a JSON library
  }
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
                          "Authorization" => "Bearer #{TOKEN2}",
                          "Idempotency-Key" => key)
req.body = JSON.dump(body)

raw = ""
event = 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.chomp!
        if line.start_with?("event: ") then event = line[7..].strip
        elsif line.start_with?("data: ") && event == "delta"
          raw << (JSON.parse(line[6..])["text"] || "")
        end
      end
    end
  end
end
review = JSON.parse(raw)
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($body),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer $token",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            if (str_starts_with($line, "event: ")) {
                $event = trim(substr($line, 7));
            } elseif (str_starts_with($line, "data: ") && $event === "delta") {
                $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$review = json_decode($raw, true);
var stream = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
  Content = new StringContent(json, Encoding.UTF8, "application/json")
};
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
stream.Headers.Add("Idempotency-Key", key);

using var res2 = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res2.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) is not null) {
  if (line.StartsWith("event: ")) evt = line[7..].Trim();
  else if (line.StartsWith("data: ") && evt == "delta")
    raw.Append(JsonDocument.Parse(line[6..]).RootElement.GetProperty("text").GetString());
}
var review = JsonDocument.Parse(raw.ToString()).RootElement;

Step 6 · Read the audit history and gate the regression

The app stores one record per run in a declared collection called audits (acl_read: owner, acl_write: user), which is what makes the only question anyone asks after shipping a fix — did it get faster or slower? — a single query. Records are created with POST /collections/audits/records and a body of {"doc": {...}}; they are read with POST /collections/audits/query and a body of {"where": {...}, "order_by": [...], "limit": n}. Records are scoped to the calling subject and capped at 64 KB per document, so the per-resource list is trimmed before storage and the raw capture is never stored at all.

Every where entry must be an operator object. {"verdict": {"eq": "poor"}} is accepted; the bare-value shorthand {"verdict": "poor"} is rejected. Operators: eq, ne, lt, lte, gt, gte, in, contains.

Declared (indexed) fields

FieldTypeWhat it holds
titlestringThe audit's display title, host plus path unless a label was given.
page_keystringThe re-audit identity: host + path lowercased, trailing slash stripped, then # and the device. Two captures of one page on one device share it. This is the field a regression gate filters on.
page_urlstringThe URL as entered.
devicestringmobile or desktop.
page_typestringThe budget preset key, e.g. marketing, ecommerce, app.
verdictstringThe model's verdict: good, needs-improvement, poor.
posturestringThe arithmetic's own posture from the vitals, which may disagree with verdict.
gradestringThe input grade: how much the capture itself could support.
perf_scorenumber0-100, reported or derived.
lcp_msnumberLargest Contentful Paint in milliseconds.
clsnumberCumulative Layout Shift, unitless.
tbt_msnumberTotal Blocking Time in milliseconds.
total_bytesnumberTotal transfer bytes on first load.
request_countnumberRequests parsed from the capture.
finding_countnumberHow many of F1-F23 fired.
savings_bytesnumberFirst-load savings from the disjoint ledger. Never add the repeat-visit figure to it.
ran_attimestampISO-8601. Sort on this, descending, for newest first.

Undeclared keys in the doc round-trip fine but are not filterable or sortable — the app stores the whole plan, the slimmed scan and the run context that way.

# The audit history lives in a declared collection called `audits`, so a re-audit
# can be diffed against the last run for the same page and device. Records are
# scoped to the calling subject (acl_read: owner) - reuse ONE token across create
# and query, because every POST /guest mints a new identity with an empty
# collection.

# Newest first:
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/audits/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}'

# Every earlier audit of one page on one device - the re-audit question, answered
# in one call.
# NOTE: every where entry must be an OPERATOR OBJECT. The bare shorthand
# {"page_key":"example.com/pricing#mobile"} is rejected with
# "where.page_key must be an object of operators".
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/audits/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"page_key":{"eq":"example.com/pricing#mobile"}},
       "order_by":[{"field":"ran_at","dir":"desc"}],"limit":8}'

# Everything still failing, slowest paint first:
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/audits/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"verdict":{"in":["needs-improvement","poor"]},"lcp_ms":{"gt":2500}},
       "order_by":[{"field":"lcp_ms","dir":"desc"}],"limit":50}'

# Write one yourself (the app does this after every run). Records live under
# /records; the query endpoint is the only one that hangs off the collection root.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/audits/records \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"doc":{"title":"example.com/pricing","page_key":"example.com/pricing#mobile",
             "page_url":"https://example.com/pricing","device":"mobile",
             "page_type":"marketing","verdict":"needs-improvement",
             "posture":"poor","grade":"A","perf_score":58,"lcp_ms":4180,
             "cls":0.26,"tbt_ms":890,"total_bytes":3140448,"request_count":86,
             "finding_count":9,"savings_bytes":1204880,
             "ran_at":"2026-08-06T09:12:44.000Z"}}'

# Read one back, replace it, or delete it:
#   GET    /collections/audits/records/{record_id}
#   PUT    /collections/audits/records/{record_id}   body {"doc":{...}}
#   DELETE /collections/audits/records/{record_id}
#
# Documents are capped at 64 KB, so never store the capture - store the banded
# metrics, the budget, the savings ledger, the findings and the review.
# A regression gate. Query the newest audit for this page_key and fail the build
# if LCP went up. Indexed fields: title, page_key, page_url, device, page_type,
# verdict, posture, grade, perf_score, lcp_ms, cls, tbt_ms, total_bytes,
# request_count, finding_count, savings_bytes, ran_at.
PAGE_KEY = "example.com/pricing#mobile"

history = call("/collections/audits/query", {
    "where": {"page_key": {"eq": PAGE_KEY}},          # operator objects only
    "order_by": [{"field": "ran_at", "dir": "desc"}],
    "limit": 20,
}, token=TOKEN)

records = history["records"]
for rec in records:
    d = rec["doc"]
    print(d["ran_at"], d["verdict"], d["lcp_ms"], "ms LCP", d["total_bytes"], "bytes")

# Fail CI if the paint regressed against the previous audit of the same page.
if len(records) >= 2:
    now, before = records[0]["doc"], records[1]["doc"]
    if now["lcp_ms"] > before["lcp_ms"]:
        raise SystemExit(
            "LCP regressed %d -> %d ms on %s" % (before["lcp_ms"], now["lcp_ms"], PAGE_KEY))
    print("LCP moved %d -> %d ms" % (before["lcp_ms"], now["lcp_ms"]))

# Store this run so the next build has something to compare against.
call("/collections/audits/records", {"doc": {
    "title": "example.com/pricing", "page_key": PAGE_KEY,
    "page_url": "https://example.com/pricing", "device": "mobile",
    "page_type": "marketing", "verdict": review["verdict"],
    "posture": body["prescan"]["posture"], "grade": body["prescan"]["input_grade"],
    "perf_score": body["prescan"]["score"]["value"], "lcp_ms": 4180,
    "cls": 0.26, "tbt_ms": 890, "total_bytes": 3140448, "request_count": 86,
    "finding_count": len(body["prescan"]["findings"]),
    "savings_bytes": body["prescan"]["savings"]["first_load_bytes"],
    "ran_at": "2026-08-06T09:12:44.000Z",
    "plan": review,                 # undeclared: round-trips, but not filterable
}}, token=TOKEN)
// Regression gate: newest audit for this page_key, compared with the one before.
const PAGE_KEY = "example.com/pricing#mobile";

const history = await call("/collections/audits/query", {
  where: { page_key: { eq: PAGE_KEY } },          // operator objects only
  order_by: [{ field: "ran_at", dir: "desc" }],
  limit: 20
});

const [now, before] = history.records.map(r => r.doc);
if (before && now.lcp_ms > before.lcp_ms) {
  throw new Error(`LCP regressed ${before.lcp_ms} -> ${now.lcp_ms} ms on ${PAGE_KEY}`);
}

// Anything still failing, slowest paint first:
const failing = await call("/collections/audits/query", {
  where: { verdict: { in: ["needs-improvement", "poor"] }, lcp_ms: { gt: 2500 } },
  order_by: [{ field: "lcp_ms", dir: "desc" }],
  limit: 50
});
for (const r of failing.records) console.log(r.doc.page_key, r.doc.verdict, r.doc.lcp_ms);
raw, err = call("/collections/audits/query", map[string]any{
	"where":    map[string]any{"page_key": map[string]any{"eq": "example.com/pricing#mobile"}},
	"order_by": []map[string]string{{"field": "ran_at", "dir": "desc"}},
	"limit":    20,
}, "")
// -> {"records":[{"record_id":"...","doc":{...}}],"next_cursor":null}
//
// Then: if len(records) >= 2 && records[0].Doc.LcpMs > records[1].Doc.LcpMs,
// os.Exit(1) - that is the whole regression gate.

// Writing one back:
_, err = call("/collections/audits/records", map[string]any{
	"doc": map[string]any{
		"title": "example.com/pricing", "page_key": "example.com/pricing#mobile",
		"device": "mobile", "verdict": "needs-improvement", "lcp_ms": 4180,
		"cls": 0.26, "tbt_ms": 890, "ran_at": "2026-08-06T09:12:44.000Z",
	},
}, "")
String q = "{\"where\":{\"page_key\":{\"eq\":\"example.com/pricing#mobile\"}},"
         + "\"order_by\":[{\"field\":\"ran_at\",\"dir\":\"desc\"}],\"limit\":20}";
String history = call("/collections/audits/query", q, null);
// Parse it, take doc.lcp_ms from records[0] and records[1], and fail the build
// when the newer one is larger.

String rec = "{\"doc\":{\"title\":\"example.com/pricing\","
           + "\"page_key\":\"example.com/pricing#mobile\",\"device\":\"mobile\","
           + "\"verdict\":\"needs-improvement\",\"lcp_ms\":4180,\"cls\":0.26,"
           + "\"tbt_ms\":890,\"ran_at\":\"2026-08-06T09:12:44.000Z\"}}";
call("/collections/audits/records", rec, null);
PAGE_KEY = "example.com/pricing#mobile"

history = call("/collections/audits/query", {
  "where" => { "page_key" => { "eq" => PAGE_KEY } },   # operator objects only
  "order_by" => [{ "field" => "ran_at", "dir" => "desc" }],
  "limit" => 20
}, token: TOKEN2)

docs = history["records"].map { |r| r["doc"] }
docs.each { |d| puts "#{d['ran_at']} #{d['verdict']} #{d['lcp_ms']}ms" }

if docs.length >= 2 && docs[0]["lcp_ms"] > docs[1]["lcp_ms"]
  abort "LCP regressed #{docs[1]['lcp_ms']} -> #{docs[0]['lcp_ms']} ms on #{PAGE_KEY}"
end

call("/collections/audits/records", { "doc" => {
  "title" => "example.com/pricing", "page_key" => PAGE_KEY, "device" => "mobile",
  "verdict" => review["verdict"], "lcp_ms" => 4180, "cls" => 0.26, "tbt_ms" => 890,
  "ran_at" => Time.now.utc.iso8601
} }, token: TOKEN2)
$pageKey = "example.com/pricing#mobile";

$history = call("/collections/audits/query", [
    "where" => ["page_key" => ["eq" => $pageKey]],   // operator objects only
    "order_by" => [["field" => "ran_at", "dir" => "desc"]],
    "limit" => 20,
]);

$docs = array_map(fn ($r) => $r["doc"], $history["records"]);
foreach ($docs as $d) {
    echo $d["ran_at"], " ", $d["verdict"], " ", $d["lcp_ms"], "ms", PHP_EOL;
}

if (count($docs) >= 2 && $docs[0]["lcp_ms"] > $docs[1]["lcp_ms"]) {
    fwrite(STDERR, "LCP regressed {$docs[1]['lcp_ms']} -> {$docs[0]['lcp_ms']} ms\n");
    exit(1);
}

call("/collections/audits/records", ["doc" => [
    "title" => "example.com/pricing", "page_key" => $pageKey, "device" => "mobile",
    "verdict" => $review["verdict"], "lcp_ms" => 4180, "cls" => 0.26, "tbt_ms" => 890,
    "ran_at" => gmdate("c"),
]]);
const string PageKey = "example.com/pricing#mobile";

var history = await CallAsync("/collections/audits/query", new {
  where = new { page_key = new { eq = PageKey } },     // operator objects only
  order_by = new[] { new { field = "ran_at", dir = "desc" } },
  limit = 20
});

var docs = history.GetProperty("records").EnumerateArray()
    .Select(r => r.GetProperty("doc")).ToList();
foreach (var d in docs)
  Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("verdict")} {d.GetProperty("lcp_ms")}");

if (docs.Count >= 2 &&
    docs[0].GetProperty("lcp_ms").GetDouble() > docs[1].GetProperty("lcp_ms").GetDouble())
  throw new Exception("LCP regressed on " + PageKey);

await CallAsync("/collections/audits/records", new { doc = new {
  title = "example.com/pricing", page_key = PageKey, device = "mobile",
  verdict = "needs-improvement", lcp_ms = 4180, cls = 0.26, tbt_ms = 890,
  ran_at = DateTime.UtcNow.ToString("o")
}});

The input schema

One object. Everything the model is asked to judge is already computed in prescan; the free-text fields are the ones only a human has. Deriving prescan is the caller's responsibility. In the browser the app builds it locally from a Lighthouse JSON report, a PageSpeed Insights response, a HAR capture, a DevTools resource table or a bare list of metric readings — and then sends only the derived object, because the raw capture is never uploaded. An API caller has the same job: parse the capture on your side and post the summary.

FieldTypeNotes
page_urlstringThe page audited. May be empty; when it is, the re-audit diff has nothing to key on.
page_contextstringThe stack, the hosting, the build tool, what has already been tried, what cannot change. Often the most useful field in the whole body: it is what makes the snippets real and stops the review recommending something the team is contractually forbidden to do.
goalstringWhat the caller is trying to achieve, in their words.
changes_sincestringOn a re-audit, what shipped between the two captures.
current_datetimestringThe caller's local date and time.
prior_runobject or nullThe previous audit of the same page and device: page_url, device, verdict, posture, reviewed, perf_score, lcp_ms, cls, tbt_ms, total_bytes, request_count, finding_count, savings_bytes, audited_at. Present it and the review opens with the movement instead of restating the state. reviewed is false when the baseline came from the free browser-only audit: verdict is then empty and only posture carries a conclusion. The baseline is always a different capture — an audit whose readings are identical to the current one is never used as its own prior.
prescan.captureobjectformat, source, requests, lighthouse_version, form_factor.
prescan.pageobjecturl, domain, device, page_type, page_type_label, framework, hydrates.
prescan.posturestringgood, needs-improvement, poor or not-measured, derived from the core vitals alone. The model's verdict is allowed to differ from it.
prescan.scoreobject{value, basis, why}; basis is reported, derived or none.
prescan.metricsarrayOne row per metric present: {id, label, value, unit, band, good, poor, over_good_by, display}. good and poor are the published thresholds; over_good_by is the gap, zero when inside the band.
prescan.metrics_absentarrayThe short names of the metrics this capture did not contain. A HAR has no LCP; say so here rather than omitting it silently.
prescan.weightobjecttotal_transfer, total_decoded, requests, and by_type[] with each resource type's share and the compression ratio it achieved.
prescan.budgetobjectpreset, preset_label, targets, a derivation ledger[] of one line per adjustment, exclusive (the assertion that an explicit target replaced the preset for that field instead of stacking on it), defects[], and rows[] of measured against target.
prescan.savingsobjectfirst_load_bytes, first_load_ms, repeat_visit_bytes, disjoint, reconciliation, lines[] (rule, label, area, bytes, ms, count, items), repeat_visit_items[]. The two ledgers are deliberately not summed: the same byte cannot be saved twice on one load.
prescan.findingsarray{id, label, area, severity, count, bytes, evidence, why, reads} with ids F1 through F23. reads names the fields that detector was allowed to inspect. Every id must come back exactly once in finding_verdicts.
prescan.third_partyobjectPer-origin bytes, requests and main-thread blocking.
prescan.blockingobjectThe render-blocking set, and whether it was measured or inferred.
prescan.lcp_elementobject or nullThe LCP node, when the report named one. Null is a legitimate answer and forces lcp_diagnosis.confidence down.
prescan.reported_opportunitiesarrayLighthouse's own opportunity list, verbatim, so the review can agree or disagree with it explicitly.
prescan.heaviest_requestsarrayUp to twelve rows: {url, type, transfer, decoded, encoding, cache_seconds, unused_bytes, third_party, render_blocking}. This is where snippet paths come from.
prescan.coveragearrayPass/gap checks on what the capture itself could show: {id, group, label, pass, detail}.
prescan.input_gradestringThe grade for the capture's own completeness.
prescan.warningsarrayCaveats raised during the parse.
prescan.clippedstringNon-empty only when pasted text was too large and was cut from the middle on whole-row boundaries, keeping the header and both ends. A dropped file is parsed in full and never clipped. When set, the request list is incomplete and every total is a lower bound — say so in the review instead of pretending completeness.
retry_notestringOnly present when a previous reply was malformed. See the idempotency note in step 4.

A complete body

Compact but valid: two metrics, a small weight object, two findings and a two-line savings ledger is enough for both /estimate and /run. A real body from the app is the same shape with longer arrays.

{
  "page_url": "https://example.com/pricing",
  "page_context": "Next.js 15 app router on Vercel, images through next/image, Tailwind, one hero JPEG served from the origin. The Segment tag manager is mandated by legal and cannot be removed. We already tried lazy-loading below the fold.",
  "goal": "Get LCP under 2.5s on mobile before the pricing relaunch.",
  "changes_since": "",
  "current_datetime": "2026-08-06T09:12:44+01:00 (Thursday)",
  "prior_run": null,
  "prescan": {
    "capture": {
      "format": "Lighthouse JSON report",
      "source": "lighthouse",
      "requests": 86,
      "lighthouse_version": "12.2.1",
      "form_factor": "mobile"
    },
    "page": {
      "url": "https://example.com/pricing",
      "domain": "example.com",
      "device": "mobile",
      "page_type": "marketing",
      "page_type_label": "Marketing or landing page",
      "framework": "Next.js 15",
      "hydrates": true
    },
    "posture": "poor",
    "score": {
      "value": 58,
      "basis": "reported",
      "why": "Lighthouse reported a performance category score of 0.58."
    },
    "metrics": [
      {
        "id": "lcp", "label": "Largest Contentful Paint", "value": 4180, "unit": "ms",
        "band": "poor", "good": 2500, "poor": 4000, "over_good_by": 1680, "display": "4.18s"
      },
      {
        "id": "cls", "label": "Cumulative Layout Shift", "value": 0.26, "unit": "",
        "band": "poor", "good": 0.1, "poor": 0.25, "over_good_by": 0.16, "display": "0.26"
      }
    ],
    "metrics_absent": ["INP", "TTFB"],
    "weight": {
      "total_transfer": 3140448,
      "total_decoded": 8904112,
      "requests": 86,
      "by_type": [
        { "type": "image", "transfer": 1806204, "decoded": 1812440, "count": 22, "share_pct": 57.5, "ratio": 1 },
        { "type": "script", "transfer": 942180, "decoded": 3120884, "count": 31, "share_pct": 30, "ratio": 3.31 },
        { "type": "stylesheet", "transfer": 188402, "decoded": 902114, "count": 6, "share_pct": 6, "ratio": 4.79 }
      ]
    },
    "budget": {
      "preset": "marketing",
      "preset_label": "Marketing or landing page",
      "targets": { "script": 143360, "image": 512000, "total": 1638400 },
      "ledger": [
        { "label": "Marketing preset on mobile", "field": "total", "delta": 1638400, "why": "Preset baseline for a landing page on a mobile connection.", "overridden": false },
        { "label": "Hydrating app shell", "field": "script", "delta": 20480, "why": "A framework that hydrates carries a runtime the preset does not assume.", "overridden": false }
      ],
      "exclusive": true,
      "defects": [],
      "rows": [
        { "field": "image", "label": "Images", "measured": 1806204, "target": 512000, "over": true, "delta": 1294204, "used_pct": 352.8, "overridden": false },
        { "field": "script", "label": "JavaScript", "measured": 942180, "target": 143360, "over": true, "delta": 798820, "used_pct": 657.3, "overridden": false }
      ]
    },
    "savings": {
      "first_load_bytes": 1204880,
      "first_load_ms": 1420,
      "repeat_visit_bytes": 812440,
      "disjoint": true,
      "reconciliation": "Each resource is claimed by exactly one rule; no byte appears in two lines.",
      "lines": [
        {
          "rule": "modern-image-formats", "label": "Re-encode raster images as AVIF or WebP",
          "area": "images", "bytes": 908400, "ms": 1010, "count": 7,
          "items": ["/img/hero-pricing.jpg (742 KB)", "/img/logo-wall.png (94 KB)"]
        },
        {
          "rule": "unused-javascript", "label": "Split or defer unused JavaScript",
          "area": "javascript", "bytes": 296480, "ms": 410, "count": 4,
          "items": ["/_next/static/chunks/charts-8f21.js (188 KB, 82% unused)"]
        }
      ],
      "repeat_visit_items": ["/_next/static/chunks/framework-2a10.js (no Cache-Control)"]
    },
    "findings": [
      {
        "id": "F1", "label": "LCP above the good threshold", "area": "loading",
        "severity": "critical", "count": 1, "bytes": 0,
        "evidence": "LCP 4.18s against a 2.5s good threshold and a 4s poor threshold.",
        "why": "Above 2.5s a visitor perceives the page as slow, and above 4s field data calls it poor.",
        "reads": ["metrics.lcp"]
      },
      {
        "id": "F14", "label": "Raster images in legacy formats", "area": "images",
        "severity": "high", "count": 7, "bytes": 908400,
        "evidence": "7 JPEG or PNG responses totalling 887 KB, largest /img/hero-pricing.jpg at 742 KB.",
        "why": "AVIF and WebP typically cut a JPEG or PNG by a third to a half at the same perceived quality.",
        "reads": ["resources[].type", "resources[].mime", "resources[].transfer"]
      }
    ],
    "third_party": {
      "total_transfer": 604112,
      "total_requests": 19,
      "blocking_ms": 310,
      "origins": [
        { "origin": "cdn.segment.com", "transfer": 188220, "requests": 4, "blocking_ms": 210 }
      ]
    },
    "blocking": {
      "measured": true,
      "count": 3,
      "items": ["/_next/static/css/app-5c2.css", "https://cdn.segment.com/analytics.js/v1/analytics.min.js"]
    },
    "lcp_element": {
      "url": "https://example.com/img/hero-pricing.jpg",
      "node": "img.hero__image",
      "type": "image"
    },
    "reported_opportunities": [
      { "id": "modern-image-formats", "title": "Serve images in next-gen formats", "savings_ms": 1050, "savings_bytes": 908400 }
    ],
    "heaviest_requests": [
      {
        "url": "/img/hero-pricing.jpg", "type": "image", "transfer": 759808, "decoded": 759808,
        "encoding": "none", "cache_seconds": 604800, "unused_bytes": 0,
        "third_party": false, "render_blocking": false
      }
    ],
    "coverage": [
      { "id": "V1", "group": "Vitals", "label": "LCP was reported", "pass": true, "detail": "4.18s from the lab run." },
      { "id": "V3", "group": "Vitals", "label": "INP or a field proxy was reported", "pass": false, "detail": "No interaction data in a lab run; TBT is the proxy used." }
    ],
    "input_grade": "A",
    "warnings": [],
    "clipped": ""
  }
}
Keep metrics honest. A metric the capture did not contain belongs in metrics_absent, not in metrics with a guessed value — the review is instructed to lower its confidence and name the gap in missing_data rather than invent a number, and it can only do that if the gap is declared.

The output contract

The model returns one JSON object and nothing else — no prose before it, no code fence around it, no trailing commentary. These are the rules the app's own render path enforces, so a client that parses the same way will not be surprised:

{
  "verdict": "poor",
  "headline": "The hero JPEG is the largest paint and it is 742 KB unencoded; converting it and preloading it is most of the 1.68s gap on its own.",
  "summary": "LCP is 4.18s against a 2.5s threshold, and the LCP element is /img/hero-pricing.jpg. Images are 57.5% of a 3.14 MB page and 352% of the derived image budget, while the 942 KB of JavaScript is over budget but arrives after the paint and is not what is holding LCP. Convert and preload the hero first; the script work is a second-stage saving that shows up in TBT rather than LCP. CLS at 0.26 is a separate, cheap fix: the hero has no intrinsic size.",
  "lcp_diagnosis": {
    "cause": "The LCP image is discovered late and downloaded at default priority: it is a 742 KB baseline JPEG referenced from markup that hydrates, so the request starts after the render-blocking CSS resolves.",
    "element": "img.hero__image loading https://example.com/img/hero-pricing.jpg",
    "confidence": "high"
  },
  "finding_verdicts": [
    { "id": "F1", "verdict": "confirmed", "note": "4.18s is measured, and the element the report named is the same asset the weight ledger shows as the single heaviest request." },
    { "id": "F14", "verdict": "confirmed", "note": "Seven legacy raster responses, and the largest of them is the LCP element, so this finding and F1 have the same root cause." }
  ],
  "optimizations": [
    {
      "title": "Serve the hero as AVIF with a WebP fallback and preload it at high priority",
      "area": "images",
      "metric": "LCP",
      "impact": "high",
      "effort": "S",
      "problem": "/img/hero-pricing.jpg is 742 KB of baseline JPEG and is the LCP element, discovered only after the render-blocking CSS resolves.",
      "fix": "Let next/image emit AVIF and WebP for the hero, mark it priority so Next.js emits the preload, and give it explicit width and height so it also stops the shift counted in CLS.",
      "snippet": "import hero from './hero-pricing.jpg';\n\n<Image src={hero} alt=\"Pricing\" priority width={1600} height={900} sizes=\"100vw\" />",
      "expected": "The savings ledger attributes 908 KB and roughly 1.01s to the image rule, most of it this file; combined with the priority hint that closes the larger part of the 1.68s LCP gap."
    },
    {
      "title": "Load the mandated tag manager after interactive instead of in the head",
      "area": "third-party",
      "metric": "TBT",
      "impact": "medium",
      "effort": "S",
      "problem": "cdn.segment.com is render-blocking and accounts for 210ms of the 310ms of third-party main-thread time; page_context says it cannot be removed.",
      "fix": "Keep the tag but move it off the critical path with next/script strategy=\"afterInteractive\", and budget it explicitly so it cannot grow silently.",
      "snippet": "<Script src=\"https://cdn.segment.com/analytics.js/v1/analytics.min.js\" strategy=\"afterInteractive\" />",
      "expected": "Removes one of the three render-blocking requests and most of the 210ms; no change to the byte total."
    }
  ],
  "budget_review": {
    "realistic": true,
    "note": "The 140 KB script target is tight for a hydrating app shell but the ledger already granted 20 KB for it, and the page is at 942 KB - the target is not the problem, the bundle is.",
    "targets": [
      { "metric": "Images", "target": "500 KB", "why": "One hero plus a logo wall in modern formats fits inside it comfortably; the current 1.76 MB is legacy encoding, not extra content." }
    ]
  },
  "rollout": [
    { "stage": 1, "actions": ["Convert and preload the hero, add explicit dimensions.", "Move the tag manager to afterInteractive."] },
    { "stage": 2, "actions": ["Split the charts chunk behind a route-level dynamic import."] }
  ],
  "regression_watch": ["AVIF encoding can crush gradients: compare the hero at the two largest breakpoints before shipping.", "Deferring the tag manager delays the first analytics event - confirm with the data team that the event is still counted."],
  "missing_data": ["No INP or TTFB in this capture, so the server's share of the 4.18s is unknown. Capture a field report or a server-timing header before ruling out TTFB."],
  "next_steps": ["Re-run the audit for page_key example.com/pricing#mobile after stage 1 and diff lcp_ms."]
}

The free lane is client-side, and you can have it too

Everything in prescan is computed by one vendored module in the bundle, /vitalscan.js, with no network access and no dependencies. It exposes window.VitalScan.analyze(captureText, options), which detects which of the five capture shapes it was handed and returns the whole object this API takes as prescan, plus budgetsJson, prioritySnippet, resourcesCsv, findingsCsv, savingsCsv and buildReport for the artifacts. The companion /report.js turns the same object into Markdown and a JSON package.

So a pipeline that wants the banded metrics, the budget and the budgets.json without paying for a review does not need this API at all: load those two files in a browser or a JS runtime, call analyze, and take the artifacts. The metered endpoint is only for the judgement half — the LCP attribution, the fix written for your stack, the ranking and the finding verdicts.

<!-- In a browser: two plain script tags, no bundler, no network. -->
<script src="/vitalscan.js"></script>
<script src="/report.js"></script>

// In Node: both files are plain scripts that assign to `window`, so pointing
// `window` at the global object and requiring them is all it takes. No bundler,
// no dynamic evaluation, no network.
const fs = require("fs");
global.window = global;
require("./vitalscan.js");
require("./report.js");

const scan = window.VitalScan.analyze(fs.readFileSync("lh.json", "utf8"), {
  pageUrl: "https://example.com/pricing",
  device: "mobile",              // or "desktop"
  pageType: "marketing",         // the budget preset key
  framework: "Next.js 15",
  spa: true,                     // the page hydrates
  strict: false,                 // tighten the preset
  label: "Pricing, pre-relaunch",
  customBudget: { script: 143360, image: 512000, total: 1638400 },  // bytes, or null
  manualMetrics: { lcp: null, inp: null, cls: null }                // fill a gap by hand
});

if (!scan.ok) throw new Error(scan.error);
console.log(scan.posture, scan.score.value, scan.findings.length, scan.savings.disjoint);

fs.writeFileSync("budgets.json", window.VitalScan.budgetsJson(scan));
fs.writeFileSync("priority.html", window.VitalScan.prioritySnippet(scan));
fs.writeFileSync("resources.csv", window.VitalScan.resourcesCsv(scan));
fs.writeFileSync("audit.md", window.VitalReport.toMarkdown(scan, null));

The same capture with the same options produces the same bands, the same budget ledger and the same savings lines in your pipeline as in the browser: there is no sampling and no clock in the derivation. That determinism is what lets the generated budgets.json be committed and the lcp_ms comparison in step 6 be a build gate rather than a suggestion.

One thing the free lane cannot do is measure. The thresholds are the published Core Web Vitals bands, but the budgets, the savings estimates and the finding severities are this app's stated conventions, and every saving is an estimate from one capture. Never ship an estimated saving as a measured one — re-audit and diff instead.