SEO Audit — API & tutorial Open the app

Drive the SEO auditor from your own code

Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS with optional streaming. Send a page (markdown, optionally with frontmatter) and the primary keyword it targets, and get back a 30-point on-page audit: seven scored categories with counted evidence, priority fixes, snippet opportunities and the rewritten title and meta description at the right length. Examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. 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. Runs execute the app's agent (model gpt-terra) and are billed in SkillSafe credits to the calling token, with a worst-case hold up front and the actual cost settled when the job finishes.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this.
404Unknown job or record id.
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. The free quick scan on the app's page is browser-side counting with no endpoint behind it; the API surface is the agent run documented below.

Step 0 — A tiny client

Every step below is one or two HTTP calls, so start with a small helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse this helper.

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

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

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]  # see step 1

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
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, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

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

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, 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 env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_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.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

For scripted use, the simplest reliable path is your personal token: open the token page, sign in, and hit "Copy shell export" — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password — it can spend your credits. For fully headless scripts, POST /guest (below) mints a guest token with no browser involved; guests can always call /me and /estimate, but whether a guest can afford an actual run depends on the app's daily sponsorship budget, so don't build on it.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"seo-audit"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "seo-audit"})["token"]
const { token } = await api("POST", "/guest", { slug: "seo-audit" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "seo-audit"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"seo-audit"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "seo-audit" })["token"]
$token = api("POST", "/guest", ["slug" => "seo-audit"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "seo-audit" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before an expensive run.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
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"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send the same input you would send to a run; the response's hold_credits is the worst-case cost and min_credits the floor. Nothing is charged and no job is created. The response also reports the resolved model and whether sponsorship is active. The app itself refuses to start a run when hold_credits exceeds the caller's balance — a sensible check for your scripts too.

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d '{"page":"…the page as it will publish…","keyword":"database indexing"}' | jq '.data'
est = api("POST", "/estimate", {"page": page, "keyword": keyword})
print("worst case:", est["hold_credits"], "credits on", est["model"])
const est = await api("POST", "/estimate", { page, keyword });
console.log("worst case:", est.hold_credits, "credits on", est.model);
var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", map[string]string{
	"page": page, "keyword": keyword,
}, &est)
String envelope = api("POST", "/estimate", """
    {"page": %s, "keyword": %s}
    """.formatted(toJsonString(page), toJsonString(keyword)));
// worst-case cost is at data.hold_credits
est = api("POST", "/estimate", { page: page, keyword: keyword })
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
$est = api("POST", "/estimate", [
    "page" => $page,
    "keyword" => $keyword,
]);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", new {
    page, keyword });
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

Step 4 — Run it and wait

POST /run
GET /jobs/{job_id}

/run places a credit hold and returns a job_id; poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The agent replies with plain text in a fixed shape (see "The report's shape" below), delivered at output.output.

Input fieldTypeNotes
pagestring, requiredThe page as it will publish: markdown body, optionally preceded by YAML frontmatter (--- title: / description: ---) so the real title and meta get scored. The web app clips at 60,000 characters by dropping the middle — it keeps the beginning and the end and inserts a [... middle of the page omitted to fit: N characters cut ...] marker — do the same for very long pages, because a page's title and opening are at the top while its internal links and conclusion are at the bottom, and a plain head-truncation makes the audit score absences against text it never saw.
keywordstring, requiredThe primary keyword the page should rank for; every placement check counts against this phrase.
slugstring, optionalThe intended URL slug (e.g. database-indexing). Without it the slug check is scored as not assessable.
notesstring, optionalContext that sharpens the calls: audience and search intent, site conventions, what you already know is wrong.
scanobject, optionalA mechanical browser-side count summary (characters, words, headings, links). The web app generates one; scripts can simply omit it — the agent recounts everything itself.
retry_notestring, optionalPresent only on an automatic second attempt: restates the required output shape after an unparseable first reply. It is a FORMAT instruction only — the agent re-audits the same page and re-emits the same findings in the correct shape. Documented in the system prompt, so the agent is never handed a field it has not been told about. Reuse the first attempt’s idempotency key derivation with the attempt counter bumped, so a retry cannot double-bill a replay.
$modelstring, optionalPer-run model override (allowlisted models only).
# input.json: {"page":"…","keyword":"database indexing","slug":"database-indexing"}
JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: seo-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# the report is plain text at data.output.output
echo "$JOB" | jq -r '.data.output.output'
import time

job_id = api("POST", "/run", {
    "page": page,
    "keyword": "database indexing",
    "slug": "database-indexing",
}, **{"Idempotency-Key": "seo-001"})["job_id"]

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

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
report = raw["output"] if isinstance(raw, dict) else raw  # plain text, not JSON
print(report.splitlines()[0])  # e.g. "VERDICT: Needs work"
score = next(l for l in report.splitlines() if l.startswith("SCORE:"))
print(score)                   # e.g. "SCORE: 16"
const { job_id } = await api("POST", "/run", {
  page,
  keyword: "database indexing",
  slug: "database-indexing",
}, { "Idempotency-Key": crypto.randomUUID() });

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

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const report = job.output?.output ?? job.output; // plain text, not JSON
console.log(report.split("\n")[0]);             // e.g. "VERDICT: Needs work"
console.log(report.split("\n").find((l) => l.startsWith("SCORE:")));
var started struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", map[string]string{
	"page": page, "keyword": "database indexing", "slug": "database-indexing",
}, &started)
if err != nil {
	log.Fatal(err)
}

var job struct {
	Status string `json:"status"`
	Error  string `json:"error"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
report := job.Output.Output // plain text, not JSON
fmt.Println(strings.SplitN(report, "\n", 2)[0]) // e.g. "VERDICT: Needs work"
String envelope = api("POST", "/run", """
    {"page": %s, "keyword": "database indexing", "slug": "database-indexing"}
    """.formatted(toJsonString(page)));
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the report is the plain-text STRING at data.output.output — no second
// JSON parse needed, just read the string field
started = api("POST", "/run", { page: page, keyword: "database indexing",
                                slug: "database-indexing" })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"]
report = raw.is_a?(Hash) ? raw["output"] : raw  # plain text, not JSON
puts report.lines.first                          # e.g. "VERDICT: Needs work"
$started = api("POST", "/run", [
    "page" => $page,
    "keyword" => "database indexing",
    "slug" => "database-indexing",
]);

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

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = $job["output"];
$report = is_array($raw) ? ($raw["output"] ?? "") : $raw; // plain text, not JSON
echo strtok($report, "\n") . "\n";                      // e.g. "VERDICT: Needs work"
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", new {
    page, keyword = "database indexing", slug = "database-indexing" });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}
var report = job.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine(report.Split('\n')[0]); // e.g. "VERDICT: Needs work"

The report's shape

The reply is plain text (markdown bullets, no code fence around the whole thing) in exactly this frame — stable enough to parse with a few string splits:

VERDICT: Ready to publish | Minor optimizations needed | Needs work | Not auditable
SCORE: <integer 0-30 - the sum of the seven category scores>
CONFIDENCE: <integer 0-100>
SUMMARY: <2-4 sentences, ends at the first blank line>

## Score breakdown
- <category> | <X/Y> | <what was found, with counted numbers> | <what to change, or None needed.>

## Priority fixes
- <plain bullets, highest ranking impact first>

## Recommended title
- <exactly one bullet: the suggested title in quotes, with its character count>

## Recommended meta description
- <exactly one bullet: the suggested description in quotes, with its character count>

## Snippet opportunities
- <plain bullets: query pattern, winning format, the exact edit>

## Open questions
- <plain bullets; an empty section is the single bullet "- None.">

If a reply ever fails to match the frame, retry once with the same input plus a retry_note field describing the problem — the agent is instructed to obey it. That's exactly what the app itself does (and the retry is a second billed run).

Step 5 — The same run, streamed

POST /run-stream

Identical input to /run, but the response is text/event-stream, so you can show the report as it generates (the app's live output panel is this endpoint). Events:

EventData
job{job_id} — the run was accepted.
delta{text} — the next chunk of agent output.
done / pendingFinal payload: {job_id, status, charged_credits, output}. Authoritative — deltas can drop the tail, so always read the final report from here.
error{code, message, job_id}.
curl -sN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json
# event: job    data: {"job_id":"job_…"}
# event: delta  data: {"text":"VERDICT: Needs work\nSCORE:"}
# …
# event: done   data: {"job_id":"…","status":"succeeded","charged_credits":412,
#                      "output":{"output":"…the full report text…"}}
res = requests.post(API + "/run-stream", json=payload, stream=True,
                    headers={"Authorization": f"Bearer {TOKEN}"})
event, done = None, None
for line in res.iter_lines(decode_unicode=True):
    if line.startswith("event:"):
        event = line[6:].strip()
    elif line.startswith("data:"):
        data = json.loads(line[5:])
        if event == "delta":
            print(data.get("text", ""), end="", flush=True)
        elif event in ("done", "pending"):
            done = data
        elif event == "error":
            raise RuntimeError(data.get("message"))

report = done["output"]["output"]  # authoritative full text
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", event = "message", done, out = "";
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  let i;
  while ((i = buf.indexOf("\n")) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const data = JSON.parse(line.slice(5));
      if (event === "delta") out += data.text ?? "";
      else if (event === "done" || event === "pending") done = data;
      else if (event === "error") throw new Error(data.message);
    }
  }
}
const report = done.output.output; // authoritative full text
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
event, done := "", []byte(nil)
for sc.Scan() {
	line := sc.Text()
	if strings.HasPrefix(line, "event:") {
		event = strings.TrimSpace(line[6:])
	} else if strings.HasPrefix(line, "data:") {
		data := strings.TrimSpace(line[5:])
		if event == "delta" {
			// unmarshal {"text": …} and append
		} else if event == "done" || event == "pending" {
			done = []byte(data)
		}
	}
}
// unmarshal done → .output.output (the full plain-text report)
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();
var lines = HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body();

final String[] event = {""};
StringBuilder doneData = new StringBuilder();
lines.forEach(line -> {
    if (line.startsWith("event:")) event[0] = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        if (event[0].equals("delta")) { /* parse {"text"} and append */ }
        else if (event[0].equals("done")) doneData.append(line.substring(5).trim());
    }
});
// parse doneData → output.output (the full plain-text report)
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = payload.to_json

event, done, buf = nil, nil, ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:")
          data = JSON.parse(line[5..])
          print data["text"] if event == "delta"
          done = data if %w[done pending].include?(event)
        end
      end
    end
  end
end
report = done["output"]["output"]  # authoritative full text
$event = ""; $done = null; $buf = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN", "Content-Type: application/json"],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$buf) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = rtrim(substr($buf, 0, $i)); $buf = substr($buf, $i + 1);
            if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
            elseif (str_starts_with($line, "data:")) {
                $data = json_decode(substr($line, 5), true);
                if ($event === "delta") echo $data["text"] ?? "";
                if ($event === "done" || $event === "pending") $done = $data;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$report = $done["output"]["output"]; // authoritative full text
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream")
    { Content = JsonContent.Create(payload) };
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? line; string ev = ""; JsonElement doneEl = default;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) ev = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = JsonDocument.Parse(line[5..]).RootElement.Clone();
        if (ev == "delta") Console.Write(
            data.TryGetProperty("text", out var t) ? t.GetString() : "");
        else if (ev is "done" or "pending") doneEl = data;
    }
}
var report = doneEl.GetProperty("output").GetProperty("output").GetString()!;

The grounding contract applies to every run: every finding quotes the page and states the number it counted, the agent never asserts search volume, rankings or competitor data (no external data exists in this app), and what the paste cannot settle lands under ## Open questions instead of being invented. Re-running after applying the fixes with the same keyword (and slug) is how you verify the score moved — the web app shows the per-category delta automatically.