Roofline Walk - API

A walk around the house, turned into findings, from your own scripts.

Turn a home walkaround into prioritised findings

Roofline Walk is a phone-first field checklist for walking around your own home from the ground: after a hail or wind storm (template storm), before wildfire season (wildfire: home hardening and the defensible-space zones), or a spring or autumn roof and drainage check (seasonal). Each item is marked pass, fail or n.a. with a note and photos. The checklist, progress, photos (downscaled, stored with your account when signed in) and the printable report all run in the browser for free.

Two paid lanes run on the model gpt-terra: report turns the failed items into prioritised findings (severity, urgency, what to do, who to call, a question to ask), 3-8 questions for the insurer, roofer or contractor, and a one-paragraph verdict; reinspect is a follow-up walk over only the items that failed last time, each judged resolved, improved, unchanged, worse or not rechecked. Photos are never sent to the model: only a count per item. Each lane is one call to the SkillSafe App API: plain JSON in over HTTPS, JSON Lines out. Pick a language once and every code sample on the page follows.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every call except POST /guest sends Authorization: Bearer <token>; bodies are JSON with Content-Type: application/json. The token is bound to this app, so there is no app or slug header: the slug appears only in the POST /guest body.

Responses use one envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code","message","details"}} on failure. /estimate, /run and /run-stream take the input object itself as the body. Do not wrap it as {"input":{...}}: the server only warns unknown field 'input', and the run is still charged.

Credits: 1 credit = $0.0001. /estimate is free and creates no job; /run and /run-stream hold up to hold_credits and charge charged_credits when the job finishes.

Statuserror.codeMeaning
400validation_errorThe body failed validation; details names the field.
401unauthorizedInvalid or expired app session. Get a new token (step 1).
402payment_requiredBalance is below min_credits for this run. Top up, or sign in for a personal token.
404not_foundUnknown job id, or a job that belongs to another token.
409conflictThe Idempotency-Key was already used with a different body.
429rate_limitedToo many requests. Back off and retry.
5xx-Transient platform error. Retry with backoff and the same Idempotency-Key.

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

Input contract

The input is one flat JSON object whose values are all strings. Anything structured (the cover, the section tallies, the items, the pre-scan) is JSON-encoded into a string field whose name ends in _json.

task (string, required): which lane to run

taskWhat it doesItems sent in items_jsonExample
reportPrioritised findings for one walk: per failed item a severity, an urgency, what it means, what to do, who to call and a question to ask; then 3-8 questions for the insurer, roofer or contractor, and a one-paragraph verdict.Every failed item, plus any other item with a note.1 report
reinspectA follow-up walk over only the items that failed last time: each one judged resolved, improved, unchanged, worse or not_rechecked, with a severity, a comment and the next step; then questions and a verdict.Every item that failed last time, each with a before object. Also send previous_json.2 reinspect

The other fields

FieldTypeLanesNotes
templatestring, requiredbothstorm (after hail or wind) | wildfire (home hardening and defensible-space zones) | seasonal (spring or autumn roof and drainage check).
template_namestringbothThe checklist's display name, e.g. After a hail or wind storm.
cover_jsonstring (JSON object), requiredboth{title, place, walked_on, inspector, purpose, audience, details:{...}}. audience is who the report is for (e.g. insurer, roofer); details holds the template's own cover fields, such as the storm date and the largest hail seen.
sections_jsonstring (JSON array)bothPer-section tallies {section, total, pass, fail, na, unchecked}.
items_jsonstring (JSON array), requiredbothEach item {id, section, item, status: pass|fail|na|unset, note, photos, default_severity, safety?, who, browser_severity?}. photos is a count only: images never leave the browser. default_severity and who come from the checklist; browser_severity is the app's own free estimate. For reinspect every item also carries before:{status, note, severity} from the last walk.
prescan_jsonstring (JSON object)both, optionalFacts from the app's free client-side tally: {browser_verdict, failed, passed, na, unchecked, photos, note_flags, notes_clipped_to}. API callers may leave it out.
previous_jsonstring (JSON object)reinspectThe walk being followed up: {title, walked_on, verdict, failed_items}.
retry_notestring, optionalbothA correction the app adds when it re-asks after a malformed reply. Leave it out.

Output contract

The finished job's text is at output.output. It is JSON Lines: one JSON object per line, each with a k kind. The first line is always {"k":"meta","task":"<lane>","verdict":"...","headline":"..."} and the last is {"k":"end"}. Split on newlines, JSON.parse each line and ignore anything that does not parse; step 4 shows this in every language. If {"k":"end"} is missing, or the job says truncated, the reply was cut short, but every whole line that arrived is still usable.

taskLines, in order
reportmeta {task:"report", verdict: act_now|repair_soon|monitor|sound, headline}; finding per failed item {item, severity: urgent|high|medium|low, urgency: now|weeks|season|monitor, what, do, who, ask}; question x3-8 {to, q, items:[...]}; verdict {text}; end.
reinspectmeta {task:"reinspect", verdict, headline}; recheck per item {item, outcome: resolved|improved|unchanged|worse|not_rechecked, severity, comment, next}; question x3-8; verdict {text}; end.

item is the checklist id sent in items_json (e.g. RF2), so join the reply back to your own rows by id. A question's to is a free-text role (insurer, roofer, arborist, ...) and its items lists the ids it is about.

Step 1 - A tiny client, and a token

POST /guest

Start with a short helper that adds the auth header, sends JSON and unwraps the envelope; every later step reuses it. For the token: open the token page, sign in with SkillSafe and press Copy shell export. That puts export SKILLSAFE_TOKEN="..." on your clipboard, and the samples read it from there. A personal token bills metered runs to your account. Scripts with no browser at all can mint a guest token with POST /guest and the body {"slug":"roofline-walk"}, sent with no auth header; it answers 201 with {token, guest_id, expires_at}.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"   # Copy shell export on tokens.html sets this for you

# No browser at all? Mint a guest token (this one call sends no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"roofline-walk"}' | jq -r '.data.token')
# 201 -> {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}

# every later call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-H "Content-Type: application/json" -d @input.json]
# jq pulls fields out of the {"ok":true,"data":{...}} envelope
import json, os, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")  # copy it from tokens.html

def api(method, path, body=None, headers=None, auth=True):
    """Send JSON (a dict, or an already-encoded string), unwrap the envelope."""
    h = {"Content-Type": "application/json", **(headers or {})}
    if auth and TOKEN:
        h["Authorization"] = f"Bearer {TOKEN}"
    data = body if isinstance(body, str) or body is None else json.dumps(body)
    res = requests.request(method, API + path, data=data, headers=h, timeout=180)
    payload = res.json()
    if not res.ok or payload.get("ok") is False:
        err = payload.get("error", {})
        raise RuntimeError(f'{res.status_code} {err.get("code")}: {err.get("message")}')
    return payload["data"]

# No browser at all? Mint a guest token (no Authorization header on this call).
if not TOKEN:
    TOKEN = api("POST", "/guest", {"slug": "roofline-walk"}, auth=False)["token"]
// Node 18+ (built-in fetch), run as an ES module (top-level await)
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from tokens.html, or load it from your secret store

async function api(method, path, body, extraHeaders = {}, auth = true) {
  const headers = { "Content-Type": "application/json", ...extraHeaders };
  if (auth) headers.Authorization = `Bearer ${TOKEN}`;
  const res = await fetch(API + path, {
    method,
    headers,
    body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || json.ok === false) {
    const e = json.error ?? {};
    throw Object.assign(new Error(`${res.status} ${e.code}: ${e.message ?? res.statusText}`),
      { status: res.status, code: e.code, details: e.details });
  }
  return json.data;
}

// No browser at all? Mint a guest token (no Authorization header on this call).
if (TOKEN === "YOUR_TOKEN") {
  ({ token: TOKEN } = await api("POST", "/guest", { slug: "roofline-walk" }, {}, false));
}
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

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

var token = os.Getenv("SKILLSAFE_TOKEN") // copy it from tokens.html

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

// call sends body (any value, or json.RawMessage for pre-encoded bytes) and
// decodes the envelope's data into out.
func call(method, path string, body any, headers map[string]string, 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("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env envelope
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 || env.Error != nil {
		if env.Error != nil {
			return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
		}
		return fmt.Errorf("HTTP %d", res.StatusCode)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}

// No browser at all? Mint a guest token (sent while token is still empty, so no auth header).
func ensureToken() {
	if token != "" {
		return
	}
	var g struct{ Token string `json:"token"` }
	if err := call("POST", "/guest", map[string]string{"slug": "roofline-walk"}, nil, &g); err != nil {
		log.Fatal(err)
	}
	token = g.Token
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;

public class RooflineWalk {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient HTTP = HttpClient.newHttpClient();
    static String token = System.getenv("SKILLSAFE_TOKEN"); // copy it from tokens.html

    /** body: any Jackson-serialisable value, or an already-encoded JSON String. */
    static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(API + path))
            .header("Content-Type", "application/json")
            .method(method, body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body instanceof String s ? s : JSON.writeValueAsString(body)));
        if (token != null) b.header("Authorization", "Bearer " + token);
        headers.forEach(b::header);
        var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        JsonNode env = JSON.readTree(res.body());
        if (res.statusCode() >= 400 || !env.path("ok").asBoolean(true))
            throw new RuntimeException(res.statusCode() + " " + env.path("error").path("code").asText()
                + ": " + env.path("error").path("message").asText());
        return env.get("data");
    }

    // No browser at all? Mint a guest token (token is null, so no auth header is sent).
    static void ensureToken() throws Exception {
        if (token == null) token = api("POST", "/guest", Map.of("slug", "roofline-walk"), Map.of()).get("token").asText();
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV["SKILLSAFE_TOKEN"] # copy it from tokens.html

# body: a Hash, or an already-encoded JSON String
def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{$token}" if $token
  headers.each { |k, v| req[k] = v }
  req.body = body.is_a?(String) ? body : body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
  payload = (JSON.parse(res.body) rescue {})
  unless res.is_a?(Net::HTTPSuccess) && payload["ok"] != false
    err = payload["error"] || {}
    raise "#{res.code} #{err["code"]}: #{err["message"] || res.message}"
  end
  payload["data"]
end

# No browser at all? Mint a guest token ($token is nil, so no auth header is sent).
$token ||= api("POST", "/guest", { slug: "roofline-walk" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: null; // copy it from tokens.html

// $body: an array, or an already-encoded JSON string
function api(string $method, string $path, $body = null, array $headers = []): array {
    global $TOKEN;
    $h = ["Content-Type: application/json"];
    if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
    foreach ($headers as $k => $v) $h[] = "$k: $v";
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $h,
        CURLOPT_TIMEOUT        => 180,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS,
            is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    }
    $payload = json_decode(curl_exec($ch), true) ?? [];
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400 || ($payload["ok"] ?? true) === false) {
        $e = $payload["error"] ?? [];
        throw new Exception("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? "HTTP error"));
    }
    return $payload["data"];
}

// No browser at all? Mint a guest token ($TOKEN is null, so no auth header is sent).
$TOKEN = $TOKEN ?: api("POST", "/guest", ["slug" => "roofline-walk"])["token"];
// .NET 8+, Program.cs. Top-level statements come first; the helper class goes at the end of the file.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// No browser at all? Mint a guest token (Token is null, so no auth header is sent).
RooflineWalk.Token ??= (await RooflineWalk.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "roofline-walk" })).GetProperty("token").GetString();

// ... steps 2-5 go here ...

static class RooflineWalk
{
    public const string Api = "https://api.skillsafe.ai/v1/app-api";
    public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
    public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"); // copy it from tokens.html

    // body: any serialisable object, or an already-encoded JSON string
    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
        Dictionary<string, string>? headers = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (Token != null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
        if (body != null)
            req.Content = new StringContent(body as string ?? JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!res.IsSuccessStatusCode)
        {
            var e = json.GetProperty("error");
            throw new Exception($"{(int)res.StatusCode} {e.GetProperty("code")}: {e.GetProperty("message")}");
        }
        return json.GetProperty("data");
    }
}

The app keeps this browser's token in localStorage under skillsafe_app_token:roofline-walk, on the app's own origin. The token page shows, copies and replaces it without developer tools. Treat a token like a password: it can spend its credits.

Step 2 - Who am I, and what is my balance?

GET /me

Returns subject_type ("user" for a personal token, "guest" for a guest token), subject_id and credits. There is no separate user id or guest flag: branch on subject_type. A 401 here means the token is stale.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"guest","subject_id":"gst_...","credits":2000}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits",
      f'(${me["credits"] * 0.0001:.2f})')
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, `${me.credits} credits ($${(me.credits * 0.0001).toFixed(2)})`);
ensureToken()
var me struct {
	SubjectType string `json:"subject_type"` // "user" or "guest"
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s: %d credits ($%.2f)\n", me.SubjectType, me.SubjectID, me.Credits, float64(me.Credits)*0.0001)
ensureToken();
JsonNode me = api("GET", "/me", null, Map.of());
System.out.printf("%s %s: %d credits%n",
    me.get("subject_type").asText(), me.get("subject_id").asText(), me.get("credits").asLong());
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await RooflineWalk.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");

Step 3 - Estimate a run before starting it

POST /estimate

Send exactly the body you would send to /run. Nothing is charged and no job is created. The answer carries:

The sample estimates a report on a storm walk, reading the items from items.json (an array of item rows like the ones under Worked examples). Only task, template, cover_json and items_json are required.

# items.json holds the items array (see "Worked examples" below).
# cover_json and items_json must be STRINGS, so jq's tojson encodes them.
jq -n --slurpfile i items.json '{
  task: "report",
  template: "storm",
  template_name: "After a hail or wind storm",
  cover_json: ({title: "After the 14 Sept hail storm", place: "12 Elm Street",
                walked_on: "2026-09-23", inspector: "Corvin Ashdale",
                purpose: "Decide whether to file a hail claim", audience: "insurer",
                details: {"Storm date": "2026-09-14"}} | tojson),
  items_json: ($i[0] | tojson)
}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, input_checked, warnings}'
with open("items.json", encoding="utf-8") as fh:
    items = json.load(fh)  # [{id, section, item, status, note, photos, default_severity, who}, ...]

cover = {
    "title": "After the 14 Sept hail storm", "place": "12 Elm Street",
    "walked_on": "2026-09-23", "inspector": "Corvin Ashdale",
    "purpose": "Decide whether to file a hail claim", "audience": "insurer",
    "details": {"Storm date": "2026-09-14"},
}
enc = lambda v: json.dumps(v, separators=(",", ":"), ensure_ascii=False)

payload = {
    "task": "report",
    "template": "storm",
    "template_name": "After a hail or wind storm",
    "cover_json": enc(cover),   # a STRING
    "items_json": enc(items),   # a STRING
}

est = api("POST", "/estimate", payload)  # the input object itself, never {"input": ...}
print(est["hold_credits"], "credits reserved at most;", est["min_credits"], "needed to start;",
      est["model_alias"], "->", est["model"])
for w in est["warnings"]:
    print("warning:", w)
import { readFileSync } from "node:fs";

const items = JSON.parse(readFileSync("items.json", "utf8"));
const cover = {
  title: "After the 14 Sept hail storm", place: "12 Elm Street",
  walked_on: "2026-09-23", inspector: "Corvin Ashdale",
  purpose: "Decide whether to file a hail claim", audience: "insurer",
  details: { "Storm date": "2026-09-14" },
};

const payload = {
  task: "report",
  template: "storm",
  template_name: "After a hail or wind storm",
  cover_json: JSON.stringify(cover), // a STRING, not an object
  items_json: JSON.stringify(items), // a STRING, not an array
};

const est = await api("POST", "/estimate", payload); // the input object itself, never { input: ... }
console.log(`${est.hold_credits} credits reserved at most, ${est.min_credits} needed to start (${est.model_alias} -> ${est.model})`);
for (const w of est.warnings) console.log("warning:", w);
raw, err := os.ReadFile("items.json")
if err != nil {
	log.Fatal(err)
}
var items []map[string]any
if err := json.Unmarshal(raw, &items); err != nil {
	log.Fatal(err)
}
itemsJSON, _ := json.Marshal(items)
coverJSON, _ := json.Marshal(map[string]any{
	"title": "After the 14 Sept hail storm", "place": "12 Elm Street",
	"walked_on": "2026-09-23", "inspector": "Corvin Ashdale",
	"purpose": "Decide whether to file a hail claim", "audience": "insurer",
	"details": map[string]string{"Storm date": "2026-09-14"},
})

payload := map[string]any{
	"task":          "report",
	"template":      "storm",
	"template_name": "After a hail or wind storm",
	"cover_json":    string(coverJSON), // a STRING, not an object
	"items_json":    string(itemsJSON), // a STRING, not an array
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	Warnings    []any  `json:"warnings"`
}
if err := call("POST", "/estimate", payload, nil, &est); err != nil {
	log.Fatal(err)
}
fmt.Println(est.HoldCredits, "credits reserved at most;", est.MinCredits, "needed to start;", est.ModelAlias, "->", est.Model)
for _, w := range est.Warnings {
	fmt.Println("warning:", w)
}
var items = JSON.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("items.json")));
var cover = JSON.createObjectNode()
    .put("title", "After the 14 Sept hail storm").put("place", "12 Elm Street")
    .put("walked_on", "2026-09-23").put("inspector", "Corvin Ashdale")
    .put("purpose", "Decide whether to file a hail claim").put("audience", "insurer");
cover.putObject("details").put("Storm date", "2026-09-14");

var payload = JSON.createObjectNode()
    .put("task", "report")
    .put("template", "storm")
    .put("template_name", "After a hail or wind storm")
    .put("cover_json", JSON.writeValueAsString(cover))  // a STRING, not an object
    .put("items_json", JSON.writeValueAsString(items)); // a STRING, not an array

JsonNode est = api("POST", "/estimate", payload, Map.of());
System.out.println(est.get("hold_credits") + " credits reserved at most; "
    + est.get("min_credits") + " needed to start; " + est.get("model").asText());
est.get("warnings").forEach(w -> System.out.println("warning: " + w));
items = JSON.parse(File.read("items.json"))
cover = {
  title: "After the 14 Sept hail storm", place: "12 Elm Street",
  walked_on: "2026-09-23", inspector: "Corvin Ashdale",
  purpose: "Decide whether to file a hail claim", audience: "insurer",
  details: { "Storm date" => "2026-09-14" }
}

payload = {
  task: "report",
  template: "storm",
  template_name: "After a hail or wind storm",
  cover_json: cover.to_json, # a STRING, not an object
  items_json: items.to_json  # a STRING, not an array
}

est = api("POST", "/estimate", payload)
puts "#{est["hold_credits"]} credits reserved at most; #{est["min_credits"]} needed to start; #{est["model"]}"
est["warnings"].each { |w| puts "warning: #{w}" }
$items = json_decode(file_get_contents("items.json"), true);
$cover = [
    "title" => "After the 14 Sept hail storm", "place" => "12 Elm Street",
    "walked_on" => "2026-09-23", "inspector" => "Corvin Ashdale",
    "purpose" => "Decide whether to file a hail claim", "audience" => "insurer",
    "details" => ["Storm date" => "2026-09-14"],
];
$enc = fn($v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

$payload = [
    "task"          => "report",
    "template"      => "storm",
    "template_name" => "After a hail or wind storm",
    "cover_json"    => $enc($cover), // a STRING
    "items_json"    => $enc($items), // a STRING
];

$est = api("POST", "/estimate", $payload);
echo "{$est['hold_credits']} credits reserved at most; {$est['min_credits']} needed to start; {$est['model']}\n";
foreach ($est["warnings"] as $w) echo "warning: " . json_encode($w) . "\n";
var items = JsonDocument.Parse(await File.ReadAllTextAsync("items.json")).RootElement;
var cover = new Dictionary<string, object>
{
    ["title"] = "After the 14 Sept hail storm", ["place"] = "12 Elm Street",
    ["walked_on"] = "2026-09-23", ["inspector"] = "Corvin Ashdale",
    ["purpose"] = "Decide whether to file a hail claim", ["audience"] = "insurer",
    ["details"] = new Dictionary<string, string> { ["Storm date"] = "2026-09-14" },
};

var payload = new Dictionary<string, object>
{
    ["task"] = "report",
    ["template"] = "storm",
    ["template_name"] = "After a hail or wind storm",
    ["cover_json"] = JsonSerializer.Serialize(cover), // a STRING, not an object
    ["items_json"] = JsonSerializer.Serialize(items), // a STRING, not an array
};

var est = await RooflineWalk.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits")} credits reserved at most; {est.GetProperty("min_credits")} needed to start; {est.GetProperty("model")}");
foreach (var w in est.GetProperty("warnings").EnumerateArray()) Console.WriteLine($"warning: {w}");

Step 4 - Run a lane and poll the job

POST /run
GET /jobs/{job_id}

/run takes the same body, places the credit hold and returns {job_id}. Poll GET /jobs/{job_id} every second or two until status is succeeded or failed. The finished job carries the reply text at output.output, plus charged_credits (what the run actually cost) and truncated (true when the reply was cut short). The samples parse the JSON Lines reply (split lines, parse each, ignore anything that is not JSON) and print the verdict, each finding or recheck, the questions and the closing verdict.

Always send an Idempotency-Key, so a network retry cannot start a second, double-charged run. The app's own key is roofline-walk:<task>:<fnv1a hash of the input>:a<attempt> (32-bit FNV-1a over the JSON body, as 8 hex digits). Because task is both in the key and in the hashed body, a report and a reinspect of the same walk never collide; a deliberate re-run of the same input bumps the attempt number. Reusing a key with a different body is a 409.

BODY=$(jq -c . input.json)
# The app hashes the body with FNV-1a; from a shell any stable hash of the exact body works.
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
KEY="roofline-walk:report:$HASH:a1"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$BODY" | 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

echo "$JOB" | jq '.data | {status, charged_credits, truncated}'

# The reply is JSON Lines: keep every line that parses as an object with "k", drop the rest.
echo "$JOB" | jq -r '.data.output.output' \
  | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply.jsonl

jq -r 'select(.k == "meta") | "\(.verdict): \(.headline)"' reply.jsonl
jq -r 'select(.k == "finding") | "  \(.item) [\(.severity)/\(.urgency)] \(.do) -> \(.who)"' reply.jsonl
jq -r 'select(.k == "recheck") | "  \(.item) \(.outcome): \(.next)"' reply.jsonl
jq -r 'select(.k == "question") | "  ask \(.to): \(.q)"' reply.jsonl
jq -r 'select(.k == "verdict") | .text' reply.jsonl
import time

def fnv1a(s):
    """The app's 32-bit FNV-1a (it hashes UTF-16 code units; identical for ASCII)."""
    h = 0x811C9DC5
    for ch in s:
        h ^= ord(ch)
        h = (h * 0x01000193) & 0xFFFFFFFF
    return f"{h:08x}"

def idem_key(task, body, attempt=1):
    return f"roofline-walk:{task}:{fnv1a(body)}:a{attempt}"

def parse_lines(text):
    """JSON Lines -> list of dicts; anything that is not a JSON object with "k" is ignored."""
    out = []
    for line in (text or "").splitlines():
        try:
            obj = json.loads(line)
        except ValueError:
            continue
        if isinstance(obj, dict) and "k" in obj:
            out.append(obj)
    return out

body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)  # hash exactly what you send
job_id = api("POST", "/run", body,
             headers={"Idempotency-Key": idem_key(payload["task"], body)})["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") or "run failed")

lines = parse_lines(job["output"]["output"])
meta = next((l for l in lines if l["k"] == "meta"), {})
print(f'{meta.get("verdict")}: {meta.get("headline")} ({job["charged_credits"]} credits'
      + (", truncated)" if job.get("truncated") else ")"))
for l in lines:
    if l["k"] == "finding":
        print(f'  {l["item"]:<4} [{l["severity"]}/{l["urgency"]}] {l["do"]} -> {l["who"]}')
    elif l["k"] == "recheck":
        print(f'  {l["item"]:<4} {l["outcome"]}: {l["next"]}')
    elif l["k"] == "question":
        print(f'  ask {l["to"]}: {l["q"]}')
    elif l["k"] == "verdict":
        print(l["text"])
if not any(l["k"] == "end" for l in lines):
    print("(reply was cut short - every whole line above is still usable)")
function fnv1a(s) { // the app's own 32-bit FNV-1a
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h.toString(16).padStart(8, "0");
}
const idemKey = (input, attempt = 1) =>
  `roofline-walk:${input.task}:${fnv1a(JSON.stringify(input))}:a${attempt}`;

function parseLines(text) { // JSON Lines -> objects; anything else is ignored
  const out = [];
  for (const line of String(text ?? "").split(/\r?\n/)) {
    try {
      const o = JSON.parse(line);
      if (o && typeof o === "object" && o.k) out.push(o);
    } catch { /* not JSON */ }
  }
  return out;
}

const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": idemKey(payload) });

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 lines = parseLines(job.output.output);
const meta = lines.find((l) => l.k === "meta") ?? {};
console.log(`${meta.verdict}: ${meta.headline} (${job.charged_credits} credits${job.truncated ? ", truncated" : ""})`);
for (const l of lines) {
  if (l.k === "finding") console.log(`  ${l.item.padEnd(4)} [${l.severity}/${l.urgency}] ${l.do} -> ${l.who}`);
  else if (l.k === "recheck") console.log(`  ${l.item.padEnd(4)} ${l.outcome}: ${l.next}`);
  else if (l.k === "question") console.log(`  ask ${l.to}: ${l.q}`);
  else if (l.k === "verdict") console.log(l.text);
}
if (!lines.some((l) => l.k === "end")) console.log("(reply was cut short - every whole line above is still usable)");
func idemKey(task string, body []byte, attempt int) string {
	h := fnv.New32a() // the same 32-bit FNV-1a the app uses
	h.Write(body)
	return fmt.Sprintf("roofline-walk:%s:%08x:a%d", task, h.Sum32(), attempt)
}

// ... in main(), after building payload in step 3:
body, _ := json.Marshal(payload) // hash exactly the bytes you send
var started struct{ JobID string `json:"job_id"` }
err = call("POST", "/run", json.RawMessage(body),
	map[string]string{"Idempotency-Key": idemKey("report", body, 1)}, &started)
if err != nil {
	log.Fatal(err)
}

var job struct {
	Status         string `json:"status"`
	ChargedCredits int64  `json:"charged_credits"`
	Truncated      bool   `json:"truncated"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal("run failed")
}

for _, line := range strings.Split(job.Output.Output, "\n") {
	var l map[string]any
	if json.Unmarshal([]byte(line), &l) != nil || l["k"] == nil {
		continue // not a JSON line: ignore
	}
	switch l["k"] {
	case "meta":
		fmt.Printf("%v: %v (%d credits, truncated=%v)\n", l["verdict"], l["headline"], job.ChargedCredits, job.Truncated)
	case "finding":
		fmt.Printf("  %-4v [%v/%v] %v -> %v\n", l["item"], l["severity"], l["urgency"], l["do"], l["who"])
	case "recheck":
		fmt.Printf("  %-4v %v: %v\n", l["item"], l["outcome"], l["next"])
	case "question":
		fmt.Printf("  ask %v: %v\n", l["to"], l["q"])
	case "verdict":
		fmt.Println(l["text"])
	}
}
static String fnv1a(String s) { // the app's own 32-bit FNV-1a
    int h = 0x811c9dc5;
    for (int i = 0; i < s.length(); i++) {
        h ^= s.charAt(i);
        h *= 0x01000193;
    }
    return String.format("%08x", h);
}

static String idemKey(String task, String body, int attempt) {
    return "roofline-walk:" + task + ":" + fnv1a(body) + ":a" + attempt;
}

// ... after building payload in step 3:
String body = JSON.writeValueAsString(payload); // hash exactly what you send
String jobId = api("POST", "/run", body,
    Map.of("Idempotency-Key", idemKey("report", body, 1))).get("job_id").asText();

JsonNode job;
do {
    Thread.sleep(1500);
    job = api("GET", "/jobs/" + jobId, null, Map.of());
} while (!job.get("status").asText().matches("succeeded|failed"));
if (job.get("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.get("error"));

for (String line : job.path("output").path("output").asText("").split("\\R")) {
    JsonNode l;
    try { l = JSON.readTree(line); } catch (Exception e) { continue; } // not JSON: ignore
    if (l == null || !l.has("k")) continue;
    switch (l.get("k").asText()) {
        case "meta" -> System.out.println(l.path("verdict").asText() + ": " + l.path("headline").asText()
            + " (" + job.get("charged_credits") + " credits, truncated=" + job.path("truncated").asBoolean() + ")");
        case "finding" -> System.out.printf("  %-4s [%s/%s] %s -> %s%n", l.path("item").asText(),
            l.path("severity").asText(), l.path("urgency").asText(), l.path("do").asText(), l.path("who").asText());
        case "recheck" -> System.out.printf("  %-4s %s: %s%n", l.path("item").asText(),
            l.path("outcome").asText(), l.path("next").asText());
        case "question" -> System.out.println("  ask " + l.path("to").asText() + ": " + l.path("q").asText());
        case "verdict" -> System.out.println(l.path("text").asText());
        default -> { }
    }
}
def fnv1a(s) # the app's own 32-bit FNV-1a, over UTF-16 code units
  h = 0x811c9dc5
  s.encode("UTF-16LE").unpack("v*").each { |u| h = ((h ^ u) * 0x01000193) & 0xffffffff }
  format("%08x", h)
end

def idem_key(task, body, attempt = 1)
  "roofline-walk:#{task}:#{fnv1a(body)}:a#{attempt}"
end

def parse_lines(text) # JSON Lines -> hashes; anything else is ignored
  text.to_s.each_line.filter_map do |line|
    obj = (JSON.parse(line) rescue nil)
    obj if obj.is_a?(Hash) && obj["k"]
  end
end

body = payload.to_json # hash exactly what you send
job_id = api("POST", "/run", body, { "Idempotency-Key" => idem_key(payload[:task], body) })["job_id"]

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

lines = parse_lines(job.dig("output", "output"))
meta = lines.find { |l| l["k"] == "meta" } || {}
puts "#{meta["verdict"]}: #{meta["headline"]} (#{job["charged_credits"]} credits#{job["truncated"] ? ", truncated" : ""})"
lines.each do |l|
  case l["k"]
  when "finding"  then puts format("  %-4s [%s/%s] %s -> %s", l["item"], l["severity"], l["urgency"], l["do"], l["who"])
  when "recheck"  then puts format("  %-4s %s: %s", l["item"], l["outcome"], l["next"])
  when "question" then puts "  ask #{l["to"]}: #{l["q"]}"
  when "verdict"  then puts l["text"]
  end
end
function idem_key(string $task, string $body, int $attempt = 1): string {
    return "roofline-walk:$task:" . hash("fnv1a32", $body) . ":a$attempt"; // built-in 32-bit FNV-1a
}

function parse_lines(?string $text): array { // JSON Lines -> arrays; anything else is ignored
    $out = [];
    foreach (preg_split('/\r?\n/', $text ?? "") as $line) {
        $o = json_decode($line, true);
        if (is_array($o) && isset($o["k"])) $out[] = $o;
    }
    return $out;
}

$body  = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // hash what you send
$jobId = api("POST", "/run", $body, ["Idempotency-Key" => idem_key($payload["task"], $body)])["job_id"];

do {
    usleep(1500000);
    $job = api("GET", "/jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") throw new Exception("run failed");

foreach (parse_lines($job["output"]["output"] ?? "") as $l) {
    switch ($l["k"]) {
        case "meta":
            echo "{$l['verdict']}: {$l['headline']} ({$job['charged_credits']} credits)\n"; break;
        case "finding":
            printf("  %-4s [%s/%s] %s -> %s\n", $l["item"], $l["severity"], $l["urgency"], $l["do"], $l["who"]); break;
        case "recheck":
            printf("  %-4s %s: %s\n", $l["item"], $l["outcome"], $l["next"]); break;
        case "question":
            echo "  ask {$l['to']}: {$l['q']}\n"; break;
        case "verdict":
            echo $l["text"], "\n"; break;
    }
}
static string Fnv1a(string s) // the app's own 32-bit FNV-1a
{
    uint h = 0x811c9dc5;
    foreach (char ch in s) { h ^= ch; h = unchecked(h * 0x01000193); }
    return h.ToString("x8");
}
static string IdemKey(string task, string body, int attempt = 1) =>
    $"roofline-walk:{task}:{Fnv1a(body)}:a{attempt}";

var body = JsonSerializer.Serialize(payload); // hash exactly what you send
var started = await RooflineWalk.ApiAsync(HttpMethod.Post, "/run", body,
    new() { ["Idempotency-Key"] = IdemKey("report", body) });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
string status;
do
{
    await Task.Delay(1500);
    job = await RooflineWalk.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed"));
if (status == "failed") throw new Exception("run failed");

string S(JsonElement e, string name) => e.TryGetProperty(name, out var v) ? v.ToString() : "";
foreach (var line in job.GetProperty("output").GetProperty("output").GetString()!.Split('\n'))
{
    JsonElement l;
    try { l = JsonDocument.Parse(line).RootElement; } catch (JsonException) { continue; } // not JSON
    if (l.ValueKind != JsonValueKind.Object || !l.TryGetProperty("k", out var k)) continue;
    switch (k.GetString())
    {
        case "meta": Console.WriteLine($"{S(l, "verdict")}: {S(l, "headline")} ({job.GetProperty("charged_credits")} credits)"); break;
        case "finding": Console.WriteLine($"  {S(l, "item"),-4} [{S(l, "severity")}/{S(l, "urgency")}] {S(l, "do")} -> {S(l, "who")}"); break;
        case "recheck": Console.WriteLine($"  {S(l, "item"),-4} {S(l, "outcome")}: {S(l, "next")}"); break;
        case "question": Console.WriteLine($"  ask {S(l, "to")}: {S(l, "q")}"); break;
        case "verdict": Console.WriteLine(S(l, "text")); break;
    }
}

Step 5 - Stream a run as it is written

POST /run-stream

/run-stream takes the same body and headers as /run and answers with server-sent events. Each event is an event: line, a data: line of JSON, and a blank line:

# a new attempt (:a2); re-sending step 4's :a1 key with the same body replays that run
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: roofline-walk:report:$HASH:a2" \
  -d "$BODY"

# event: job
# data: {"job_id":"job_..."}
#
# event: tick
# data: {"t":4}
#
# event: delta
# data: {"text":"{\"k\":\"meta\",\"task\":\"report\",\"verdict\":\"repair_soon\",..."}
#
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":...,"truncated":false,"output":{"output":"...JSON Lines..."}}
res = requests.post(API + "/run-stream", data=body, stream=True, timeout=300, headers={
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
    "Idempotency-Key": idem_key(payload["task"], body, attempt=2),  # :a1 would replay step 4
})
res.raise_for_status()

event, job = None, None
for raw in res.iter_lines(decode_unicode=True):
    if raw.startswith("event:"):
        event = raw[6:].strip()
    elif raw.startswith("data:"):
        data = json.loads(raw[5:].strip())
        if event == "delta":
            print(data["text"], end="", flush=True)  # progress only
        elif event == "tick":
            pass                                     # heartbeat: data["t"] seconds so far
        elif event == "done":
            job = data.get("job", data)
        elif event == "error":
            raise RuntimeError(data.get("message", data))

lines = parse_lines(job["output"]["output"])  # parse the final text, not the deltas
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idemKey(payload, 2), // :a1 would replay step 4
  },
  body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`run-stream: HTTP ${res.status}`);

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "", job = null;
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value.replace(/\r\n/g, "\n");
  let i;
  while ((i = buf.indexOf("\n\n")) >= 0) {
    const frame = buf.slice(0, i);
    buf = buf.slice(i + 2);
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const data = /^data:\s?(.*)$/m.exec(frame)?.[1];
    if (!name || data === undefined) continue;
    const d = JSON.parse(data);
    if (name === "delta") console.log("+", d.text.length, "chars"); // progress only
    else if (name === "tick") { /* heartbeat: d.t seconds so far */ }
    else if (name === "done") job = d.job ?? d;
    else if (name === "error") throw new Error(d.message ?? "stream error");
  }
}

const lines = parseLines(job.output.output); // parse the final text, not the deltas
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey("report", body, 2)) // :a1 would replay step 4

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var done struct {
	Status         string `json:"status"`
	ChargedCredits int64  `json:"charged_credits"`
	Truncated      bool   `json:"truncated"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<22) // done carries the whole reply on one line
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		data := []byte(strings.TrimSpace(line[5:]))
		switch event {
		case "delta":
			var d struct{ Text string `json:"text"` }
			json.Unmarshal(data, &d)
			fmt.Print(d.Text) // progress only
		case "done":
			json.Unmarshal(data, &done)
		case "error":
			log.Fatalf("stream error: %s", data)
		}
	}
}
// done.Output.Output is the JSON Lines reply: parse it as in step 4
String body = JSON.writeValueAsString(payload);
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", idemKey("report", body, 2)) // :a1 would replay step 4
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());

String event = "";
JsonNode job = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) event = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        JsonNode d = JSON.readTree(line.substring(5).trim());
        switch (event) {
            case "delta" -> System.out.print(d.get("text").asText()); // progress only
            case "done"  -> job = d;
            case "error" -> throw new RuntimeException(d.toString());
            default      -> { } // job, tick
        }
    }
}
String reply = job.path("output").path("output").asText(); // parse it as in step 4
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem_key(payload[:task], body, 2) # :a1 would replay step 4
req.body = body

job = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
  http.request(req) do |res|
    buf = +""
    event = nil
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          d = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print d["text"] # progress only
          when "done"  then job = d
          when "error" then raise d.to_s
          end
        end
      end
    end
  end
end

lines = parse_lines(job.dig("output", "output")) # parse the final text, not the deltas
$buf = ""; $event = null; $job = null; $err = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST          => true,
    CURLOPT_POSTFIELDS    => $body,
    CURLOPT_TIMEOUT       => 300,
    CURLOPT_HTTPHEADER    => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: " . idem_key($payload["task"], $body, 2), // :a1 would replay step 4
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event, &$job, &$err) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = trim(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:")) {
                $d = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") echo $d["text"];      // progress only
                elseif ($event === "done") $job = $d;
                elseif ($event === "error") $err = $d;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
if ($err) throw new Exception(json_encode($err));

$lines = parse_lines($job["output"]["output"] ?? ""); // parse the final text, not the deltas
var req = new HttpRequestMessage(HttpMethod.Post, RooflineWalk.Api + "/run-stream")
{
    Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", RooflineWalk.Token);
req.Headers.Add("Idempotency-Key", IdemKey("report", body, 2)); // :a1 would replay step 4

using var res = await RooflineWalk.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(JsonDocument.Parse(data).RootElement.GetProperty("text").GetString());
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

var final = JsonDocument.Parse(done!).RootElement;
var reply = final.GetProperty("output").GetProperty("output").GetString(); // parse it as in step 4

Treat delta as progress only and parse the reply from done: some clients and proxies deliver only tick heartbeats until the end. In a browser, EventSource only speaks GET, so read the fetch body as the JavaScript sample does. On an idempotent replay the server may answer with a plain JSON envelope instead of a stream, so check Content-Type before parsing frames.

Worked examples per lane

One house, 12 Elm Street, walked with the storm checklist nine days after a hail storm, then walked again three weeks later. The inputs are real app inputs with items_json shortened to four rows (the real ones carry 16 and 11 items); the rest is unchanged, so each one is a valid body for /estimate, /run or /run-stream. The replies are the first lines of real model replies; real wording will differ run to run.

1 - report

Every failed item plus every item with a note. photos is only a count: no image is ever sent.

Input

{
  "task": "report",
  "template": "storm",
  "template_name": "After a hail or wind storm",
  "cover_json": "{\"title\":\"After the 14 Sept hail storm\",\"place\":\"12 Elm Street\",\"walked_on\":\"2026-09-23\",\"inspector\":\"Corvin Ashdale\",\"purpose\":\"Decide whether to file a hail claim and what to fix first\",\"audience\":\"insurer\",\"details\":{\"Storm date\":\"2026-09-14\",\"Storm type\":\"hail and wind\",\"Largest hail seen\":\"quarter (1 in)\"}}",
  "sections_json": "[{\"section\":\"Safety first\",\"total\":3,\"pass\":3,\"fail\":0,\"na\":0,\"unchecked\":0},{\"section\":\"Roof from the ground\",\"total\":8,\"pass\":3,\"fail\":4,\"na\":1,\"unchecked\":0},{\"section\":\"Soft metals: the hail evidence\",\"total\":5,\"pass\":3,\"fail\":2,\"na\":0,\"unchecked\":0},{\"section\":\"Gutters and drainage\",\"total\":3,\"pass\":2,\"fail\":1,\"na\":0,\"unchecked\":0},{\"section\":\"Siding, windows and doors\",\"total\":5,\"pass\":4,\"fail\":1,\"na\":0,\"unchecked\":0},{\"section\":\"Yard and outbuildings\",\"total\":4,\"pass\":2,\"fail\":1,\"na\":1,\"unchecked\":0},{\"section\":\"Attic and ceilings from inside\",\"total\":4,\"pass\":3,\"fail\":1,\"na\":0,\"unchecked\":0},{\"section\":\"Documentation for the claim\",\"total\":5,\"pass\":3,\"fail\":1,\"na\":0,\"unchecked\":1}]",
  "items_json": "[{\"id\":\"RF1\",\"section\":\"Roof from the ground\",\"item\":\"Missing, creased or lifted shingles\",\"status\":\"fail\",\"note\":\"Two tabs missing on the north slope near the ridge, one creased tab above the back door.\",\"photos\":0,\"default_severity\":\"high\",\"who\":\"roofer\",\"browser_severity\":\"high\"},{\"id\":\"RF2\",\"section\":\"Roof from the ground\",\"item\":\"Hail hits: dark spots and granule loss on shingles\",\"status\":\"fail\",\"note\":\"Dark round spots about the size of a quarter all over the west slope, granules gone in the spots. Counted 9 in one area about 3 ft square through binoculars.\",\"photos\":1,\"default_severity\":\"high\",\"who\":\"roofer\",\"browser_severity\":\"high\"},{\"id\":\"AT2\",\"section\":\"Attic and ceilings from inside\",\"item\":\"New water stains on ceilings and upper walls\",\"status\":\"fail\",\"note\":\"Fresh brown ring about 8 in across on the back bedroom ceiling under the west slope. It appeared after the storm.\",\"photos\":0,\"default_severity\":\"high\",\"who\":\"roofer\",\"browser_severity\":\"high\"},{\"id\":\"DC3\",\"section\":\"Documentation for the claim\",\"item\":\"Storm date recorded and claim reported on time\",\"status\":\"fail\",\"note\":\"Have not called the insurer yet - the storm was 9 days ago.\",\"photos\":0,\"default_severity\":\"high\",\"who\":\"insurer\",\"browser_severity\":\"high\"}]",
  "prescan_json": "{\"browser_verdict\":\"repair_soon\",\"failed\":11,\"passed\":23,\"na\":2,\"unchecked\":1,\"photos\":4,\"note_flags\":{\"RF1\":[\"opening\"],\"SW4\":[\"opening\"]},\"notes_clipped_to\":0}"
}

Reply (output.output, first 5 of 21 lines)

{"k":"meta","task":"report","verdict":"repair_soon","headline":"The storm left hail and wind damage across the roof, gutters, screens and yard that is worth fixing this season, but call your insurer about the claim right away since nine days have already passed."}
{"k":"finding","item":"RF1","severity":"high","urgency":"weeks","what":"Two shingle tabs are missing on the north slope near the ridge and one tab is creased above the back door. A missing or creased shingle has lost its seal and wind resistance, so it is likely to tear off further or let water in during the next storm.","do":"Have a roofer inspect and reseal or replace the missing and creased shingles before the next storm.","who":"roofer","ask":"Are the missing and creased shingles isolated repairs, or does the north slope need wider replacement?"}
{"k":"finding","item":"RF2","severity":"high","urgency":"weeks","what":"The west slope has scattered dark spots with the granules knocked off, and you counted 9 hits in one 3 ft square through binoculars - a pattern consistent with hail strikes rather than normal wear.","do":"Have a roofer confirm whether this granule loss counts as functional hail damage and keep your existing photo with the claim.","who":"roofer","ask":"Do the marks on the west slope meet the threshold your company treats as functional hail damage?"}
{"k":"finding","item":"AT2","severity":"high","urgency":"now","what":"A fresh brown ring about 8 in across appeared on the back bedroom ceiling under the west slope after the storm, which usually means water has already gotten past the roof above that room.","do":"Have a roofer find the leak's entry point on the west slope as soon as possible and watch the stain for any further growth.","who":"roofer","ask":"Where on the roof is this leak most likely coming from, and will the interior repair be included in the claim?"}
{"k":"finding","item":"DC3","severity":"high","urgency":"now","what":"You have not yet called the insurer, and the storm was 9 days ago, so your reporting deadline and your wind or hail deductible are still unconfirmed.","do":"Call the insurer now to report the storm date and open the claim before any filing deadline passes.","who":"insurer","ask":"What is my deadline to file for this storm, and does a separate wind or hail deductible apply?"}

The full reply continues with one finding per failed item, then the question lines, a verdict and {"k":"end"}.

2 - reinspect

The follow-up walk: only the items that failed last time, each with its before state, plus previous_json describing the earlier walk.

Input

{
  "task": "reinspect",
  "template": "storm",
  "template_name": "After a hail or wind storm",
  "cover_json": "{\"title\":\"Re-inspection: After the 14 Sept hail storm\",\"place\":\"12 Elm Street\",\"walked_on\":\"2026-10-14\",\"inspector\":\"Corvin Ashdale\",\"purpose\":\"Decide whether to file a hail claim and what to fix first\",\"audience\":\"insurer\",\"details\":{\"Storm date\":\"2026-09-14\",\"Storm type\":\"hail and wind\",\"Largest hail seen\":\"quarter (1 in)\"}}",
  "sections_json": "[{\"section\":\"Roof from the ground\",\"total\":4,\"pass\":2,\"fail\":2,\"na\":0,\"unchecked\":0},{\"section\":\"Soft metals: the hail evidence\",\"total\":2,\"pass\":0,\"fail\":2,\"na\":0,\"unchecked\":0},{\"section\":\"Gutters and drainage\",\"total\":1,\"pass\":1,\"fail\":0,\"na\":0,\"unchecked\":0},{\"section\":\"Siding, windows and doors\",\"total\":1,\"pass\":0,\"fail\":0,\"na\":0,\"unchecked\":1},{\"section\":\"Yard and outbuildings\",\"total\":1,\"pass\":1,\"fail\":0,\"na\":0,\"unchecked\":0},{\"section\":\"Attic and ceilings from inside\",\"total\":1,\"pass\":0,\"fail\":1,\"na\":0,\"unchecked\":0},{\"section\":\"Documentation for the claim\",\"total\":1,\"pass\":1,\"fail\":0,\"na\":0,\"unchecked\":0}]",
  "items_json": "[{\"id\":\"RF1\",\"section\":\"Roof from the ground\",\"item\":\"Missing, creased or lifted shingles\",\"status\":\"pass\",\"note\":\"Roofer replaced the missing and creased tabs on 3 Oct.\",\"photos\":0,\"before\":{\"status\":\"fail\",\"note\":\"Two tabs missing on the north slope near the ridge, one creased tab above the back door.\",\"severity\":\"high\"},\"default_severity\":\"high\",\"who\":\"roofer\"},{\"id\":\"RF2\",\"section\":\"Roof from the ground\",\"item\":\"Hail hits: dark spots and granule loss on shingles\",\"status\":\"fail\",\"note\":\"Spots still there - adjuster visit booked for 20 Oct.\",\"photos\":0,\"before\":{\"status\":\"fail\",\"note\":\"Dark round spots about the size of a quarter all over the west slope, granules gone in the spots. Counted 9 in one area about 3 ft square through binoculars.\",\"severity\":\"high\"},\"default_severity\":\"high\",\"who\":\"roofer\"},{\"id\":\"RF4\",\"section\":\"Roof from the ground\",\"item\":\"Ridge and hip caps\",\"status\":\"pass\",\"note\":\"Ridge cap pieces replaced with the tabs.\",\"photos\":0,\"before\":{\"status\":\"fail\",\"note\":\"A few ridge cap pieces look cracked at the west end.\",\"severity\":\"medium\"},\"default_severity\":\"medium\",\"who\":\"roofer\"},{\"id\":\"RF6\",\"section\":\"Roof from the ground\",\"item\":\"Roof vents, caps and pipe boots\",\"status\":\"fail\",\"note\":\"Vent cap still dented, roofer says it is part of the claim.\",\"photos\":0,\"before\":{\"status\":\"fail\",\"note\":\"Kitchen exhaust vent cap dented on top.\",\"severity\":\"medium\"},\"default_severity\":\"medium\",\"who\":\"roofer\"}]",
  "prescan_json": "{\"browser_verdict\":\"repair_soon\",\"failed\":5,\"passed\":5,\"na\":0,\"unchecked\":1,\"photos\":0,\"note_flags\":{\"RF1\":[\"opening\"]},\"notes_clipped_to\":0}",
  "previous_json": "{\"title\":\"After the 14 Sept hail storm\",\"walked_on\":\"2026-09-23\",\"verdict\":\"repair_soon\",\"failed_items\":11}"
}

Reply (output.output, first 5 of 18 lines)

{"k":"meta","task":"reinspect","verdict":"act_now","headline":"Five of the eleven storm items are now fixed, but the attic water stain has grown and the paint is bubbling, so the roofer should be called about an active leak now, while the hail spots, vent cap, gutter dents, and AC fins stay open for the adjuster."}
{"k":"recheck","item":"RF1","outcome":"resolved","severity":"low","comment":"The roofer replaced the missing and creased tabs on 3 Oct, and the item now passes.","next":"No action needed; keep the before-and-after record in case the adjuster asks."}
{"k":"recheck","item":"RF2","outcome":"unchanged","severity":"high","comment":"The dark spots and granule loss are still there; nothing has changed since the last walk, and an adjuster visit is booked for 20 Oct.","next":"Leave the spots untouched until the adjuster sees them on 20 Oct."}
{"k":"recheck","item":"RF4","outcome":"resolved","severity":"low","comment":"The cracked ridge cap pieces were replaced along with the shingle tabs.","next":"No action needed."}
{"k":"recheck","item":"RF6","outcome":"unchanged","severity":"medium","comment":"The vent cap is still dented; the roofer says it is part of the claim, so it has not been fixed yet.","next":"Confirm with the roofer that the vent cap is on the claim scope before the adjuster signs off."}

The full reply continues with a recheck for every item, then the question lines, a verdict and {"k":"end"}.