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.
| Status | error.code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation; details names the field. |
401 | unauthorized | Invalid or expired app session. Get a new token (step 1). |
402 | payment_required | Balance is below min_credits for this run. Top up, or sign in for a personal token. |
404 | not_found | Unknown job id, or a job that belongs to another token. |
409 | conflict | The Idempotency-Key was already used with a different body. |
429 | rate_limited | Too 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
| task | What it does | Items sent in items_json | Example |
|---|---|---|---|
report | Prioritised 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 |
reinspect | A 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
| Field | Type | Lanes | Notes |
|---|---|---|---|
template | string, required | both | storm (after hail or wind) | wildfire (home hardening and defensible-space zones) | seasonal (spring or autumn roof and drainage check). |
template_name | string | both | The checklist's display name, e.g. After a hail or wind storm. |
cover_json | string (JSON object), required | both | {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_json | string (JSON array) | both | Per-section tallies {section, total, pass, fail, na, unchecked}. |
items_json | string (JSON array), required | both | Each 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_json | string (JSON object) | both, optional | Facts 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_json | string (JSON object) | reinspect | The walk being followed up: {title, walked_on, verdict, failed_items}. |
retry_note | string, optional | both | A 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.
| task | Lines, in order |
|---|---|
report | meta {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. |
reinspect | meta {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
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?
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
Send exactly the body you would send to /run. Nothing is charged and no job is
created. The answer carries:
hold_credits: the credits a run would reserve up front. It is a ceiling, not the price; the run chargescharged_creditsand releases the rest.min_credits: the balance needed to start a run at all (below it,/runanswers402).model(gpt-5.6-terra) andmodel_alias(gpt-terra): the model the lane runs on.markup_bps: the app's markup in basis points (1000= 10%).input_checked: whether the body was validated against the app's input schema.warnings: anything the server did not like about the input (an unknown field, for instance).
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
/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
/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:
job:{job_id}, sent once when the job starts.delta:{"text"}, the next piece of the reply.tick:{"t"}, a heartbeat with seconds elapsed.done: the whole finished job, includingoutput.output,charged_creditsandtruncated.error:{code, message}; the stream ends.
# 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"}.