Grill Desk — API

Ask a plan the questions it is dodging, from your own tools.

API tokens Back to the app

Run a round of grilling on a plan from your own pipeline

Send the plan text, what the prescan measured about it, and every question the user has already answered, and get back one JSON object: an understanding that proves the plan was read, a questions array holding the whole frontier — every decision whose prerequisites are already settled, each with a recommendation, a why_now and a severity — plus deferred questions waiting on this round's answers, verify_yourself facts to go and check rather than ask about, and a prescan_coverage block that accounts for every measured finding by id. The output is mechanically checkable: the app's own grillkit.js re-runs the prescan and re-checks the round against it — frontier discipline, a recommendation on every question, no invented finding ids — and your pipeline can do the same. Wire it into a planning workflow to grill an RFC before review, to gate a design-doc repo on an empty frontier, or to loop rounds until done is true. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL https://api.skillsafe.ai/v1/app-api, app slug grill-desk. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Rounds are written by the gpt-terra model alias (currently gpt-5.6-terra) at a publisher markup of 1000 bps — 10%. Credits are in units of 1/10 000 of a US dollar, so 10 000 credits is $1.00.

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

Error codes

HTTPcodeWhat it means and what to do
400validation_errorThe body is missing a required field or a field has the wrong type. error.details names it. POST /guest in particular needs slug in the body — sending it as an X-App-Slug header returns 400 "slug is required".
401unauthorizedNo token, a malformed token, or a token that has expired. Mint a new guest token or sign in again.
402payment_requiredThe balance cannot cover this round's minimum. Call /estimate first and compare min_credits against /me's credits.
404not_foundUnknown job id, unknown collection, or a record that belongs to another subject. Guest identities are per-token: a new guest token cannot see the previous guest's sessions.
409conflictAn Idempotency-Key was reused with a different body. Bump the attempt counter in the key when the input changes.
429rate_limitedToo many requests. Back off and retry; do not tight-loop. Semantic search over the grills collection has its own limit — 30 requests/minute per IP.
500internal_errorTransient. Retry with the same Idempotency-Key so the retry cannot bill twice.
503service_unavailableThe upstream model is briefly unreachable or overloaded. Same rule as 500: retry with the same key, with a backoff.
The one call that costs money is /run and /run-stream. /guest, /me and /estimate are free, so a client can price a round, check the balance and prove the model binding without spending anything — and the prescan that produces most of this API's input is a pure client-side computation that costs nothing at all.

Step 1 · Get a token

Two ways in. If you already use the app in a browser, open the token page at /tokens.html and press Copy shell export — it hands you the exact export SKILLSAFE_TOKEN="…" line, with no DevTools console involved. For a fully scripted client, POST /guest mints a guest token with no browser at all. The slug goes in the body: {"slug":"grill-desk"}. Guest tokens can call /me and the free /estimate; a personal token is what bills rounds to your own account.

# Option A — take the token this browser already has: open /tokens.html,
# press "Copy shell export", and paste the line it gives you.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"

# Option B — mint a guest token with no browser at all. Guest tokens can call
# /me and the free /estimate; sign in for a personal token to bill rounds to
# your own account. The slug goes in the BODY — an X-App-Slug header is
# rejected with 400 "slug is required".
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"grill-desk"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "grill-desk"

def call(path, body=None, token=None, method=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                method=method or ("POST" if data else "GET"))
    req.add_header("Content-Type", "application/json")
    req.add_header("User-Agent", "grill-desk-client/1.0")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

# Option A: the token from /tokens.html, kept in your environment.
token = os.environ.get("SKILLSAFE_TOKEN")

# Option B: a fresh guest token, no browser involved. The slug goes in the body.
if not token:
    token = call("/guest", {"slug": SLUG})["token"]

print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "grill-desk";

async function call(path, { body, token, method } = {}) {
  const res = await fetch(BASE + path, {
    method: method || (body ? "POST" : "GET"),
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: "Bearer " + token } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (json.error) throw Object.assign(new Error(json.error.message), json.error);
  return json.data;
}

// Option A: paste the token from /tokens.html (or read it from your own config).
let token = "YOUR_TOKEN";

// Option B: mint a guest token — good for /me and the free /estimate.
if (token === "YOUR_TOKEN") token = (await call("/guest", { body: { slug: SLUG } })).token;

console.log(token.slice(0, 12) + "...");
package main

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

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

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

func call(path, token string, body any, out any) error {
    var rdr io.Reader
    method := "GET"
    if body != nil {
        b, _ := json.Marshal(body)
        rdr = bytes.NewReader(b)
        method = "POST"
    }
    req, _ := http.NewRequest(method, base+path, rdr)
    req.Header.Set("Content-Type", "application/json")
    if token != "" {
        req.Header.Set("Authorization", "Bearer "+token)
    }
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    var env envelope
    if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
        return err
    }
    if env.Error != nil {
        return errors.New(env.Error.Code + ": " + env.Error.Message)
    }
    if out != nil {
        return json.Unmarshal(env.Data, out)
    }
    return nil
}

func main() {
    token := os.Getenv("SKILLSAFE_TOKEN")
    if token == "" {
        // The slug travels in the body, never as a header.
        var guest struct{ Token string `json:"token"` }
        if err := call("/guest", "", map[string]string{"slug": slug}, &guest); err != nil {
            panic(err)
        }
        token = guest.Token
    }
    fmt.Println(token[:12] + "...")
}
import java.net.URI;
import java.net.http.*;

public class GrillDesk {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String SLUG = "grill-desk";
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String call(String path, String token, String jsonBody) throws Exception {
        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
            .header("Content-Type", "application/json");
        if (token != null) b.header("Authorization", "Bearer " + token);
        b = jsonBody == null ? b.GET()
                             : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
        HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        return res.body();   // {"data":...} or {"error":{...}} — parse with your JSON library
    }

    public static void main(String[] args) throws Exception {
        String token = System.getenv("SKILLSAFE_TOKEN");
        if (token == null) {
            // POST /guest with the slug in the body returns {"data":{"token":"aut_..."}}
            System.out.println(call("/guest", null, "{\"slug\":\"" + SLUG + "\"}"));
        } else {
            System.out.println(token.substring(0, 12) + "...");
        }
    }
}
require "json"
require "net/http"

BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "grill-desk"

def call(path, body: nil, token: nil, method: nil)
  uri = URI(BASE.to_s + path)
  req = (method || (body ? "POST" : "GET")) == "POST" ?
    Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}" if token
  req.body = JSON.generate(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  json = JSON.parse(res.body)
  raise "#{json['error']['code']}: #{json['error']['message']}" if json["error"]
  json["data"]
end

# The slug goes in the body of /guest, not in a header.
token = ENV.fetch("SKILLSAFE_TOKEN", nil) || call("/guest", body: { slug: SLUG })["token"]
puts token[0, 12] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "grill-desk";

function call(string $path, ?array $body = null, ?string $token = null): array {
    $headers = ["Content-Type: application/json"];
    if ($token) { $headers[] = "Authorization: Bearer " . $token; }
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $json = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (isset($json["error"])) {
        throw new RuntimeException($json["error"]["code"] . ": " . $json["error"]["message"]);
    }
    return $json["data"];
}

$token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
echo substr($token, 0, 12) . "...\n";
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;

class GrillDesk {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    const string Slug = "grill-desk";
    static readonly HttpClient Http = new HttpClient();

    static async Task<JsonElement> Call(string path, object body = null, string token = null) {
        var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
        if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        if (doc.RootElement.TryGetProperty("error", out var err))
            throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
        return doc.RootElement.GetProperty("data");
    }

    static async Task Main() {
        var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
        if (token == null) {
            // Slug in the body; a header is rejected with 400 "slug is required".
            var guest = await Call("/guest", new { slug = Slug });
            token = guest.GetProperty("token").GetString();
        }
        Console.WriteLine(token.Substring(0, 12) + "...");
    }
}

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

GET /me returns subject_type (user or guest), subject_id and credits. Compare credits against /estimate's min_credits before submitting a round — a 402 after submit is a client bug, not a user problem. subject_type also decides what your history reads: records in the grills collection are scoped to the subject, and every POST /guest mints a new guest identity.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","subject_id":"usr_...","credits":184213}}
#
# subject_type is "user" for a personal token and "guest" for a guest one.
# credits is in credit units: 10 000 credits = $1.00.
me = call("/me", token=token)
print(me["subject_type"], me["credits"], "credits",
      "= $%.2f" % (me["credits"] / 10000))
const me = await call("/me", { token });
console.log(me.subject_type, me.credits, "credits =",
  "$" + (me.credits / 10000).toFixed(2));
var me struct {
    SubjectType string `json:"subject_type"`
    SubjectID   string `json:"subject_id"`
    Credits     int64  `json:"credits"`
}
if err := call("/me", token, nil, &me); err != nil {
    panic(err)
}
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
// GET /me — {"data":{"subject_type":"user","credits":184213}}
String me = call("/me", token, null);
System.out.println(me);
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']} credits = $#{'%.2f' % (me['credits'] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F2}");

Step 3 · Price the round — free, and it proves the model binding

POST /estimate takes the same body as /run, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Present hold_credits as reserved, never as the price: the hold covers the full output cap — a round with eight long questions, deferred items and coverage — and the settled charged_credits is usually far lower. A long plan raises the hold, which is one more reason to estimate per round rather than once per session.

# /estimate is free: no job is created, no credits are held, nothing is charged.
# Use it to show a price and to prove the model binding before you spend anything.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d @grill-input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":2958,"min_credits":240,"sponsor_enabled":false}}
est = call("/estimate", body=grill_input, token=token)
print("model", est["model"], "alias", est["model_alias"], "markup", est["markup_bps"])
print("reserved up to $%.4f per round" % (est["hold_credits"] / 10000))
if me["credits"] < est["min_credits"]:
    raise SystemExit("balance below the model minimum — top up before running a round")
const est = await call("/estimate", { body: grillInput, token });
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved up to $" + (est.hold_credits / 10000).toFixed(4) + " per round");
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
var est struct {
    Model       string `json:"model"`
    ModelAlias  string `json:"model_alias"`
    MarkupBps   int    `json:"markup_bps"`
    HoldCredits int64  `json:"hold_credits"`
    MinCredits  int64  `json:"min_credits"`
}
if err := call("/estimate", token, grillInput, &est); err != nil {
    panic(err)
}
fmt.Printf("%s (%s) markup %d bps, reserve $%.4f\n",
    est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000)
// POST /estimate with the same body you would send to /run. Free, no job.
String est = call("/estimate", token, grillInputJson);
System.out.println(est);
// {"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,...}
est = call("/estimate", body: grill_input, token: token)
puts "#{est['model']} (#{est['model_alias']}) markup #{est['markup_bps']} bps"
puts "reserved up to $#{'%.4f' % (est['hold_credits'] / 10000.0)} per round"
$est = call("/estimate", $grill_input, $token);
printf("%s (%s) markup %d bps, reserve $%.4f\n",
    $est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"] / 10000);
var est = await Call("/estimate", grillInput, token);
Console.WriteLine(est.GetProperty("model").GetString() + " / " +
                  est.GetProperty("model_alias").GetString() + " markup " +
                  est.GetProperty("markup_bps").GetInt32() + " bps");

Step 4 · Run the round and poll for it

POST /run returns {"job_id"}; poll GET /jobs/{job_id} until status is succeeded or failed, then read data.output.output — the round as a JSON string. Always send Idempotency-Key (step 6): a network blip or a retry after a malformed reply must never bill the same round twice. A round is one turn of a multi-round loop — take the answers the user gives to this round's questions, append them to settled, increment round, and run again until done is true.

# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing the round twice.
KEY="grill-desk:$(python3 fnv1a.py grill-input.json):a1"

JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d @grill-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while true; do
  OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
# => {"plan_title":"Move nightly reporting off the cron box","understanding":"…",
#     "round":1,"done":false,"questions":[…],"deferred":[…],"verify_yourself":[…],
#     "prescan_coverage":{…},"settled_recap":[]}
import time

def run_round(inp, token, attempt=1):
    data = json.dumps(inp).encode()
    req = urllib.request.Request(BASE + "/run", data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("Idempotency-Key", idem_key(inp, attempt))   # see step 6
    with urllib.request.urlopen(req) as r:
        job_id = json.loads(r.read())["data"]["job_id"]
    while True:
        job = call("/jobs/" + job_id, token=token)
        if job["status"] in ("succeeded", "failed"):
            break
        time.sleep(2)
    if job["status"] == "failed":
        raise RuntimeError(job.get("error") or "run failed")
    return json.loads(job["output"]["output"])

result = run_round(grill_input, token)
print(result["plan_title"], "round", result["round"],
      "done" if result["done"] else "%d questions" % len(result["questions"]))
for q in result["questions"]:
    print("[%s] %s — %s" % (q["severity"], q["id"], q["title"]))
    print("    recommends:", q["recommendation"])
async function runRound(inp, token, attempt = 1) {
  const res = await fetch(BASE + "/run", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + token,
      "Idempotency-Key": idemKey(inp, attempt),   // see step 6
    },
    body: JSON.stringify(inp),
  });
  const { data, error } = await res.json();
  if (error) throw new Error(error.message);
  let job;
  do {
    await new Promise((r) => setTimeout(r, 2000));
    job = await call("/jobs/" + data.job_id, { token });
  } while (job.status !== "succeeded" && job.status !== "failed");
  if (job.status === "failed") throw new Error(job.error || "run failed");
  return JSON.parse(job.output.output);
}

const result = await runRound(grillInput, token);
console.log(result.plan_title, "round", result.round,
  result.done ? "— frontier empty" : `— ${result.questions.length} open`);
for (const q of result.questions) {
  console.log(`[${q.severity}] ${q.id} ${q.title}`);
  console.log("   why now:", q.why_now);
}
import "time"

// POST /run with the Idempotency-Key header, then poll GET /jobs/{id} every two
// seconds until status is "succeeded" or "failed". job.Output.Output holds the
// round as a JSON string; unmarshal it into your own round struct.
func runRound(inp map[string]any, token string, attempt int) (string, error) {
    b, _ := json.Marshal(inp)
    req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Idempotency-Key", idemKey(inp, attempt)) // see step 6
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer res.Body.Close()
    var env envelope
    json.NewDecoder(res.Body).Decode(&env)
    var started struct{ JobID string `json:"job_id"` }
    json.Unmarshal(env.Data, &started)
    for {
        var job struct {
            Status string `json:"status"`
            Output struct{ Output string `json:"output"` } `json:"output"`
        }
        if err := call("/jobs/"+started.JobID, token, nil, &job); err != nil {
            return "", err
        }
        if job.Status == "succeeded" {
            return job.Output.Output, nil
        }
        if job.Status == "failed" {
            return "", errors.New("run failed")
        }
        time.Sleep(2 * time.Second)
    }
}
// POST /run must carry Idempotency-Key, derived from the input plus an attempt
// counter, so a network retry cannot bill the same round twice.
String key = "grill-desk:" + fnv1aSeed(planTitle, focus, planText, round, settled) + ":a1";

HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(grillInputJson))
    .build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// started => {"data":{"job_id":"job_..."}}
// then poll GET /jobs/{job_id} until status is succeeded or failed, and read
// data.output.output — {"plan_title":…,"understanding":…,"questions":[…],
// "deferred":[…],"verify_yourself":[…],"prescan_coverage":{…}} as a JSON string.
def run_round(inp, token, attempt = 1)
  uri = URI(BASE.to_s + "/run")
  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}"
  req["Idempotency-Key"] = idem_key(inp, attempt)   # see step 6
  req.body = JSON.generate(inp)
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  job_id = JSON.parse(res.body)["data"]["job_id"]
  loop do
    job = call("/jobs/#{job_id}", token: token)
    return JSON.parse(job["output"]["output"]) if job["status"] == "succeeded"
    raise "run failed" if job["status"] == "failed"
    sleep 2
  end
end

result = run_round(grill_input, token)
puts "#{result['plan_title']} — round #{result['round']}"
result["questions"].each { |q| puts "[#{q['severity']}] #{q['id']} #{q['title']}" }
function run_round(array $inp, string $token, int $attempt = 1): array {
    $ch = curl_init(BASE . "/run");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($inp),
        CURLOPT_HTTPHEADER => [
            "Content-Type: application/json",
            "Authorization: Bearer " . $token,
            "Idempotency-Key: " . idem_key($inp, $attempt),   // see step 6
        ],
    ]);
    $job_id = json_decode(curl_exec($ch), true)["data"]["job_id"];
    curl_close($ch);
    while (true) {
        $job = call("/jobs/" . $job_id, null, $token);
        if ($job["status"] === "succeeded") { return json_decode($job["output"]["output"], true); }
        if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
        sleep(2);
    }
}

$result = run_round($grill_input, $token);
echo $result["plan_title"] . " — round " . $result["round"] . "\n";
foreach ($result["questions"] as $q) {
    echo "[{$q['severity']}] {$q['id']} {$q['title']}\n";
}
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
    Content = JsonContent.Create(grillInput)
};
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", IdemKey(grillInput, 1));   // see step 6
var started = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();

// Poll GET /jobs/{jobId} every two seconds; on "succeeded", data.output.output is
// {"plan_title":…,"understanding":…,"round":1,"done":false,"questions":[…],
//  "deferred":[…],"verify_yourself":[…],"prescan_coverage":{…}} as a JSON string.
// Feed the user's answers back as `settled` and run the next round.

Step 5 · Or stream it

POST /run-stream is the same call over server-sent events, which is what the web app uses so it can show the round taking shape. The frame name arrives on the event: linejob, delta, done — and is not a type field inside the payload; a client that looks for payload.type will see nothing and hang. Concatenate every delta payload's text to rebuild the JSON, and read charged_credits and truncated from the done frame. If truncated is true the output cap was reduced to fit the balance: a round cut off mid-question is not the whole frontier, which is the one property this method depends on, so say so rather than presenting it as a complete round.

# Server-sent events. Frame names arrive on the `event:` line, not as a field in
# the payload — `delta` carries text chunks, `job` the job id, `done` the
# settlement (charged_credits, truncated).
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d @grill-input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"plan_title\":\"Move nightly reporting off the cron"}
# ...
# event: done
# data: {"status":"succeeded","charged_credits":734,"truncated":false}
def run_stream(inp, token, attempt=1, on_delta=None):
    data = json.dumps(inp).encode()
    req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("Idempotency-Key", idem_key(inp, attempt))
    raw, event = "", None
    with urllib.request.urlopen(req) as r:
        for line in r:
            line = line.decode().rstrip("\n")
            if line.startswith("event:"):
                event = line[6:].strip()          # the frame name lives HERE
            elif line.startswith("data:"):
                payload = json.loads(line[5:].strip() or "{}")
                if event == "delta":
                    raw += payload.get("text", "")
                    if on_delta:
                        on_delta(payload.get("text", ""))
                elif event == "done":
                    return json.loads(raw), payload
    raise RuntimeError("stream ended without a done frame")

result, settle = run_stream(grill_input, token)
print(result["plan_title"], "charged", settle["charged_credits"])
if settle.get("truncated"):
    print("output was capped — this round is NOT the whole frontier")
for v in result["verify_yourself"]:
    print("check yourself:", v["fact"], "—", v["how"])
async function runStream(inp, token, onDelta, attempt = 1) {
  const res = await fetch(BASE + "/run-stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + token,
      "Idempotency-Key": idemKey(inp, attempt),
    },
    body: JSON.stringify(inp),
  });
  const reader = res.body.getReader();
  const dec = new TextDecoder();
  let buf = "", raw = "", event = null;
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    const lines = buf.split("\n");
    buf = lines.pop();
    for (const line of lines) {
      if (line.startsWith("event:")) event = line.slice(6).trim();  // frame name
      else if (line.startsWith("data:")) {
        const payload = JSON.parse(line.slice(5).trim() || "{}");
        if (event === "delta") { raw += payload.text || ""; onDelta?.(payload.text || ""); }
        else if (event === "done") return { result: JSON.parse(raw), settle: payload };
      }
    }
  }
  throw new Error("stream ended without a done frame");
}

let chars = 0;
const { result, settle } = await runStream(grillInput, token, (t) => { chars += t.length; });
console.log(result.plan_title, "charged", settle.charged_credits, "-", chars, "chars");
if (settle.truncated) console.warn("capped output — not the whole frontier");
// POST /run-stream and read the SSE frames. The frame name is on the `event:`
// line; `delta` payloads carry {"text":"..."} and concatenate into the round JSON.
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
    panic(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw strings.Builder
event := ""
for sc.Scan() {
    line := sc.Text()
    switch {
    case strings.HasPrefix(line, "event:"):
        event = strings.TrimSpace(line[6:])
    case strings.HasPrefix(line, "data:"):
        payload := strings.TrimSpace(line[5:])
        if event == "delta" {
            var d struct{ Text string `json:"text"` }
            json.Unmarshal([]byte(payload), &d)
            raw.WriteString(d.Text)
        } else if event == "done" {
            fmt.Println("settled:", payload)   // charged_credits, truncated
            fmt.Println("round:", raw.String())
            return
        }
    }
}
// POST /run-stream with BodyHandlers.ofLines() and fold the SSE frames yourself.
// The frame name is the `event:` line — there is no "type" field in the payload.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(grillInputJson))
    .build();

StringBuilder raw = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library and append it
        raw.append(extractText(line.substring(5).trim()));
    }
});
System.out.println(raw);   // {"plan_title":…,"questions":[…],"deferred":[…]}
def run_stream(inp, token, attempt = 1)
  uri = URI(BASE.to_s + "/run-stream")
  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}"
  req["Idempotency-Key"] = idem_key(inp, attempt)
  req.body = JSON.generate(inp)
  raw = ""
  event = nil
  settle = nil
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(req) do |res|
      res.read_body do |chunk|
        chunk.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip           # the frame name
          elsif line.start_with?("data:")
            payload = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
            raw << payload.fetch("text", "") if event == "delta"
            settle = payload if event == "done"
          end
        end
      end
    end
  end
  [JSON.parse(raw), settle]
end

result, settle = run_stream(grill_input, token)
puts "#{result['plan_title']} charged #{settle['charged_credits']}"
warn "capped output — not the whole frontier" if settle["truncated"]
// POST /run-stream with a write callback; the frame name arrives on `event:`.
$raw = "";
$event = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($grill_input),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer " . $token,
        "Idempotency-Key: " . idem_key($grill_input),
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            $line = rtrim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:") && $event === "delta") {
                $payload = json_decode(trim(substr($line, 5)), true) ?: [];
                $raw .= $payload["text"] ?? "";
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
echo $result["plan_title"] . " — " . count($result["questions"]) . " questions\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
    Content = JsonContent.Create(grillInput)
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Idempotency-Key", IdemKey(grillInput, 1));

using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
    if (line.StartsWith("event:")) {
        evt = line[6..].Trim();              // the frame name lives here
    } else if (line.StartsWith("data:")) {
        var payload = JsonDocument.Parse(line[5..].Trim() is { Length: > 0 } s ? s : "{}");
        if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
            raw.Append(t.GetString());
        else if (evt == "done")
            Console.WriteLine("settled: " + payload.RootElement);
    }
}
Console.WriteLine(raw.ToString());

Step 6 · Idempotency — and why the reformat retry reuses the key

The app sends Idempotency-Key: grill-desk:<fnv1a>:<len>:a<attempt>. The hash is a 32-bit FNV-1a over the round's context — the plan title, the focus, the raw plan text, the round number and every settled question/answer pair joined with |, each trimmed and joined with a single space — and len is the length of that same seed string, which makes an accidental hash collision on a different plan vanishingly unlikely. So a key looks like grill-desk:9f3c21ab:2841:a1.

The reformat-retry lane reuses the same hash. When a reply cannot be parsed as the single JSON object the contract requires, the app re-asks once with a retry_note naming the parse error. The context has not changed, so the seed has not changed, so the key changes only in its attempt counter — and a malformed first reply therefore cannot double-bill the user for one round of thinking. The rule generalises: reuse the key for a retry of the same context (a timeout, a 500, a 503, a reformat); bump the attempt counter only when the context itself changed. Reusing a key with a genuinely different body is what earns a 409 conflict.

# fnv1a.py — the same 32-bit FNV-1a the app computes, over the raw context.
cat > fnv1a.py <<'PY'
import json, sys
ctx = json.load(open(sys.argv[1]))
seed = " ".join(str(x).strip() for x in [
    ctx.get("plan_title", ""), ctx.get("focus", ""), ctx.get("plan_text", ""),
    ctx.get("round", 1),
    "|".join(s["question"] + "=" + s["answer"] for s in ctx.get("settled", [])),
])
h = 0x811c9dc5
for ch in seed:
    h = ((h ^ ord(ch)) * 0x01000193) & 0xFFFFFFFF
print("%08x:%d" % (h, len(seed)))
PY

KEY="grill-desk:$(python3 fnv1a.py grill-input.json):a1"
echo "$KEY"        # => grill-desk:9f3c21ab:2841:a1

# Retry the SAME round after a 500, a 503 or a reformat: same key, same bill.
# Only a changed plan, focus, title, round or settled list gets a new one.
def idem_key(inp, attempt=1):
    settled = "|".join(s["question"] + "=" + s["answer"] for s in inp.get("settled", []))
    seed = " ".join(str(v).strip() for v in [
        inp.get("plan_title", ""), inp.get("focus", ""), inp.get("plan_text", ""),
        inp.get("round", 1), settled,
    ])
    h = 0x811c9dc5
    for ch in seed:
        h = ((h ^ ord(ch)) * 0x01000193) & 0xFFFFFFFF
    return "grill-desk:%08x:%d:a%d" % (h, len(seed), attempt)

# The reformat lane: one extra ask, the same seed, the attempt counter bumped —
# so the user is never billed twice for one round of thinking.
try:
    result = run_round(grill_input, token, attempt=1)
except ValueError:                      # the reply was not the single JSON object
    grill_input["retry_note"] = "Previous reply did not parse: unexpected token at 1:1. " \
                                "Send the same round again as one JSON object, no code fences."
    result = run_round(grill_input, token, attempt=2)
function idemKey(inp, attempt = 1) {
  const settled = (inp.settled || [])
    .map((s) => s.question + "=" + s.answer).join("|");
  const seed = [inp.plan_title, inp.focus, inp.plan_text, inp.round, settled]
    .map((v) => String(v ?? "").trim()).join(" ");
  let h = 0x811c9dc5;
  for (let i = 0; i < seed.length; i++) {
    h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193) >>> 0;
  }
  return `grill-desk:${("0000000" + h.toString(16)).slice(-8)}:${seed.length}:a${attempt}`;
}

// Same context, same key: a retry after a timeout or a malformed reply returns
// the original job instead of billing a second round.
console.log(idemKey(grillInput));        // grill-desk:9f3c21ab:2841:a1
console.log(idemKey(grillInput, 2));     // grill-desk:9f3c21ab:2841:a2
import "strings"

func idemKey(inp map[string]any, attempt int) string {
    settled := []string{}
    if arr, ok := inp["settled"].([]map[string]string); ok {
        for _, s := range arr {
            settled = append(settled, s["question"]+"="+s["answer"])
        }
    }
    parts := []string{
        fmt.Sprint(inp["plan_title"]), fmt.Sprint(inp["focus"]),
        fmt.Sprint(inp["plan_text"]), fmt.Sprint(inp["round"]),
        strings.Join(settled, "|"),
    }
    for i, p := range parts {
        parts[i] = strings.TrimSpace(p)
    }
    seed := strings.Join(parts, " ")
    var h uint32 = 0x811c9dc5
    for i := 0; i < len(seed); i++ {
        h = (h ^ uint32(seed[i])) * 0x01000193
    }
    return fmt.Sprintf("grill-desk:%08x:%d:a%d", h, len(seed), attempt)
}

// Retry the same context with the same key — a 500 or a 503 must not cost a
// second round. Bump `attempt` only when the plan, focus, round or settled
// answers actually changed; reusing a key with a different body is a 409.
// 32-bit FNV-1a over the round context, exactly as the app computes it.
static String fnv1aSeed(String title, String focus, String plan, int round,
                        java.util.List<String[]> settled) {
    StringBuilder sb = new StringBuilder();
    for (String[] s : settled) {
        if (sb.length() > 0) sb.append("|");
        sb.append(s[0]).append("=").append(s[1]);
    }
    String seed = String.join(" ", title.trim(), focus.trim(), plan.trim(),
                              String.valueOf(round), sb.toString().trim());
    int h = 0x811c9dc5;
    for (int i = 0; i < seed.length(); i++) {
        h = (h ^ seed.charAt(i)) * 0x01000193;
    }
    return String.format("%08x:%d", h, seed.length());
}

// key = "grill-desk:" + fnv1aSeed(...) + ":a" + attempt
// A reformat retry reuses the seed and bumps only the attempt counter, so one
// round of thinking is billed once no matter how many times the reply is re-asked.
def idem_key(inp, attempt = 1)
  settled = (inp["settled"] || []).map { |s| "#{s['question']}=#{s['answer']}" }.join("|")
  seed = [inp["plan_title"], inp["focus"], inp["plan_text"], inp["round"], settled]
         .map { |v| v.to_s.strip }.join(" ")
  h = 0x811c9dc5
  seed.each_char { |ch| h = ((h ^ ch.ord) * 0x01000193) & 0xFFFFFFFF }
  format("grill-desk:%08x:%d:a%d", h, seed.length, attempt)
end

# Same seed on the reformat retry — the malformed reply costs nothing extra.
puts idem_key(grill_input)      # grill-desk:9f3c21ab:2841:a1
function idem_key(array $inp, int $attempt = 1): string {
    $settled = implode("|", array_map(
        fn($s) => $s["question"] . "=" . $s["answer"], $inp["settled"] ?? []));
    $seed = implode(" ", array_map("trim", [
        (string)($inp["plan_title"] ?? ""), (string)($inp["focus"] ?? ""),
        (string)($inp["plan_text"] ?? ""), (string)($inp["round"] ?? 1), $settled,
    ]));
    $h = 0x811c9dc5;
    for ($i = 0; $i < strlen($seed); $i++) {
        $h = (($h ^ ord($seed[$i])) * 0x01000193) & 0xFFFFFFFF;
    }
    return sprintf("grill-desk:%08x:%d:a%d", $h, strlen($seed), $attempt);
}

// Reuse for a retry of the same context; bump the attempt only when the plan,
// the focus, the round or the settled answers changed.
echo idem_key($grill_input) . "\n";   // grill-desk:9f3c21ab:2841:a1
static string IdemKey(Dictionary<string, object> inp, int attempt = 1) {
    var settled = string.Join("|", (inp.TryGetValue("settled", out var sv)
        ? (IEnumerable<Dictionary<string, string>>)sv
        : Array.Empty<Dictionary<string, string>>())
        .Select(s => s["question"] + "=" + s["answer"]));
    var seed = string.Join(" ", new[] { "plan_title", "focus", "plan_text", "round" }
        .Select(k => inp.TryGetValue(k, out var v) ? (v?.ToString() ?? "").Trim() : "")
        .Append(settled.Trim()));
    uint h = 0x811c9dc5;
    foreach (var ch in seed) h = (h ^ ch) * 0x01000193;
    return $"grill-desk:{h:x8}:{seed.Length}:a{attempt}";
}

// Retry the same context with the same key. Only a changed context earns a new
// hash; reusing a key with a different body returns 409 conflict.

Step 7 · Keep the sessions — and search them by meaning

Past sessions live in a declared collection named grills. It is the system of record for a grilling, not a log of one round: grilling is multi-round by construction and round N+1 needs round N's settled answers or the frontier never moves, so a record carries the whole session — the plan text, every settled question/answer pair and the latest round — and restoring one puts you back exactly where you stopped. title, understanding, open_questions and settled_summary are the embedded (vector-searchable) fields, so “the one where we argued about the cutover window” finds it without remembering the title. Every where entry must be an operator object ({"eq": …}); a bare value is rejected. Operators: eq ne lt lte gt gte in contains. Records are scoped to the calling subject, and each POST /guest mints a new guest identity, so reuse one token across writes and reads. Documents are capped at 64 KB, and the app trims in reverse order of irreplaceability — the plan can be re-pasted and the round can be re-run, but the user's own answers cannot be regenerated, so plan gives way first, then result, and the record is marked doc_trimmed.

Doc fieldTypeMeaning
titlestringThe plan title, capped at 120 characters. Embedded.
understandingstringThe round's restatement of the plan, capped at 600 characters. Embedded.
open_questionsstringThe open question titles joined with ·, capped at 400 characters. Embedded — this is what “the cutover window one” matches against.
settled_summarystringEach settled pair as question → answer, joined with ·, capped at 600 characters. Embedded.
round, settled_count, open_countnumberWhich round this is, how many decisions are settled, how many questions are still open. Filterable.
doneboolTrue only when the round declared the frontier empty. {"done":{"eq":true}} is the query for finished plans.
ran_atstringISO timestamp. Sort field — {"field":"ran_at","dir":"desc"}.
settledarrayThe full {question, answer} pairs — the irreplaceable part, trimmed last.
resultobjectThe whole normalized round, or null if the document had to be trimmed.
planstringThe plan text, capped at 24 000 characters, cut to 6 000 and then to empty if the doc exceeds the cap.
focusstringWhat to grill hardest on, capped at 200 characters.
metaobjectRun metadata — model, seconds, charged credits. Free-form, not filterable.
doc_trimmedboolPresent and true when the 64 KB cap forced something out. Show it; a silently trimmed session is a lie about what was saved.
Writing records. The query endpoint is POST /collections/grills/query, but the record CRUD paths sit under /recordsnot /collections/grills — and wrap the document in a doc envelope:
POST /collections/grills/records with {"doc": {…}}{"data":{"record":{"record_id":"rec_…"}}}
GET /collections/grills/records/{record_id} · PUT /collections/grills/records/{record_id} · DELETE /collections/grills/records/{record_id}
Semantic search is POST /collections/grills/similar with {"text": "the one where we argued about the cutover window", "limit": 8} — each hit carries a cosine score. It is rate-limited to 30 requests/minute per IP and costs roughly ten times a filtered query, so run it on an explicit action and never on keystrokes, and prefer where whenever an exact match would do. Indexing is asynchronous and only records written after the collection was declared are searchable.
# Create — note /records, and the {doc} envelope.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/grills/records \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"doc":{"title":"Move nightly reporting off the cron box",
              "understanding":"A nightly revenue extract on a 2021 EC2 box…",
              "open_questions":"Which runtime · What has to be true before cutover",
              "settled_summary":"Where does the job run? → ECS Fargate at 02:00 UTC",
              "round":1,"settled_count":0,"open_count":8,"done":false,
              "ran_at":"2026-08-07T12:00:00Z","settled":[],"result":{},
              "plan":"# Move nightly reporting off the cron box…","focus":""}}'
# => {"data":{"record":{"record_id":"rec_..."}}}

# Filtered query: unfinished sessions, newest first.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/grills/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"where":{"done":{"eq":false},"open_count":{"gt":0}},
       "sort":{"field":"ran_at","dir":"desc"},"limit":12}'

# Semantic search over title + understanding + open_questions + settled_summary
# (30/min per IP; ~10x a query — explicit action only):
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/grills/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text":"the one where we argued about the cutover window","limit":8}'
# Create — /records, with the doc wrapped in {"doc": …}
rec = call("/collections/grills/records", body={"doc": {
    "title": result["plan_title"],
    "understanding": result["understanding"],
    "open_questions": " · ".join(q["title"] for q in result["questions"])[:400],
    "settled_summary": " · ".join("%s → %s" % (s["question"], s["answer"])
                                 for s in grill_input["settled"])[:600],
    "round": result["round"],
    "settled_count": len(grill_input["settled"]),
    "open_count": len(result["questions"]),
    "done": result["done"],
    "ran_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    "settled": grill_input["settled"],
    "result": result,
    "plan": grill_input["plan_text"][:24000],
    "focus": grill_input["focus"],
}}, token=token)
print("saved", rec["record"]["record_id"])

res = call("/collections/grills/query", body={
    "where": {"done": {"eq": False}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 12,
}, token=token)
for r in res["records"]:
    d = r["doc"]
    print(d["ran_at"], d["title"], "- round", d["round"], "-", d["open_count"], "open")

hits = call("/collections/grills/similar",
            body={"text": "the one where we argued about the cutover window", "limit": 8},
            token=token)
for r in hits["records"]:
    print("%.2f" % r.get("score", 0), r["doc"]["title"], "-", r["doc"]["open_questions"])
// Create — /records, doc envelope.
const saved = await call("/collections/grills/records", {
  token,
  body: {
    doc: {
      title: result.plan_title,
      understanding: result.understanding,
      open_questions: result.questions.map((q) => q.title).join(" · ").slice(0, 400),
      settled_summary: grillInput.settled
        .map((s) => `${s.question} → ${s.answer}`).join(" · ").slice(0, 600),
      round: result.round,
      settled_count: grillInput.settled.length,
      open_count: result.questions.length,
      done: result.done,
      ran_at: new Date().toISOString(),
      settled: grillInput.settled,
      result,
      plan: grillInput.plan_text.slice(0, 24000),
      focus: grillInput.focus,
    },
  },
});
console.log("saved", saved.record.record_id);

const res = await call("/collections/grills/query", {
  token,
  body: {
    where: { done: { eq: false } },
    sort: { field: "ran_at", dir: "desc" },
    limit: 12,
  },
});
for (const r of res.records) {
  console.log(r.doc.ran_at, r.doc.title, "round", r.doc.round, r.doc.open_count, "open");
}

// Explicit action only — 30/min per IP, roughly 10x the cost of a query.
const hits = await call("/collections/grills/similar", {
  token,
  body: { text: "the one where we argued about the cutover window", limit: 8 },
});
for (const r of hits.records) console.log(r.score, r.doc.title);
// Create: POST /collections/grills/records with the doc envelope.
create := map[string]any{"doc": map[string]any{
    "title":           "Move nightly reporting off the cron box",
    "understanding":   understanding,
    "open_questions":  openQuestions,
    "settled_summary": settledSummary,
    "round":           1,
    "open_count":      8,
    "done":            false,
    "ran_at":          time.Now().UTC().Format(time.RFC3339),
    "settled":         settled,
    "plan":            planText,
}}
var created struct {
    Record struct {
        RecordID string `json:"record_id"`
    } `json:"record"`
}
if err := call("/collections/grills/records", token, create, &created); err != nil {
    panic(err)
}

// Query: an operator object per where field.
query := map[string]any{
    "where": map[string]any{"done": map[string]any{"eq": false}},
    "sort":  map[string]string{"field": "ran_at", "dir": "desc"},
    "limit": 12,
}
var res struct {
    Records []struct {
        RecordID string         `json:"record_id"`
        Doc      map[string]any `json:"doc"`
        Score    float64        `json:"score"`
    } `json:"records"`
}
if err := call("/collections/grills/query", token, query, &res); err != nil {
    panic(err)
}
for _, r := range res.Records {
    fmt.Println(r.Doc["ran_at"], r.Doc["title"], r.Doc["open_count"])
}
// Semantic: POST /collections/grills/similar {"text":"…","limit":8}
// Create — /records, and the document goes inside {"doc": …}
String doc = "{\"doc\":{\"title\":\"Move nightly reporting off the cron box\"," +
             "\"understanding\":\"…\",\"open_questions\":\"Which runtime · …\"," +
             "\"settled_summary\":\"\",\"round\":1,\"open_count\":8,\"done\":false," +
             "\"ran_at\":\"2026-08-07T12:00:00Z\"}}";
System.out.println(call("/collections/grills/records", token, doc));
// => {"data":{"record":{"record_id":"rec_..."}}}

// Query — every where entry is an operator object.
String q = "{\"where\":{\"done\":{\"eq\":false}}," +
           "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":12}";
System.out.println(call("/collections/grills/query", token, q));

// Semantic search: POST /collections/grills/similar {"text":"…","limit":8}
// 30 requests/minute per IP — bind it to a button, never to keystrokes.
# Create — /records, doc envelope.
saved = call("/collections/grills/records", body: { "doc" => {
  "title" => result["plan_title"],
  "understanding" => result["understanding"],
  "open_questions" => result["questions"].map { |q| q["title"] }.join(" · ")[0, 400],
  "settled_summary" => grill_input["settled"]
    .map { |s| "#{s['question']} → #{s['answer']}" }.join(" · ")[0, 600],
  "round" => result["round"],
  "settled_count" => grill_input["settled"].length,
  "open_count" => result["questions"].length,
  "done" => result["done"],
  "ran_at" => Time.now.utc.iso8601,
  "settled" => grill_input["settled"],
  "result" => result,
  "plan" => grill_input["plan_text"][0, 24000],
} }, token: token)
puts "saved #{saved['record']['record_id']}"

res = call("/collections/grills/query", body: {
  "where" => { "done" => { "eq" => false } },
  "sort" => { "field" => "ran_at", "dir" => "desc" },
  "limit" => 12,
}, token: token)
res["records"].each do |rec|
  d = rec["doc"]
  puts "#{d['ran_at']} #{d['title']} - round #{d['round']} - #{d['open_count']} open"
end
// Create — /records, doc envelope.
$saved = call("/collections/grills/records", ["doc" => [
    "title" => $result["plan_title"],
    "understanding" => $result["understanding"],
    "open_questions" => mb_substr(implode(" · ", array_column($result["questions"], "title")), 0, 400),
    "settled_summary" => "",
    "round" => $result["round"],
    "settled_count" => count($grill_input["settled"]),
    "open_count" => count($result["questions"]),
    "done" => $result["done"],
    "ran_at" => gmdate("c"),
    "settled" => $grill_input["settled"],
    "result" => $result,
    "plan" => mb_substr($grill_input["plan_text"], 0, 24000),
]], $token);
echo "saved " . $saved["record"]["record_id"] . "\n";

$res = call("/collections/grills/query", [
    "where" => ["done" => ["eq" => false]],
    "sort" => ["field" => "ran_at", "dir" => "desc"],
    "limit" => 12,
], $token);
foreach ($res["records"] as $rec) {
    $d = $rec["doc"];
    echo "{$d['ran_at']} {$d['title']} - round {$d['round']}\n";
}
// Create — /records, doc envelope.
var saved = await Call("/collections/grills/records", new {
    doc = new {
        title = "Move nightly reporting off the cron box",
        understanding = understanding,
        open_questions = openQuestions,
        settled_summary = settledSummary,
        round = 1,
        settled_count = 0,
        open_count = 8,
        done = false,
        ran_at = DateTime.UtcNow.ToString("o"),
        settled = Array.Empty<object>(),
        plan = planText
    }
}, token);
Console.WriteLine(saved.GetProperty("record").GetProperty("record_id").GetString());

var q = new {
    where = new { done = new { eq = false } },
    sort = new { field = "ran_at", dir = "desc" },
    limit = 12
};
var res = await Call("/collections/grills/query", q, token);
foreach (var rec in res.GetProperty("records").EnumerateArray()) {
    var d = rec.GetProperty("doc");
    Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("title")}");
}
// Semantic: POST /collections/grills/similar {"text":"…","limit":8}

The input schema

These are the exact fields the app submits. The plan is measured locally before the run: a deterministic prescan finds placeholders, vague quantifiers, unresolved choices, undefined acronyms, hedges and untestable commitments, gives each a stable id, and sends them along with the document-level signals a finished plan should carry. That prescan is what the round is reconciled against — a finding neither cited by a question nor dismissed with a reason is reported to the user by id as a gap. A client that computes no prescan may send empty arrays; the round still runs, it simply has nothing to be held to.

FieldTypeMeaning
plan_titlestringA short title for the plan, capped at 120 characters. May be empty — the round writes its own plan_title back either way.
focusstringWhat to grill hardest on, capped at 200 characters, possibly empty — e.g. “the cutover and the rollback path”.
roundnumberWhich round this is, starting at 1. Round N+1 is the same plan with the previous round's answers in settled.
plan_textstringThe plan itself. Oversized plans are clipped from the middle — the head and the tail are both kept, on line boundaries, with the cut announced in-band and the dropped line range named — because a plan carries its framing at the top and its open items, rollout and risks at the bottom, and the bottom is where the unsettled things live.
plan_clippedboolTrue when plan_text is the clipped form. The findings were measured over the whole document, so a finding may cite a line the clipped text does not show — its excerpt travels with it.
settledarray{question, answer} pairs decided in earlier rounds. This is what pushes the frontier outward; an empty array is round 1. Questions cap at 300 characters, answers at 1 200.
prescan.findingsarray{id, kind, line, term, excerpt} per finding, first 120 sent. kind is one of tbd vague choice acronym hedge untestable, and id is kind:n in scan order — tbd:1, vague:3, choice:2, acronym:7, hedge:1, untestable:4. Every id must be accounted for, either cited in a question's prescan_refs or dismissed with a reason.
prescan.signalsarray{id, label, present} for the five document-level signals: numbers, dates, success, owner, risks. A signal measured missing is a real gap to ask about; a signal measured present must not be reported as missing.
prescan.countsobjectA count per finding kind — {tbd, vague, choice, acronym, untestable, hedge}.
prescan.statsobjectlines and words of the plan, plus chars_total and chars_sent — how much plan exists and how much of it travelled. Honest clipping, declared.
current_datetimestringThe caller's local time, weekday included — e.g. 2026-08-07T12:00:00+08:00 (Friday).
retry_notestringOptional, and absent on a first attempt. The app sets it only when a previous reply could not be parsed as the single JSON object the contract requires; it names the parse error and asks for the same round again, correctly formatted. If you drive the API yourself you will not normally send it — and the retry reuses an Idempotency-Key derived from the same context, so a reformat never double-bills.

A complete body

A round-1 grilling of a plan to move a nightly reporting job off an old EC2 box, cut down so the shape is readable. A real prescan of that plan measures dozens of findings; plan_text runs to thousands of characters.

{
  "plan_title": "Move nightly reporting off the cron box",
  "focus": "the cutover and what has to be true before it",
  "round": 1,
  "plan_text": "# Move nightly reporting off the cron box\n\n## Background\n\nOur nightly reporting still runs from a shell script on an EC2 instance someone set up in 2021…\n\n## Proposed approach\n\nEither rewrite it as an Airflow DAG on our existing MWAA environment, or wrap it in a container and run it on ECS with an EventBridge schedule…\n\n## Open items\n\n- Secrets handling: TBD\n- Backfill story: ???\n- On-call: the job should page someone if it fails, but our current RTO for reporting is unclear.",
  "plan_clipped": false,
  "settled": [],
  "prescan": {
    "findings": [
      { "id": "tbd:1", "kind": "tbd", "line": 46, "term": "TBD",
        "excerpt": "- Secrets handling: TBD" },
      { "id": "tbd:2", "kind": "tbd", "line": 48, "term": "???",
        "excerpt": "- Backfill story: ???" },
      { "id": "choice:1", "kind": "choice", "line": 32, "term": "either/or",
        "excerpt": "Either rewrite it as an Airflow DAG on our existing MWAA environment, or wrap it…" },
      { "id": "choice:2", "kind": "choice", "line": 33, "term": "alternatively",
        "excerpt": "Alternatively we could push it into the DBT project that the analytics team owns" },
      { "id": "vague:1", "kind": "vague", "line": 27, "term": "quickly",
        "excerpt": "Move the job somewhere modern and maintainable, quickly." },
      { "id": "vague:2", "kind": "vague", "line": 53, "term": "a while",
        "excerpt": "Run both old and new in parallel for a while, compare outputs, then cut over." },
      { "id": "acronym:1", "kind": "acronym", "line": 21, "term": "EC2",
        "excerpt": "a shell script on an EC2 instance someone set up in 2021" },
      { "id": "acronym:4", "kind": "acronym", "line": 49, "term": "RTO",
        "excerpt": "our current RTO for reporting is unclear" },
      { "id": "hedge:1", "kind": "hedge", "line": 36, "term": "probably",
        "excerpt": "We think the container route is probably simpler." },
      { "id": "untestable:1", "kind": "untestable", "line": 28, "term": "commitment without a measure",
        "excerpt": "The new setup should be more robust and scalable than what we have" }
    ],
    "signals": [
      { "id": "numbers", "label": "Numbers anywhere", "present": true },
      { "id": "dates", "label": "A date or deadline", "present": false },
      { "id": "success", "label": "Success criteria", "present": false },
      { "id": "owner", "label": "An owner", "present": false },
      { "id": "risks", "label": "Risks or fallbacks", "present": true }
    ],
    "counts": { "tbd": 2, "vague": 9, "choice": 3, "acronym": 7, "untestable": 4, "hedge": 5 },
    "stats": { "lines": 54, "words": 341, "chars_total": 2187, "chars_sent": 2187 }
  },
  "current_datetime": "2026-08-07T12:00:00+08:00 (Friday)"
}
The finding ids are the contract's hinge. They are stable for a given text (kind:n in scan order), they travel verbatim, and the round is expected to cite them verbatim — prescan_refs: ["choice:1", "vague:2"]. An invented id (vague:99 when the prescan measured nine) fails the check just as loudly as an unaccounted one, so never renumber them client-side.

The output contract

The reply is one JSON object and nothing else. Parse defensively anyway: strip a stray code fence, take the span from the first { to the matching last } — which is exactly what the app's parseJsonText does — and re-ask once with the same idempotency seed, a bumped attempt counter and a retry_note if it does not parse. These are the fields the app's own normalizer requires, and the constraints it enforces.

FieldConstraint
plan_titleA short title for the plan, taken from the text. Trimmed; may be empty.
understandingRequired. A missing or empty understanding is a hard parse failure — the whole reply is rejected. One paragraph restating the plan concretely; when done is true it becomes the statement of shared understanding.
roundNumber. Falls back to the client's own round counter when absent or non-numeric.
doneBoolean. True only when the frontier is empty. done: true alongside a non-empty questions array is downgraded to false, the questions are shown as open, and the contradiction is reported as a failed check — not silently corrected.
questions[].idq1, q2, … unique within the round. A missing id is filled in positionally; a duplicate id is suffixed with its index (q3q3-5) so two questions can never collide.
questions[].titleFive to nine words. Falls back to Question N when empty.
questions[].bodyThe question itself, possibly several sentences, laying out what is actually at stake in the answer. No compound questions — two answers means two questions.
questions[].recommendationWhat you would do, concretely, and the one-line reason. Required on every question; a round with a recommendation missing fails the check.
questions[].why_nowWhich already-settled prerequisites make this question askable this round.
questions[].depends_onArray of ids from earlier rounds (round-prefixed, e.g. r1q3). Naming a question asked in the same round is a frontier violation and is reported as one.
questions[].prescan_refsArray of finding ids from the prescan, verbatim. Strings only; blanks are dropped.
questions[].severityOne of blocking, important, minor. Anything else defaults to important — matching is case-insensitive and trimmed, so Blocking is fine but critical becomes important.
deferred[]{title, blocked_by[]}. Questions you can already see coming that depend on this round's answers; blocked_by names ids from this round. Entries without a title are dropped. [] when none.
verify_yourself[]{fact, how}. Environment facts to go and check — never asked of the user as if they were decisions. Entries without a fact are dropped. [] when the plan needs none.
prescan_coverage.addressed[]Finding ids settled by a question this round.
prescan_coverage.dismissed[]{id, reason} for findings that are genuinely fine as written or are noise. Entries without an id are dropped; the reason is what the user reads.
settled_recap[]{question, answer} echoing the earlier-round decisions this round builds on — how the user sees that their answers landed. Empty on round 1; entries without a question are dropped.

A complete reply

{
  "plan_title": "Move nightly reporting off the cron box",
  "understanding": "You have a nightly revenue extract that runs as a shell script on an EC2 instance stood up in 2021, outside IaC, on an AMI past its support window; it reads a dozen-table join from the prod replica and emails finance a CSV. You want it moved onto something maintained, and you are weighing an Airflow DAG on your existing MWAA, a container on ECS with an EventBridge schedule, or folding it into the analytics team's DBT project. Some of the transformation logic lives in the shell script rather than in SQL and would have to be ported. Secrets handling, the backfill story, whether finance keeps the emailed CSV, and the recovery target if a run fails are all recorded in the plan as open.",
  "round": 1,
  "done": false,
  "questions": [
    {
      "id": "q1",
      "title": "Which runtime: MWAA, ECS, or the analytics project?",
      "body": "The plan names three targets and commits to none. They are not equivalent: an Airflow DAG on MWAA gives you retries, backfill and a UI for free but puts this job's fate in an environment another team's DAGs share; ECS plus EventBridge is one task on one schedule with nothing to learn but nothing given to you either; folding it into the analytics project moves ownership to a team the plan itself says has a lot on right now.",
      "recommendation": "ECS Fargate with an EventBridge schedule. This is one task on one cron with no fan-out and no inter-task dependencies, which is the shape an orchestrator earns its keep against and yours does not. Revisit MWAA the first time this job grows a second dependent step.",
      "why_now": "Nothing is settled yet, so the root of the design tree is the whole frontier's prerequisite — and it is askable immediately, because the plan already lays out the three candidates.",
      "depends_on": [],
      "prescan_refs": ["choice:1", "choice:2", "hedge:1", "untestable:1"],
      "severity": "blocking"
    },
    {
      "id": "q2",
      "title": "What has to be true before you cut over?",
      "body": "The plan asks for something more robust and significantly easier to debug, and says to run both in parallel for a while and compare outputs — but none of those is a thing you can check on a Tuesday morning and get a yes or no from. How many parallel days, compared how, and who looks at the comparison and says go?",
      "recommendation": "Fourteen consecutive days where the new CSV is byte-identical to the old one after normalising row order, checked by an automated diff rather than a person eyeballing it. Fourteen days catches a month-end boundary, which is where a revenue extract is most likely to diverge.",
      "why_now": "The parity bar is a property of the output, not of the scheduler, so it does not wait on q1.",
      "depends_on": [],
      "prescan_refs": ["vague:1", "vague:2", "tbd:2"],
      "severity": "blocking"
    }
  ],
  "deferred": [
    { "title": "How the job is deployed and by which pipeline", "blocked_by": ["q1"] },
    { "title": "Where the parallel-run comparison executes, and who is paged when it diverges", "blocked_by": ["q1", "q2"] }
  ],
  "verify_yourself": [
    { "fact": "What the shell script actually does beyond running the SQL",
      "how": "Read it end to end on the instance and write down each transformation that is not in the query — this is the main unknown in the plan's size." },
    { "fact": "Whether the replica lags at month end, and by how much",
      "how": "Your replication-lag metric for the last two month-end boundaries — it decides whether the job needs a freshness assertion before it emits a number." }
  ],
  "prescan_coverage": {
    "addressed": ["tbd:1", "tbd:2", "choice:1", "choice:2", "vague:1", "vague:2", "hedge:1", "untestable:1"],
    "dismissed": [
      { "id": "acronym:1", "reason": "EC2 is universally understood in this context; what is unsettled is the AMI version behind it, which is a verify_yourself item." },
      { "id": "acronym:4", "reason": "RTO is expanded nowhere, but the plan already flags the recovery target as unclear, so q4 settles the number rather than the term." }
    ]
  },
  "settled_recap": []
}
The round is checked, not trusted. Three failures are worth guarding against in your own pipeline because they are the ones this method exists to prevent. Frontier violations: a question whose depends_on names another question asked in the same round belongs to a later round — it should have been a deferred entry with blocked_by. Unaccounted findings: every prescan id must appear in prescan_coverage.addressed, in some question's prescan_refs, or in dismissed with a reason; silence is the failure mode the ids exist to make visible. A contradictory finish: done: true with open questions is not a finished session, and the client downgrades it rather than presenting both.

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

The prescan ships with the app as grillkit.js and calls no network: the ambiguity analyzer (placeholders, vague quantifiers, unresolved choices, undefined acronyms, hedges, untestable commitments, each with a stable kind:n id, a line number and an excerpt), the five document-level signal checks, the round validator that re-checks a reply against the same prescan — frontier discipline, a recommendation on every question, finding coverage by id, no invented ids, no done-with-questions — and a summarizer that rolls the findings into a brief. It exposes window.GrillKit.analyze(text), validate(result, prescan), summarize(entries), brief(prescan), and the VAGUE_TERMS and HEDGES vocabularies it matches on. A pipeline can measure a plan, and check every round it gets back — or rounds it wrote itself — without spending anything.

The logic is string work with no I/O: stub a window object and the module loads under Node directly. The same plan always analyzes to the same findings with the same ids, and the same round always validates to the same entries, so a CI job can gate a design-doc repo on “no unaccounted findings and no frontier violations” — or simply on a plan whose prescan comes back clean before a human is asked to read it.