Close Desk — API

Read a month-end close from your close-management tool, your ERP export or a cron job.

API tokens Open the app

Drive Close Desk from your own code

Everything the browser computes — the business-day close calendar, the dependency level of every task, what is blocked and how much it holds up, what is past deadline and by how many business days, what was completed ahead of its own inputs, the canonical checklist coverage, the reconciling items against materiality, the owner load per close day and the close posture reached by rule — is deterministic, free and runs client-side. The API surface here is the metered judgement pass on top of it. Every request goes to https://api.skillsafe.ai/v1/app-api and carries a bearer token.

Base URL, envelope and errors

Every response is a JSON envelope. Success carries data; failure carries error with a stable code. Nothing else appears at the top level, so a client can branch on the presence of error alone.

POST /v1/app-api/guest GET /v1/app-api/me POST /v1/app-api/estimate POST /v1/app-api/run GET /v1/app-api/jobs/{id} POST /v1/app-api/run-stream
{ "ok": true,  "data":  { "...": "..." } }
{ "ok": false, "error": { "code": "validation_error", "message": "...", "details": { } } }
CodeHTTPWhat it meansWhat to do
unauthorized401No token, an expired token, or a token minted for another app.Mint a guest token or sign in; see step 1.
payment_required402The balance is below min_credits for this run.Top up, or use a sponsored run. /estimate is free, so check before you submit.
validation_error400The input object is not the shape the app declares — or the guest slug was sent as a header instead of in the body.Compare against the input object below; put slug in the JSON body.
not_found404Unknown path, or a job_id that belongs to another subject. Every /guest call mints a new subject.Poll with the same token that created the job.
rate_limited429Too many requests in the window.Back off and retry with growing delays; do not tight-loop a poll.
internal_error500The server or the model provider failed part-way.Retry with the SAME Idempotency-Key so a partial charge is not repeated.

1. Get a token

Two ways, and neither of them involves a developer console. Scripted: POST /guest mints a guest token for this app; the slug goes in the body as {"slug":"close-desk"} — an X-App-Slug header returns 400. Personal: open the token page, which shows the token this browser holds and copies a ready-made export SKILLSAFE_TOKEN=... line for you. A guest can call /me and /estimate; billing a metered run to your account needs the personal token.

# Scripted: a guest token, no browser and no account.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"close-desk"}'
# -> { "ok": true, "data": { "token": "...", "subject_type": "guest" } }

# Personal: open /tokens.html, press "Copy shell export", paste the line.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"
import json, os, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")

if not TOKEN:                                   # fall back to a scripted guest
    req = urllib.request.Request(
        BASE + "/guest",
        data=json.dumps({"slug": "close-desk"}).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req) as r:
        TOKEN = json.load(r)["data"]["token"]
// Keep the token in a constant your build or your secret store injects.
// The token page at /tokens.html will show you yours and copy it for you --
// there is never a reason to fish one out of a developer console.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";

// Or mint a scripted guest token, which can call /me and /estimate:
async function guestToken() {
  const res = await fetch(BASE + "/guest", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ slug: "close-desk" })
  });
  const env = await res.json();
  if (!env.ok) throw new Error(env.error.code);
  return env.data.token;
}
package main

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

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

func token() string {
    if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
        return t
    }
    res, err := http.Post(base+"/guest", "application/json",
        bytes.NewBufferString(`{"slug":"close-desk"}`))
    if err != nil {
        return ""
    }
    defer res.Body.Close()
    var env struct {
        Data struct {
            Token string `json:"token"`
        } `json:"data"`
    }
    json.NewDecoder(res.Body).Decode(&env)
    return env.Data.Token
}
import java.net.URI;
import java.net.http.*;

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

    static String token() throws Exception {
        String t = System.getenv("SKILLSAFE_TOKEN");
        if (t != null && !t.isEmpty()) return t;

        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(BASE + "/guest"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"close-desk\"}"))
            .build();
        return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
        // an envelope: read data.token with the JSON library you already use
    }
}
require "json"
require "net/http"

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

TOKEN = ENV["SKILLSAFE_TOKEN"] || begin
  res = Net::HTTP.post(URI(BASE + "/guest"),
                       JSON.dump({ slug: "close-desk" }),
                       "Content-Type" => "application/json")
  JSON.parse(res.body)["data"]["token"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";

$token = getenv("SKILLSAFE_TOKEN");
if (!$token) {                                  // scripted guest fallback
    $ch = curl_init(BASE . "/guest");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
        CURLOPT_POSTFIELDS     => json_encode(["slug" => "close-desk"]),
    ]);
    $token = json_decode(curl_exec($ch), true)["data"]["token"];
    curl_close($ch);
}
using System.Text;
using System.Text.Json;

var BASE = "https://api.skillsafe.ai/v1/app-api";
var TOKEN = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");

if (string.IsNullOrEmpty(TOKEN))                 // scripted guest fallback
{
    var body = new StringContent("{\"slug\":\"close-desk\"}", Encoding.UTF8, "application/json");
    var res = await new HttpClient().PostAsync(BASE + "/guest", body);
    var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    TOKEN = env.GetProperty("data").GetProperty("token").GetString();
}
Treat the token like a password. Anyone holding it can spend its credits through this app. Keep it in an environment variable or a secret store, never in a committed file, and never ask a user to read one out of their browser's developer tools.

2. A tiny client

Every call is the same three lines: a bearer token, a JSON body, and the envelope unwrapped. Write it once and the rest of this page is one-liners.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"

call() {   # call <path> <json-body>
  curl -sS -X POST "$BASE$1" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$2"
}

get() {    # get <path>
  curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
}
def call(path, body=None, method="POST"):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        env = json.load(r)
    if not env.get("ok"):
        raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
    return env["data"]
async function call(path, body, method = "POST") {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": `Bearer ${TOKEN}`,
      "Content-Type": "application/json"
    },
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const env = await res.json();
  if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
  return env.data;
}
import (
    "errors"
    "io"
)

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

func call(method, path string, body any) (json.RawMessage, error) {
    var r io.Reader
    if body != nil {
        b, _ := json.Marshal(body)
        r = bytes.NewReader(b)
    }
    req, _ := http.NewRequest(method, base+path, r)
    req.Header.Set("Authorization", "Bearer "+token())
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    var env envelope
    if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
        return nil, err
    }
    if !env.OK {
        return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
    }
    return env.Data, nil
}
static String call(String path, String jsonBody) throws Exception {
    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create(BASE + path))
        .header("Authorization", "Bearer " + token())
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
        .build();
    HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
    return res.body();   // a JSON envelope: { ok, data } or { ok, error }
}

static String get(String path) throws Exception {
    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create(BASE + path))
        .header("Authorization", "Bearer " + token())
        .GET()
        .build();
    return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
def call(path, body = nil, method = :post)
  uri = URI(BASE + path)
  klass = method == :get ? Net::HTTP::Get : Net::HTTP::Post
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  env = JSON.parse(res.body)
  raise "#{env["error"]["code"]}: #{env["error"]["message"]}" unless env["ok"]
  env["data"]
end
<?php
function call(string $path, ?array $body = null, string $method = "POST") {
    global $token;
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . $token,
            "Content-Type: application/json",
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $env = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($env["ok"])) {
        throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
    }
    return $env["data"];
}
using System.Net.Http.Headers;

var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);

async Task<JsonElement> Call(string path, object? body = null)
{
    var content = new StringContent(JsonSerializer.Serialize(body ?? new { }),
                                    Encoding.UTF8, "application/json");
    var res = await http.PostAsync(BASE + path, content);
    var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!env.GetProperty("ok").GetBoolean())
        throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
    return env.GetProperty("data");
}

async Task<JsonElement> Get(string path)
{
    var res = await http.GetAsync(BASE + path);
    var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!env.GetProperty("ok").GetBoolean())
        throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
    return env.GetProperty("data");
}

3. Who am I, and what is the balance

GET /me is free and is what the app uses for its credit preflight: compare credits against the min_credits that /estimate returns and refuse to submit rather than collecting a 402 afterwards. subject_type is guest or user; a guest balance of zero is normal and expected.

get /me
# -> { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
    log.Fatal(err)
}
var me struct {
    SubjectType string `json:"subject_type"`
    Credits     int    `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(get("/me"));
// { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", nil, :get)
puts me["subject_type"], me["credits"]
<?php
$me = call("/me", null, "GET");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Get("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());

The input object

This is what goes in the input field of /estimate, /run and /run-stream. The facts object is the whole client-side measurement, and it is what makes the model accountable: it is the authority on every number, and the app re-checks the reply against it afterwards. If you are driving the API without running the browser engine, send whatever of facts you can compute — but understand that the model is instructed to return exactly one review per entry in facts.tasks_for_review, so an empty list yields an empty review.

{
  "entity": "single-entity | multi-entity | multi-currency | public-filer | nonprofit",
  "note": "optional free text steering the pack, e.g. the audit committee reads this Thursday",
  "period_block":   "the key: value period block, verbatim",
  "tasks_excerpt":  "the close task table, cut on WHOLE ROWS with a marker if it was long",
  "issues_excerpt": "the reconciling-item table, clipped the same way",
  "facts": {
    "entity": "single-entity",
    "period": "March 2026",
    "period_end": "2026-03-31",
    "as_of": "2026-04-06",
    "as_of_day": "T+3",
    "calendar_mode": "standard | accelerated",
    "target_days": 5,
    "min_days_required": 6,
    "target_feasible": false,
    "projected_hard_close_day": "T+8",
    "calendar": [
      { "day": "T+1", "date": "2026-04-01", "weekday": "Wed",
        "stages": [ "Post the source entries" ] }
    ],
    "counts": { "total": 32, "complete": 18, "in-progress": 6,
                "not-started": 5, "blocked": 2, "at-risk": 1 },
    "pct_complete": 56,
    "levels": [ { "level": 2, "name": "Reconcile the subledgers",
                  "total": 7, "complete": 4, "open": 3 } ],
    "blocked": [
      { "id": "t14", "task": "Complete bank reconciliation with the final statement",
        "owner": "T. Alvarez", "due_day": "T+2",
        "blocker": "waiting on the final bank statement",
        "downstream_count": 6, "downstream": [ "t19", "t23", "t28" ],
        "known_cause": "the statement lands after the deadline that depends on it",
        "known_fix": "reconcile continuously through the month" }
    ],
    "late": [ { "id": "t21", "task": "Revenue recognition adjustments", "owner": "K. Nakamura",
                "due_day": "T+3", "due_date": "2026-04-03", "days_late": 2 } ],
    "at_risk": [ { "id": "t26", "task": "Intercompany elimination", "owner": "L. Ferreira",
                   "due_day": "T+4", "due_date": "2026-04-06" } ],
    "out_of_sequence": [ { "id": "t28", "task": "Draft the trial balance", "level": 4,
                           "blocking_count": 3, "blocking": [ "t14", "t19", "t21" ] } ],
    "owner_load": [ { "owner": "T. Alvarez", "open": 5, "by_day": { "T+3": 3, "T+4": 2 },
                      "peak_day": "T+3", "peak": 3, "levels": [ 1, 2 ],
                      "bottleneck": false } ],
    "missing_activities": [ { "id": "cutoff", "activity": "Confirm cut-off with AP, AR and payroll",
                              "level": 0, "level_name": "Pre-close", "day": "T+0",
                              "date": "2026-03-31", "required": true } ],
    "reconciling_items": [ { "id": "e1", "item": "Unidentified cash receipt", "account": "1010 Cash",
                             "amount": 41250, "age_days": 46, "owner": "T. Alvarez",
                             "status": "in-progress", "over_materiality": true, "aged": true } ],
    "materiality": 25000,
    "posture_by_rule": { "key": "at-risk", "label": "At risk",
                         "reasons": [ "2 blocked tasks; the worst holds up 6 downstream tasks." ] },
    "tasks_for_review": [
      { "id": "t14", "task": "Complete bank reconciliation with the final statement",
        "owner": "T. Alvarez", "level": 2, "level_name": "Reconcile the subledgers",
        "due_day": "T+2", "state": "blocked", "status": "Blocked",
        "blocker": "waiting on the final bank statement",
        "why": "blocked, holding up 6 downstream tasks" }
    ],
    "checks": [ { "key": "feasibility", "state": "pass | warn | fail",
                  "label": "The task set fits the close target", "detail": "..." } ],
    "gaps": [ "string" ],
    "warnings": [ "string" ]
  },
  "current_datetime": "2026-06-10T09:14:00+01:00",
  "retry_note": "optional - present only on the app's one reformat retry"
}

The facts fields that carry the weight

FieldShapeWhat it is
period, period_endstringThe period label and its ISO end date. Every close day is counted forward from here.
as_of, as_of_daystringToday, and which close day that is (T+3). Lateness is measured against it.
calendar_modestandard | acceleratedOne dependency stage per business day, or two on an accelerated close.
target_days, min_days_required, target_feasiblenumber, number, booleanThe declared target against the minimum the dependency map allows. When target_feasible is false the target is the defect, not the team.
projected_hard_close_day"T+n"Where the close actually lands given what is already late.
calendar[]day, date, weekday, stages[]The business-day calendar, holidays removed. The only legal source of dates.
counts, pct_complete, levels[]object, number, arrayTask counts by status, completion percentage, and the six dependency levels with their totals.
blocked[]id, task, owner, due_day, blocker, downstream_count, downstream[], known_cause, known_fixWhat is blocked and exactly how much it holds up. downstream_count is what severity is judged on. known_cause / known_fix appear when the blocker matches a catalogued close bottleneck.
late[]id, task, owner, due_day, due_date, days_latePast deadline, in business days, as of as_of.
at_risk[]arrayDue today and not started.
out_of_sequence[]id, level, blocking_count, blocking[]Completed while its own inputs were still open — numbers computed on figures that have since moved.
owner_load[]owner, open, by_day, peak_day, peak, bottleneckOpen work per owner per close day, and whether one person is a bottleneck.
missing_activities[]activity, level, day, requiredCanonical close activities with no task at all. required: true is a coverage failure, not a note.
reconciling_items[], materialityarray, numberOpen items with over_materiality and aged already decided. At or above materiality belongs in the escalation; below it, in the retro.
posture_by_rulekey, label, reasons[]The posture the rule reached from these facts. Shown beside the model's, never merged with it.
tasks_for_review[]id, task, owner, level, level_name, due_day, state, status, blocker, whyThe exact set the reply must review, one entry each. Capped at 12, built from blocked, then late, then out_of_sequence, then at_risk, de-duplicated by id.
checks[], gaps[], warnings[]arraysWhat the engine already asserted about the close, and what it could not read from what was pasted.
A close that is completely healthy produces an empty tasks_for_review. That is not an error and it is not a reason to invent something to review: the correct reply then has an empty critical_reviews and says so in posture_note.

4. Estimate first — it is free

/estimate creates no job, starts no run and charges nothing. Post the same {"input": ...} body you will send to /run.

FieldMeaning
modelThe resolved model that will serve the run.
model_aliasThe alias the app asked for; the resolved model can move under it.
markup_bpsThe app's markup in basis points, applied on top of provider cost.
hold_creditsWhat will be reserved, priced against the full output cap.
min_creditsThe floor. Below this the run is refused with payment_required.
sponsor_enabledTrue when the app owner sponsors runs, so a subject with no balance can still run.

Hold is not price. The hold is a reservation against the worst case; the settled charged_credits on the finished job is usually far lower, and the difference is released. If the balance sits between min_credits and hold_credits the run still executes with a reduced output cap and comes back with "truncated": true.

call /estimate "$(cat body.json)"
# body.json is { "input": { ...the input object... } }
# -> { "ok": true, "data": { "model": "gpt-5.6-terra", "model_alias": "gpt-terra",
#                            "markup_bps": 1000, "hold_credits": 2400,
#                            "min_credits": 140, "sponsor_enabled": false } }
payload = {"input": app_input}          # app_input is the object above

est = call("/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"], est["sponsor_enabled"])
if not est["sponsor_enabled"] and me["credits"] < est["min_credits"]:
    raise SystemExit("top up before running")
print("reserving up to", est["hold_credits"], "credits")
const payload = { input: appInput };

const est = await call("/estimate", payload);
if (!est.sponsor_enabled && me.credits < est.min_credits) {
  throw new Error("top up before running");
}
console.log(`reserving up to ${est.hold_credits} credits on ${est.model}`);
payload := map[string]any{"input": appInput}

data, err := call("POST", "/estimate", payload)
if err != nil {
    log.Fatal(err)
}
var est struct {
    Model          string `json:"model"`
    ModelAlias     string `json:"model_alias"`
    MarkupBps      int    `json:"markup_bps"`
    HoldCredits    int    `json:"hold_credits"`
    MinCredits     int    `json:"min_credits"`
    SponsorEnabled bool   `json:"sponsor_enabled"`
}
json.Unmarshal(data, &est)
if !est.SponsorEnabled && me.Credits < est.MinCredits {
    log.Fatal("top up before running")
}
String est = call("/estimate", payloadJson);
System.out.println(est);
// data: model, model_alias, markup_bps, hold_credits, min_credits, sponsor_enabled
payload = { input: app_input }

est = call("/estimate", payload)
abort("top up before running") if !est["sponsor_enabled"] && me["credits"] < est["min_credits"]
puts "reserving up to #{est["hold_credits"]} on #{est["model"]}"
<?php
$payload = ["input" => $appInput];

$est = call("/estimate", $payload);
if (!$est["sponsor_enabled"] && $me["credits"] < $est["min_credits"]) {
    exit("top up before running\n");
}
echo "reserving up to {$est["hold_credits"]} on {$est["model"]}\n";
var payload = new { input = appInput };

var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("model").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
if (!est.GetProperty("sponsor_enabled").GetBoolean() &&
    me.GetProperty("credits").GetInt32() < est.GetProperty("min_credits").GetInt32())
{
    throw new Exception("top up before running");
}

5. Run it — with an Idempotency-Key

POST /run returns a job immediately; poll GET /jobs/{id} until status is succeeded or failed. The model's object arrives as a string in output.output, so parse it.

A retry must reuse the same key. The app derives close-desk:<fnv1a-of-input>:<length>:a<attempt>, and its one automatic reformat retry reuses a key derived from the same input, so a malformed first reply can never double-bill. Do the same: a timeout, a dropped connection or a 500 that makes you retry must not become a second charge. Only change the attempt suffix when you are deliberately asking for a new answer.

KEY="close-desk:9f31c0ab:2184:a1"

curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data @body.json
# -> { "ok": true, "data": { "job_id": "job_...", "status": "queued" } }

get /jobs/job_xxxxxxxx
# poll until status is "succeeded" or "failed"; the pack is data.output.output
import time

KEY = "close-desk:9f31c0ab:2184:a1"

req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", KEY)          # reuse this exact key on any retry
with urllib.request.urlopen(req) as r:
    job = json.load(r)["data"]

while True:
    j = call("/jobs/" + job["job_id"], method="GET")
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if j["status"] == "failed":
    raise SystemExit(j.get("error", "the run failed"))

result = json.loads(j["output"]["output"])
print(result["posture"], "-", len(result["critical_reviews"]), "reviews")
const KEY = "close-desk:9f31c0ab:2184:a1";

const job = await fetch(`${BASE}/run`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": KEY          // reuse this exact key on any retry
  },
  body: JSON.stringify(payload)
}).then(r => r.json()).then(e => {
  if (!e.ok) throw new Error(e.error.code);
  return e.data;
});

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

if (j.status === "failed") throw new Error("the run failed");
const result = JSON.parse(j.output.output);
console.log(result.posture, result.critical_reviews.length);
const key = "close-desk:9f31c0ab:2184:a1"

b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // reuse this exact key on any retry
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var env struct {
    Data struct {
        JobID string `json:"job_id"`
    } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)

for {
    d, err := call("GET", "/jobs/"+env.Data.JobID, nil)
    if err != nil {
        log.Fatal(err)
    }
    var j struct {
        Status string `json:"status"`
        Output struct {
            Output string `json:"output"`
        } `json:"output"`
    }
    json.Unmarshal(d, &j)
    if j.Status == "succeeded" || j.Status == "failed" {
        // json.Unmarshal([]byte(j.Output.Output), &pack)
        break
    }
    time.Sleep(1500 * time.Millisecond)
}
String KEY = "close-desk:9f31c0ab:2184:a1";

HttpRequest run = HttpRequest.newBuilder()
    .uri(URI.create(BASE + "/run"))
    .header("Authorization", "Bearer " + token())
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", KEY)      // reuse this exact key on any retry
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();
String job = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();

// then poll GET /jobs/{job_id} until status is succeeded or failed,
// and parse data.output.output -- it is the pack as a JSON *string*.
String polled = get("/jobs/" + jobId);
KEY = "close-desk:9f31c0ab:2184:a1"

uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = KEY            # reuse this exact key on any retry
req.body = JSON.dump(payload)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]

j = nil
loop do
  j = call("/jobs/#{job["job_id"]}", nil, :get)
  break if %w[succeeded failed].include?(j["status"])
  sleep 1.5
end
abort("the run failed") if j["status"] == "failed"

result = JSON.parse(j["output"]["output"])
puts "#{result["posture"]} - #{result["critical_reviews"].length} reviews"
<?php
$key = "close-desk:9f31c0ab:2184:a1";

$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        "Authorization: Bearer " . $token,
        "Content-Type: application/json",
        "Idempotency-Key: " . $key,     // reuse this exact key on any retry
    ],
    CURLOPT_POSTFIELDS     => json_encode($payload),
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);

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

if ($j["status"] === "failed") {
    exit("the run failed\n");
}
$result = json_decode($j["output"]["output"], true);
var key = "close-desk:9f31c0ab:2184:a1";

var runMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run")
{
    Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
runMsg.Headers.Add("Idempotency-Key", key);   // reuse this exact key on any retry
var runRes = await http.SendAsync(runMsg);
var jobId = JsonDocument.Parse(await runRes.Content.ReadAsStringAsync())
    .RootElement.GetProperty("data").GetProperty("job_id").GetString();

JsonElement j;
string status;
do
{
    await Task.Delay(1500);
    j = await Get("/jobs/" + jobId);
    status = j.GetProperty("status").GetString()!;
} while (status != "succeeded" && status != "failed");

if (status == "failed") throw new Exception("the run failed");
var result = JsonDocument.Parse(
    j.GetProperty("output").GetProperty("output").GetString()!).RootElement;

6. Streaming, if you want the progress

POST /run-stream is the same request with a server-sent-event response. Frame names arrive on the event: line and the payload on data:; concatenating every delta text gives you the same JSON string that output.output would have held. The app uses this to advance its staged progress card off real signals — each top-level field name appearing in the stream moves it on. The same Idempotency-Key discipline applies.

curl -N -sS -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data @body.json

# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Northwind March close"}
# event: delta
# data: {"text":" - held by one bank statement\",\"posture\":\"at-risk\""}
# event: done
# data: {"status":"succeeded","charged_credits":734,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", KEY)

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()
        elif line.startswith("data:") and event == "delta":
            raw += json.loads(line[5:])["text"]

result = json.loads(raw)
const res = await fetch(`${BASE}/run-stream`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": KEY
  },
  body: JSON.stringify(payload)
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
  const { done, value } = 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();
    else if (line.startsWith("data:") && event === "delta") raw += JSON.parse(line.slice(5)).text;
  }
}
const result = JSON.parse(raw);
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
    line := sc.Text()
    switch {
    case strings.HasPrefix(line, "event:"):
        event = strings.TrimSpace(line[6:])
    case strings.HasPrefix(line, "data:") && event == "delta":
        var d struct{ Text string }
        json.Unmarshal([]byte(line[5:]), &d)
        raw += d.Text
    }
}
// raw is now the pack as JSON text
HttpRequest stream = HttpRequest.newBuilder()
    .uri(URI.create(BASE + "/run-stream"))
    .header("Authorization", "Bearer " + token())
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", KEY)
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();

StringBuilder raw = new StringBuilder();
String[] evt = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines())
    .body()
    .forEach(l -> {
        if (l.startsWith("event:")) evt[0] = l.substring(6).trim();
        else if (l.startsWith("data:") && "delta".equals(evt[0]))
            raw.append(textOf(l.substring(5)));   // your JSON reader, field "text"
    });
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = KEY
req.body = JSON.dump(payload)

raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        event = line[6..].strip if line.start_with?("event:")
        raw << JSON.parse(line[5..])["text"] if line.start_with?("data:") && event == "delta"
      end
    end
  end
end
result = JSON.parse(raw)
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . $token,
        "Content-Type: application/json",
        "Idempotency-Key: " . $key,
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:") && $event === "delta") {
                $raw .= json_decode(substr($line, 5), true)["text"] ?? "";
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
$result = json_decode($raw, true);
var streamMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run-stream")
{
    Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
streamMsg.Headers.Add("Idempotency-Key", key);

var streamRes = await http.SendAsync(streamMsg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:") && evt == "delta")
        raw.Append(JsonDocument.Parse(line[5..]).RootElement.GetProperty("text").GetString());
}
var result = JsonDocument.Parse(raw.ToString()).RootElement;

The output contract

One strict JSON object, no fence and no prose around it. Entries with no task_id are dropped; an unrecognised severity falls back to medium; a posture outside the four legal keys is left blank rather than guessed at, so a malformed pack is reported instead of quietly normalised into agreement.

{
  "title": "Northwind Foods, March 2026 - held at T+3 by one bank statement",
  "posture": "on-track | at-risk | slipping | blocked",
  "posture_note": "3-5 sentences a controller could paste at the top of the status mail",
  "posture_rationale": "why that posture, and where it agrees or disagrees with facts.posture_by_rule",
  "critical_reviews": [
    {
      "task_id": "t14",
      "severity": "high | medium | low",
      "impact": "what this specifically costs the close, in days and in downstream work",
      "resolution": "the concrete next action that clears it",
      "owner_ask": "what to ask of whom, by when, phrased as it would be said"
    }
  ],
  "resequencing":          [ "each move naming the task and the close day it moves to" ],
  "standup_agenda":        [ "items for the 15-minute daily standup, in the order to raise them" ],
  "escalation":            "a short, ready-to-send note that states the ask, not just the status",
  "automation_candidates": [ "what to automate or move earlier before next month" ],
  "retro_items":           [ "retrospective points tied to what actually happened this close" ],
  "unverified":            [ "what could not be confirmed from the inputs supplied" ]
}

What the app asserts about that reply

These are counted, not sampled, and the results are rendered next to the model's own words. Step 7 reproduces the first and the fifth client-side.

CheckAssertionFails when
partitioncritical_reviews holds exactly one entry per id in facts.tasks_for_review — no more, no fewer, no others. Missing, duplicated and invented ids are counted and reported separately.An id is left unreviewed, reviewed twice, or points at a task the engine never flagged.
severitySeverity is compared against the measured downstream_count and days_late: high at three or more downstream or two or more days late, medium at one or two downstream or one day late, low only where neither applies. Erring one step high is tolerated; erring low is not.A task holding up the close is reviewed as low, or a task holding up nothing is reviewed as high.
postureThe pack's posture is shown beside the rule's, never merged into it.Warns when they differ, or when no legal posture was stated at all.
ownersEvery person named in the escalation, the resequencing, the standup agenda or an owner_ask is an owner in the pasted task list. Roles are fine; invented names are not.A name appears that owns no task.
datesEvery T+n and every ISO date cited exists in facts.calendar (plus period_end and as_of). T+6 does not exist on a five-day close.The pack cites a day off the end of the calendar, or a date that is not a close day.
stateNo task the facts say is complete is reviewed as an open problem, unless it appears in out_of_sequence.A finished task is written up as a blocker.
escalationThe escalation contains an actual ask — what is needed, from whom, by when.Warns when it reads as a status update with no request in it.

7. Parse and check the reply

The app never trusts the pack it just paid for, and neither should your script. Two of the checks are pure arithmetic against the same facts you posted, so they are worth reproducing wherever the run happens: the partition — every id in facts.tasks_for_review reviewed exactly once, nothing missing, nothing duplicated, nothing invented — and the calendar — no T+n and no date cited that is not a real day on this close.

# bundle.json = { "facts": {...as posted...}, "result": {...the parsed pack...} }
jq '
  (.facts.tasks_for_review | map(.id))          as $want
| (.result.critical_reviews | map(.task_id))     as $got
| (.facts.calendar | map(.day | ascii_upcase))   as $days
| ( [ .result.escalation ]
    + .result.resequencing
    + .result.standup_agenda
    + (.result.critical_reviews | map(.owner_ask + " " + .resolution))
    | join(" ") | ascii_upcase )                 as $text
| { missing:      ($want - $got),
    invented:     ($got - $want),
    duplicated:   ($got | group_by(.) | map(select(length > 1) | .[0])),
    off_calendar: ([ $text | scan("T[+-][0-9]+") ] - $days | unique) }
' bundle.json
# every list empty means the pack agrees with the measurement
import re
from collections import Counter

facts = app_input["facts"]                   # exactly what you posted
# result came from step 5 or step 6

# --- 1. the partition -------------------------------------------------
want = [t["id"] for t in facts["tasks_for_review"]]
got = Counter(r["task_id"] for r in result["critical_reviews"])

missing    = [i for i in want if not got[i]]
duplicated = [i for i, n in got.items() if n > 1]
invented   = [i for i in got if i not in want]
if missing or duplicated or invented:
    raise SystemExit(
        f"partition broken - missing {missing}, duplicated {duplicated}, invented {invented}")

# --- 2. every day and date cited is on the close calendar -------------
text = " ".join(
    [result["escalation"]]
    + result["resequencing"]
    + result["standup_agenda"]
    + [r["owner_ask"] + " " + r["resolution"] for r in result["critical_reviews"]]
)
days  = {d["day"].upper() for d in facts["calendar"]}
dates = {d["date"] for d in facts["calendar"]} | {facts["period_end"], facts["as_of"]}

off  = [d.replace(" ", "") for d in re.findall(r"T\s?[+-]\s?\d+", text.upper())]
off  = [d for d in off if d not in days]
off += [d for d in re.findall(r"\d{4}-\d{2}-\d{2}", text) if d not in dates]
if off:
    raise SystemExit("not on the close calendar: " + ", ".join(sorted(set(off))))

print(result["posture"], "-", len(result["critical_reviews"]), "reviews, all checks clean")
const facts = appInput.facts;          // exactly what you posted

// --- 1. the partition -------------------------------------------------
const want = facts.tasks_for_review.map(t => t.id);
const got = {};
for (const r of result.critical_reviews) got[r.task_id] = (got[r.task_id] || 0) + 1;

const missing    = want.filter(id => !got[id]);
const duplicated = Object.keys(got).filter(id => got[id] > 1);
const invented   = Object.keys(got).filter(id => !want.includes(id));
if (missing.length || duplicated.length || invented.length) {
  throw new Error(
    `partition broken - missing [${missing}], duplicated [${duplicated}], invented [${invented}]`);
}

// --- 2. every day and date cited is on the close calendar -------------
const text = [result.escalation]
  .concat(result.resequencing, result.standup_agenda,
          result.critical_reviews.map(r => `${r.owner_ask} ${r.resolution}`))
  .join(" \n ");

const days  = new Set(facts.calendar.map(d => d.day.toUpperCase()));
const dates = new Set(facts.calendar.map(d => d.date)
  .concat(facts.period_end, facts.as_of));

const off = (text.toUpperCase().match(/\bT\s?[+-]\s?\d+\b/g) || [])
  .map(s => s.replace(/\s+/g, ""))
  .filter(s => !days.has(s))
  .concat((text.match(/\b\d{4}-\d{2}-\d{2}\b/g) || []).filter(s => !dates.has(s)));
if (off.length) throw new Error("not on the close calendar: " + [...new Set(off)].join(", "));

console.log(result.posture, result.critical_reviews.length, "reviews, all checks clean");
// facts and pack decoded into structs mirroring the schemas above.

// --- 1. the partition -------------------------------------------------
want := map[string]bool{}
for _, t := range facts.TasksForReview {
    want[t.ID] = true
}
got := map[string]int{}
for _, r := range pack.CriticalReviews {
    got[r.TaskID]++
}
for id := range want {
    if got[id] != 1 {
        log.Fatalf("task %s reviewed %d times, want exactly 1", id, got[id])
    }
}
for id, n := range got {
    if !want[id] {
        log.Fatalf("review of %s (x%d) points at a task the engine never flagged", id, n)
    }
}

// --- 2. every day cited is on the close calendar ----------------------
days := map[string]bool{}
for _, d := range facts.Calendar {
    days[strings.ToUpper(d.Day)] = true
}
parts := append([]string{pack.Escalation}, pack.Resequencing...)
parts = append(parts, pack.StandupAgenda...)
for _, r := range pack.CriticalReviews {
    parts = append(parts, r.OwnerAsk, r.Resolution)
}
text := strings.ToUpper(strings.Join(parts, " "))

for _, m := range regexp.MustCompile(`T[+-]\d+`).FindAllString(text, -1) {
    if !days[m] {
        log.Fatalf("%s is not a day on this close calendar", m)
    }
}
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;

// --- 1. the partition -------------------------------------------------
Map<String, Long> got = pack.criticalReviews.stream()
    .collect(Collectors.groupingBy(r -> r.taskId, Collectors.counting()));

Set<String> want = facts.tasksForReview.stream()
    .map(t -> t.id).collect(Collectors.toSet());

for (String id : want) {
    long n = got.getOrDefault(id, 0L);
    if (n != 1) throw new IllegalStateException(id + " reviewed " + n + " times, want exactly 1");
}
for (String id : got.keySet()) {
    if (!want.contains(id))
        throw new IllegalStateException("review of " + id + " points at an unflagged task");
}

// --- 2. every day cited is on the close calendar ----------------------
Set<String> days = facts.calendar.stream()
    .map(d -> d.day.toUpperCase()).collect(Collectors.toSet());

List<String> parts = new ArrayList<>();
parts.add(pack.escalation);
parts.addAll(pack.resequencing);
parts.addAll(pack.standupAgenda);
pack.criticalReviews.forEach(r -> { parts.add(r.ownerAsk); parts.add(r.resolution); });

Matcher m = Pattern.compile("T[+-]\\d+").matcher(String.join(" ", parts).toUpperCase());
while (m.find()) {
    if (!days.contains(m.group()))
        throw new IllegalStateException(m.group() + " is not a day on this close calendar");
}
# --- 1. the partition -------------------------------------------------
want = facts["tasks_for_review"].map { |t| t["id"] }
got  = result["critical_reviews"].map { |r| r["task_id"] }.tally

missing    = want.reject { |id| got[id] }
duplicated = got.select { |_, n| n > 1 }.keys
invented   = got.keys - want
unless (missing + duplicated + invented).empty?
  abort("partition broken - missing #{missing}, duplicated #{duplicated}, invented #{invented}")
end

# --- 2. every day and date cited is on the close calendar -------------
text = ([result["escalation"]] + result["resequencing"] + result["standup_agenda"] +
        result["critical_reviews"].map { |r| "#{r["owner_ask"]} #{r["resolution"]}" }).join(" ")

days  = facts["calendar"].map { |d| d["day"].upcase }
dates = facts["calendar"].map { |d| d["date"] } + [facts["period_end"], facts["as_of"]]

off  = text.upcase.scan(/T\s?[+-]\s?\d+/).map { |s| s.delete(" ") } - days
off += text.scan(/\d{4}-\d{2}-\d{2}/) - dates
abort("not on the close calendar: #{off.uniq.join(", ")}") unless off.empty?

puts "#{result["posture"]} - #{result["critical_reviews"].length} reviews, all checks clean"
<?php
// --- 1. the partition -------------------------------------------------
$want = array_column($facts["tasks_for_review"], "id");
$got  = array_count_values(array_column($result["critical_reviews"], "task_id"));

$missing    = array_values(array_diff($want, array_keys($got)));
$duplicated = array_keys(array_filter($got, fn($n) => $n > 1));
$invented   = array_values(array_diff(array_keys($got), $want));
if ($missing || $duplicated || $invented) {
    exit("partition broken - missing " . implode(",", $missing) .
         ", duplicated " . implode(",", $duplicated) .
         ", invented " . implode(",", $invented) . "\n");
}

// --- 2. every day and date cited is on the close calendar -------------
$text = implode(" ", array_merge(
    [$result["escalation"]],
    $result["resequencing"],
    $result["standup_agenda"],
    array_map(fn($r) => $r["owner_ask"] . " " . $r["resolution"], $result["critical_reviews"])
));

$days  = array_map(fn($d) => strtoupper($d["day"]), $facts["calendar"]);
$dates = array_merge(array_column($facts["calendar"], "date"),
                     [$facts["period_end"], $facts["as_of"]]);

preg_match_all('/T[+-]\d+/', strtoupper($text), $md);
preg_match_all('/\d{4}-\d{2}-\d{2}/', $text, $mi);
$off = array_merge(array_diff(array_unique($md[0]), $days),
                   array_diff(array_unique($mi[0]), $dates));
if ($off) {
    exit("not on the close calendar: " . implode(", ", $off) . "\n");
}
using System.Text.RegularExpressions;

// --- 1. the partition -------------------------------------------------
var want = facts.GetProperty("tasks_for_review").EnumerateArray()
    .Select(t => t.GetProperty("id").GetString()!).ToList();
var got = result.GetProperty("critical_reviews").EnumerateArray()
    .GroupBy(r => r.GetProperty("task_id").GetString()!)
    .ToDictionary(g => g.Key, g => g.Count());

foreach (var id in want)
{
    got.TryGetValue(id, out var n);
    if (n != 1) throw new Exception($"{id} reviewed {n} times, want exactly 1");
}
foreach (var id in got.Keys)
    if (!want.Contains(id))
        throw new Exception($"review of {id} points at a task the engine never flagged");

// --- 2. every day cited is on the close calendar ----------------------
var days = facts.GetProperty("calendar").EnumerateArray()
    .Select(d => d.GetProperty("day").GetString()!.ToUpperInvariant()).ToHashSet();

var parts = new List<string> { result.GetProperty("escalation").GetString() ?? "" };
foreach (var key in new[] { "resequencing", "standup_agenda" })
    parts.AddRange(result.GetProperty(key).EnumerateArray().Select(e => e.GetString() ?? ""));
foreach (var r in result.GetProperty("critical_reviews").EnumerateArray())
    parts.Add(r.GetProperty("owner_ask").GetString() + " " + r.GetProperty("resolution").GetString());

foreach (Match m in Regex.Matches(string.Join(" ", parts).ToUpperInvariant(), @"T[+-]\d+"))
    if (!days.Contains(m.Value))
        throw new Exception($"{m.Value} is not a day on this close calendar");
Both of these are cheap and both catch real failures. A missed id means a blocked task nobody was asked about; an off-calendar day means an ask with a deadline that does not exist. If either fires, retry with the same Idempotency-Key plus a retry_note in the input naming what was wrong — that is exactly what the app does, and it is why a malformed first reply is not billed twice.

Where to go next

The app itself runs the whole engine for free with no account: paste a close task list, a period block and a reconciling-item table and you get the calendar, the dependency levels, the blockers with their downstream counts, the coverage gaps, the owner load and the rule's posture without a single network call. Three bundled closes are one click away if you want to see the shape before you paste anything. The token page shows the token this browser holds, copies a shell export for you and mints a fresh guest token on request — it is the right place to send anyone who asks how to authenticate, and it means nobody ever needs to open a developer console.