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.
{ "ok": true, "data": { "...": "..." } }
{ "ok": false, "error": { "code": "validation_error", "message": "...", "details": { } } }
| Code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | No token, an expired token, or a token minted for another app. | Mint a guest token or sign in; see step 1. |
payment_required | 402 | The balance is below min_credits for this run. | Top up, or use a sponsored run. /estimate is free, so check before you submit. |
validation_error | 400 | The 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_found | 404 | Unknown 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_limited | 429 | Too many requests in the window. | Back off and retry with growing delays; do not tight-loop a poll. |
internal_error | 500 | The 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();
}
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
| Field | Shape | What it is |
|---|---|---|
period, period_end | string | The period label and its ISO end date. Every close day is counted forward from here. |
as_of, as_of_day | string | Today, and which close day that is (T+3). Lateness is measured against it. |
calendar_mode | standard | accelerated | One dependency stage per business day, or two on an accelerated close. |
target_days, min_days_required, target_feasible | number, number, boolean | The 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, array | Task 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_fix | What 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_late | Past deadline, in business days, as of as_of. |
at_risk[] | array | Due 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, bottleneck | Open work per owner per close day, and whether one person is a bottleneck. |
missing_activities[] | activity, level, day, required | Canonical close activities with no task at all. required: true is a coverage failure, not a note. |
reconciling_items[], materiality | array, number | Open items with over_materiality and aged already decided. At or above materiality belongs in the escalation; below it, in the retro. |
posture_by_rule | key, 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, why | The 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[] | arrays | What the engine already asserted about the close, and what it could not read from what was pasted. |
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.
| Field | Meaning |
|---|---|
model | The resolved model that will serve the run. |
model_alias | The alias the app asked for; the resolved model can move under it. |
markup_bps | The app's markup in basis points, applied on top of provider cost. |
hold_credits | What will be reserved, priced against the full output cap. |
min_credits | The floor. Below this the run is refused with payment_required. |
sponsor_enabled | True 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.
| Check | Assertion | Fails when |
|---|---|---|
partition | critical_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. |
severity | Severity 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. |
posture | The 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. |
owners | Every 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. |
dates | Every 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. |
state | No 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. |
escalation | The 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");
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.