Drive Reorder Desk from your own code
The app is a thin client over a public REST API. Everything the page does — estimate a run,
submit one, stream it — is available to you directly. Base URL
https://api.skillsafe.ai/v1/app-api. Every response is the same envelope:
{"data": ...} on success, {"error":{"code":"...","message":"..."}} on
failure. Branch on the presence of error and then on error.code, never on
the message text.
What the model is and is not asked to do
This decides what you have to send, so it comes before any code. The measurement is not the
model's job. Reading the item file, forming each line's mean and sample standard deviation,
cutting ABC on cumulative annual value and XYZ on the coefficient of variation, drawing a service level
out of the policy matrix, inverting the normal to a quantile, sizing the safety stock both with and
without the lead time's own spread, netting the inventory position, forming the reorder point and the
order-up-to level, writing the order rounded up to the case pack and lifted to the minimum, the economic
order quantity against the cheapest whole-case quantity, the driver study by neutralisation, the ABC
sensitivity by removal, and every refusal — all of that is deterministic. It runs in the browser in
the app, and it has to run in your code if you are driving the API. What you send is that
measurement, in facts. What comes back is a buyer's note: a headline, a posture word, a buy
summary, one note per actionable line, the vendor levers, a watch list and an honest list of what could
not be supported.
The binding is a vocabulary, not a topic. facts.admissible.by_sku[sku_id].actions and
.urgency, facts.admissible.posture_words and
facts.admissible.lever_words hold the only strings the reply may use for
those four characterisations, and each list is computed from the measured position before the run. A
string outside the list is an invention, not a judgement call, and the same object is what you check the
reply back against afterwards.
One consequence worth internalising: an empty list is a real answer. When a line's
position or policy could not be formed, its actions list is [], the line does
not appear in facts.actionable_ids at all, and no sku_notes entry may be
written for it. A refused number stays refused; it never becomes a hedged sentence.
An abridged example of the body you post
Illustrative and heavily abridged — a twelve-line ambient-grocery book in the exact shape the engine emits, not one of the three books bundled with the app. The full payload is about 60 KB: every item line with its policy, its two safety stocks, its order-quantity study and its flags, plus the totals, the driver study and the ABC sensitivity. Two lines are shown here so the shape is readable; the field-by-field contract is below.
{
"facts": {
"ok": true,
"measurable": true,
"scope": "Midwest DC - ambient grocery",
"asof": "2026-08-03",
"unit": "week", "unit_label": "week", "unit_plural": "weeks",
"periods_per_year": 52, "review_period_default": 1,
"ordering_cost": 45, "holding_rate": 0.22,
"policy_mode": "the ABC/XYZ policy matrix",
"skus": [
{ "id": "s1", "sku": "OAT-500", "description": "Rolled oats 500g", "vendor": "Northwind",
"abc": "A", "xyz": "X", "klass": "AX",
"mean": 191.66666666666666, "sd": 11.972189997378446, "cv": 0.06246714781606319,
"n_obs": 12, "zeros": 0,
"lead_time": 2, "lead_time_sd": 0.4, "review_period": 1,
"service_level": 0.975, "z": 1.9599639845400545,
"safety_stock": 155.66138731124094,
"ss_demand_only": 40.638723449018,
"ss_basis": "demand and lead-time variability",
"reorder_point": 538.9947206445743, "order_up_to": 730.661387311241,
"inventory_position": 300, "order_raw": 430.661387311241, "order_qty": 432,
"case_pack": 12, "moq": 120, "unit_cost": 1.85, "unit_margin": 0.72,
"annual_demand": 9966.666666666666, "annual_value": 7176,
"cover_periods": 1.5652173913043479, "periods_to_stockout": 0.7531229320458732,
"eoq": { "raw": 1484.5666, "naive": 1488, "naive_cost": 604.2, "optimal": 1488,
"optimal_cost": 604.2, "penalty": 0, "penalty_pct": 0 },
"state": "at or below the reorder point",
"flags": [], "refusals": [] },
{ "id": "s7", "sku": "SPC-CHL", "description": "Chilli flakes 200g", "vendor": "Northwind",
"abc": "C", "xyz": "Z", "klass": "CZ",
"mean": 6.25, "sd": 7.879, "cv": 1.2606, "n_obs": 12, "zeros": 5,
"lead_time": 3, "lead_time_sd": null, "review_period": 1,
"service_level": 0.85, "z": 1.0364333894937896,
"safety_stock": null, "ss_demand_only": null, "ss_basis": null,
"reorder_point": 18.75, "order_up_to": 25, "inventory_position": 14,
"order_raw": 11, "order_qty": 12, "case_pack": 12, "moq": null,
"unit_cost": 2.4, "annual_value": 780,
"state": "at or below the reorder point",
"flags": [],
"refusals": [ { "what": "safety stock", "cause": "intermittent demand",
"detail": "5 of 12 periods sold nothing, above the 30% limit, so the normal quantile does not describe this demand; the line needs Croston or a bootstrapped distribution, neither of which this engine carries" } ] }
/* ... 10 more lines, abridged away ... */
],
"totals": { "skus": 12, "order_lines": 5, "order_units": 1284, "order_value": 2946.32,
"ss_units": 611.4, "ss_value": 1204.7, "position_value": 4180.55,
"annual_value": 41230.5, "at_risk": 1, "refused_lines": 2, "priced_lines": 5 },
"drivers": { "base_units": 611.4, "base_value": 1204.7,
"rows": [ { "name": "lead-time variability", "units_after": 402.9,
"saved_units": 208.5, "saved_value": 411.3, "share": 0.341 } ],
"decisive": "lead-time variability", "decisive_share": 0.341,
"neutralisations": ["demand variability: every sigma_d set to zero", "..."] },
"abc_sensitivity": { "decisive": { "id": "s4", "sku": "CFE-1KG", "moved": 3,
"detail": "removing it moves 3 other letters" },
"largest": { "id": "s1", "sku": "OAT-500" },
"same_as_largest": false, "note": "" },
"approx": { "norminv_max_error": 4.5e-08,
"eoq": [ { "id": "s1", "raw": 1484.5666, "naive": 1488, "optimal": 1488,
"penalty": 0, "penalty_pct": 0 } ],
"eoq_penalty_total": 3.42,
"ss_shortcut": [ { "id": "s1", "demand_only": 40.638723449018,
"combined": 155.66138731124094,
"uplift": 115.02266386222294, "uplift_pct": 2.8304 } ] },
"admissible": {
"by_sku": {
"s1": { "actions": ["order now"], "urgency": ["immediate", "this cycle"], "why": "" },
"s7": { "actions": ["order now", "review the item"],
"urgency": ["immediate", "this cycle"], "why": "" }
},
"posture_words": ["replenish", "expedite"],
"lever_words": ["consolidate the order", "renegotiate the minimum", "shorten the lead time",
"reduce the lead-time spread", "review more often", "split the shipment"],
"urgency_words": ["immediate", "this cycle", "next cycle"]
},
"actionable_ids": ["s1", "s7"],
"refusals": [
{ "id": "s7", "sku": "SPC-CHL", "what": "safety stock", "cause": "intermittent demand",
"detail": "5 of 12 periods sold nothing, above the 30% limit ..." }
],
"table": { "total": 12, "parsed": 12, "skipped": [], "format": "pipe table",
"unknown_columns": [], "wide_periods": 0,
"columns_read": ["sku", "description", "vendor", "demand", "on hand"] },
"causes": ["no demand signal", "intermittent demand", "no lead time",
"no cost basis", "no inventory position"]
},
"file_excerpt": "| sku | description | vendor | demand | on hand | on order | committed | lead time |\n| OAT-500 | Rolled oats 500g | Northwind | 180 195 172 ... | 320 | 0 | 20 | 2 |\n..."
}
POST /guest
GET /me
POST /estimate
POST /run
POST /run-stream
GET /jobs/{job_id}
Errors
Every failure is {"error":{"code":"...","message":"...","details":{...}}}. Branch on code, never on the message.
| Code | HTTP | What it means |
|---|---|---|
VALIDATION_ERROR | 400 | The body is not the shape the app expects. error.details.violations names the offending field. A run input over 1 MB of JSON lands here — clip file_excerpt, never the facts. |
UNAUTHORIZED | 401 | No token, an expired one, or a token minted for a different app. Mint a guest token or sign in on the token page. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. /estimate is free and tells you this before you submit, so a 402 after submit means the preflight was skipped. |
FORBIDDEN | 403 | The token is valid but not permitted here — most often a guest token on an app whose owner has not enabled sponsorship. |
NOT_FOUND | 404 | Unknown job id, or a route this release does not declare. |
RATE_LIMITED | 429 | Too many requests. Back off and retry; a polling loop tighter than one second per job, or an /estimate call on every keystroke, is the usual cause. |
INTERNAL | 500 | The platform failed. Retry with the SAME Idempotency-Key: the platform replays rather than re-billing. |
UNAVAILABLE | 503 | The model or the queue is temporarily unavailable. Retry with backoff, same key. |
1. A tiny client
One helper covers every call. Keep the token out of source control — the token page will copy a ready-made shell export for you, and it is the supported way to get one.
# Every call is one request to the same host. Keep the token in a shell
# variable; never commit it. Get one from /tokens.html, or step 2.
API=https://api.skillsafe.ai/v1/app-api
SKILLSAFE_TOKEN="YOUR_TOKEN"
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$API$1" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
fi
}import json, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # or read it from your own secret store
def call(path, body=None, method=None, token=TOKEN, headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data,
method=method or ("POST" if data else "GET"))
if token:
req.add_header("Authorization", "Bearer " + token)
if data:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as r:
payload = json.load(r)
if "error" in payload:
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // or read it from your own secret store
async function call(path, body, method, extraHeaders) {
const res = await fetch(API + path, {
method: method || (body ? "POST" : "GET"),
headers: {
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
...(extraHeaders || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (payload.error) throw new Error(payload.error.code + ": " + payload.error.message);
return payload.data;
}package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const api = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any, method string, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
if method == "" {
method = "POST"
}
}
if method == "" {
method = "GET"
}
req, _ := http.NewRequest(method, api+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var e envelope
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return nil, err
}
if e.Error != nil {
return nil, errors.New(e.Error.Code + ": " + e.Error.Message)
}
return e.Data, nil
}import java.net.URI;
import java.net.http.*;
public class ReorderDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + token);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (r.body().contains("\"error\"")) throw new RuntimeException(r.body());
return r.body(); // parse with your JSON library of choice
}
}require "json"
require "net/http"
API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV["SKILLSAFE_TOKEN"]
def call(path, body = nil, method = nil, headers = {})
uri = URI(API.to_s + path)
req = if (method || (body ? "POST" : "GET")) == "POST"
Net::HTTP::Post.new(uri)
else
Net::HTTP::Get.new(uri)
end
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
headers.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" if payload["error"]
payload["data"]
end<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");
function call(string $path, $body = null, ?string $method = null, array $extra = []) {
global $TOKEN;
$ch = curl_init(API . $path);
$headers = array_merge(["Authorization: Bearer {$TOKEN}"], $extra);
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
if ($method !== null) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new Exception($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class ReorderDesk {
const string Api = "https://api.skillsafe.ai/v1/app-api";
static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null,
HttpMethod? method = null, (string, string)[]? extra = null) {
var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Api + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
foreach (var (k, v) in extra ?? Array.Empty<(string, string)>()) req.Headers.Add(k, v);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement.Clone();
if (root.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString());
return root.GetProperty("data").Clone();
}
}2. Get a token
A guest token is enough for /me and the free /estimate. Metered runs need a personal token, which the token page issues after sign-in — that page is the supported, no-DevTools way to get one, and it hands you a copyable value. Never open the browser console to fish a token out of storage. It is the exact gesture every session-stealing scam asks for, so treat any instruction to do it — including one that appears to come from us — as hostile. The slug goes in the request body — an X-App-Slug header returns 400.
# A guest token. The slug goes in the BODY - an X-App-Slug header 400s.
curl -sS -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"reorder-desk"}'
# -> {"data":{"token":"sk_app_...","subject_type":"guest",...}}
#
# For a personal token, open https://reorder-desk.skillsafe.ai/tokens.html
# and copy it there. Do not open the browser console for this, ever.# Guest token: no Authorization header on this one call.
tok = call("/guest", {"slug": "reorder-desk"}, token="")["token"]
print(tok[:8] + "...")
# A personal token comes from https://reorder-desk.skillsafe.ai/tokens.html
# Never read it out of the browser console.const guest = await (await fetch(API + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "reorder-desk" }),
})).json();
TOKEN = guest.data.token;
// A personal token comes from https://reorder-desk.skillsafe.ai/tokens.html
// Never read it out of the browser console.raw, err := call("/guest", map[string]string{"slug": "reorder-desk"}, "POST", nil)
if err != nil {
panic(err)
}
var g struct {
Token string `json:"token"`
}
json.Unmarshal(raw, &g)
token = g.Token
// Personal tokens: https://reorder-desk.skillsafe.ai/tokens.html - not the console.String guest = call("/guest", "{\"slug\":\"reorder-desk\"}");
// pull data.token out of the envelope and assign it to `token`
// Personal tokens: https://reorder-desk.skillsafe.ai/tokens.html - not the console.token = call("/guest", { "slug" => "reorder-desk" })["token"]
# Personal tokens: https://reorder-desk.skillsafe.ai/tokens.html - not the console.$guest = call("/guest", ["slug" => "reorder-desk"]);
$TOKEN = $guest["token"];
// Personal tokens: https://reorder-desk.skillsafe.ai/tokens.html - not the console.var guest = await ReorderDesk.Call("/guest", new { slug = "reorder-desk" });
// guest.GetProperty("token").GetString()
// Personal tokens: https://reorder-desk.skillsafe.ai/tokens.html - not the console.3. Check the session
GET /me returns subject_type (user or guest), subject_id and credits. Compare that balance against min_credits from step 4 before you submit anything.
curl -sS "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"subject_type":"user","subject_id":"...","credits":1240}}me = call("/me")
print(me["subject_type"], me["credits"])const me = await call("/me");
console.log(me.subject_type, me.credits);raw, _ := call("/me", nil, "GET", nil)
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)String me = call("/me", null);
// read data.subject_type and data.creditsme = call("/me")
puts "#{me['subject_type']} #{me['credits']}"$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], "\n";var me = await ReorderDesk.Call("/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());4. Estimate first — it is free
/estimate takes the same body as /run, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits (a worst-case reservation, priced at the full output cap) and min_credits. Only what a run actually uses is charged, so the eventual charged_credits is usually far below the hold. Assert the three pricing facts before you spend anything: model is gpt-5.6-terra, model_alias is gpt-terra, and markup_bps is 1000. If any of them has moved, something about the deployment has changed and a run is not the moment to find out. In the snippets below run_input is the body documented below; from a shell it is simply input.json.
call /estimate "$(cat input.json)"
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...}}
# Free: no job is created and nothing is charged.
# Assert the pricing facts before spending anything:
call /estimate "$(cat input.json)" \
| grep -q '"model":"gpt-5.6-terra"' || echo "the model has moved - stop"est = call("/estimate", run_input)
assert est["model"] == "gpt-5.6-terra", est["model"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print(est["hold_credits"], est["min_credits"])
if me["credits"] < est["min_credits"]:
raise SystemExit("top up first - a 402 after submit is a preflight you skipped")const est = await call("/estimate", runInput);
if (est.model !== "gpt-5.6-terra") throw new Error("unexpected model " + est.model);
if (est.model_alias !== "gpt-terra") throw new Error("unexpected alias " + est.model_alias);
if (est.markup_bps !== 1000) throw new Error("unexpected markup " + est.markup_bps);
if (me.credits < est.min_credits) throw new Error("balance below min_credits");
console.log("reserves", est.hold_credits, "credits");raw, _ = call("/estimate", runInput, "POST", nil)
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"`
}
json.Unmarshal(raw, &est)
if est.Model != "gpt-5.6-terra" || est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
panic("the pricing facts have moved: " + est.Model + "/" + est.ModelAlias)
}String est = call("/estimate", inputJson);
// assert data.model is "gpt-5.6-terra", data.model_alias is "gpt-terra"
// and data.markup_bps is 1000 before you submit anything;
// then compare data.min_credits against the balance from /me.
// data.hold_credits is the worst-case reservation, not the charge.
if (!est.contains("\"gpt-5.6-terra\"")) throw new IllegalStateException("the model has moved");est = call("/estimate", run_input)
abort "the model has moved: #{est['model']}" unless est["model"] == "gpt-5.6-terra"
abort "the alias has moved: #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
abort "the markup has moved: #{est['markup_bps']}" unless est["markup_bps"] == 1000
abort "top up first" if me["credits"] < est["min_credits"]
puts "holds #{est['hold_credits']}"$est = call("/estimate", $runInput);
if ($est["model"] !== "gpt-5.6-terra") { throw new Exception("the model has moved"); }
if ($est["model_alias"] !== "gpt-terra") { throw new Exception("the alias has moved"); }
if ($est["markup_bps"] !== 1000) { throw new Exception("the markup has moved"); }
if ($me["credits"] < $est["min_credits"]) { throw new Exception("top up first"); }
echo "holds ", $est["hold_credits"], "\n";var est = await ReorderDesk.Call("/estimate", runInput);
if (est.GetProperty("model").GetString() != "gpt-5.6-terra")
throw new Exception("the model has moved");
if (est.GetProperty("model_alias").GetString() != "gpt-terra")
throw new Exception("the alias has moved");
if (est.GetProperty("markup_bps").GetInt32() != 1000)
throw new Exception("the markup has moved");
var hold = est.GetProperty("hold_credits").GetInt32();
var min = est.GetProperty("min_credits").GetInt32();5. Run it
Send an Idempotency-Key on every run: a content hash of the input plus one nonce per user gesture. A reformat retry after a malformed reply must reuse the same key, or a bad first answer costs twice. The reply is a job; poll GET /jobs/{job_id} until status is succeeded or failed, then parse output_text as JSON.
# Idempotency-Key is a content hash of the input plus one nonce per
# gesture. Reuse it for a retry and the platform replays the job it
# already has instead of billing a second one.
KEY="$(shasum -a 256 input.json | cut -c1-32)-1"
curl -sS -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# The reply is a job. Poll until it is terminal, no tighter than 1s.
curl -sS "$API/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"job_id":"...","status":"succeeded","output_text":"{...}"}}import hashlib, json, time
key = hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:32] + "-1"
job = call("/run", run_input, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed"):
time.sleep(1.0)
job = call("/jobs/" + job["job_id"], method="GET")
if job["status"] == "failed":
raise SystemExit(job.get("error") or "the run failed")
result = json.loads(job["output_text"])
print(result["headline"])const key = "run-" + Date.now().toString(36) + "-1"; // one nonce per gesture
let job = await call("/run", runInput, "POST", { "Idempotency-Key": key });
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 1000));
job = await call("/jobs/" + job.job_id);
}
if (job.status === "failed") throw new Error(job.error || "the run failed");
const result = JSON.parse(job.output_text);key := fmt.Sprintf("%x-1", sha256.Sum256(bodyBytes))[:34]
raw, _ = call("/run", runInput, "POST", map[string]string{"Idempotency-Key": key})
var job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
OutputText string `json:"output_text"`
}
json.Unmarshal(raw, &job)
for job.Status != "succeeded" && job.Status != "failed" {
time.Sleep(time.Second)
raw, _ = call("/jobs/"+job.JobID, nil, "GET", nil)
json.Unmarshal(raw, &job)
}
// job.OutputText is the buyer's note, as JSON text// Add the header on the run request:
// .header("Idempotency-Key", key)
String job = call("/run", inputJson);
// read data.job_id, then poll GET /jobs/{job_id} until status is
// "succeeded" or "failed"; the reply text is data.output_text,
// which parses into the buyer's-note object documented belowrequire "digest"
key = Digest::SHA256.hexdigest(JSON.generate(run_input))[0, 32] + "-1"
job = call("/run", run_input, "POST", { "Idempotency-Key" => key })
until %w[succeeded failed].include?(job["status"])
sleep 1
job = call("/jobs/#{job['job_id']}")
end
abort(job["error"] || "the run failed") if job["status"] == "failed"
result = JSON.parse(job["output_text"])$key = substr(hash("sha256", json_encode($runInput)), 0, 32) . "-1";
$job = call("/run", $runInput, null, ["Idempotency-Key: {$key}"]);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(1);
$job = call("/jobs/" . $job["job_id"]);
}
$result = json_decode($job["output_text"], true);var key = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
JsonSerializer.SerializeToUtf8Bytes(runInput)))[..32] + "-1";
var job = await ReorderDesk.Call("/run", runInput, null, new[] { ("Idempotency-Key", key) });
var jobId = job.GetProperty("job_id").GetString();
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
await Task.Delay(1000);
job = await ReorderDesk.Call("/jobs/" + jobId);
}
var result = JsonDocument.Parse(job.GetProperty("output_text").GetString()!);6. Or stream it
Same body, same key. The frame name arrives on the event: line and the payload on the following data: line — parsing only data: and guessing the type is the usual bug. A stream that ends early is still worth rendering: trim the buffer back to the last complete member, close it, and show what arrived rather than discarding it.
curl -N -sS -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# The frame NAME arrives on the "event:" line, the payload on "data:".
# event: delta -> {"text":"..."} append it
# event: done -> the terminal job object
# event: error -> {"code":"...","message":"..."}import json, urllib.request
req = urllib.request.Request(API + "/run-stream",
data=json.dumps(run_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf, event, job = "", None, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip() # the frame NAME lives here
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
buf += payload.get("text", "")
elif event == "done":
job = payload
elif event == "error":
raise RuntimeError(payload["code"] + ": " + payload["message"])
result = json.loads(job["output_text"] if job else buf)const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", pending = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += dec.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") buf += payload.text || "";
else if (event === "error") throw new Error(payload.code + ": " + payload.message);
}
}
}req, _ := http.NewRequest("POST", api+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:]) // the frame NAME
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d)
buf += d.Text
}
}HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
final String[] event = { null };
res.body().forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:") && "delta".equals(event[0]))
buf.append(line.substring(5).trim()); // then pull .text with your JSON library
});uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(run_input)
buf = +""
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|
line = line.chomp
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") && event == "delta"
buf << (JSON.parse(line[5..].strip)["text"] || "")
end
end
end
end
end$buf = "";
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runInput));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer {$TOKEN}",
"Content-Type: application/json",
"Idempotency-Key: {$key}",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$buf, &$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") {
$d = json_decode(trim(substr($line, 5)), true);
$buf .= $d["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string? evt = null;
while (await sr.ReadLineAsync() is { } line) {
if (line.StartsWith("event:")) evt = line[6..].Trim(); // the frame NAME
else if (line.StartsWith("data:") && evt == "delta") {
using var d = JsonDocument.Parse(line[5..].Trim());
buf.Append(d.RootElement.GetProperty("text").GetString());
}
}The input contract
The run body is two keys, plus two optional ones. Everything numeric lives in facts. file_excerpt is the item file as pasted, clipped on whole-row boundaries with the header kept, so the model can see the raw rows behind the numbers — it is context for item names and vendor names only, and where it disagrees with facts, facts wins. Keys beginning with an underscore are the app's own bookkeeping and are stripped before submission — do not send them.
{
"facts": { ... the whole measurement, exactly as the engine produced it ... },
"file_excerpt": "| sku | description | vendor | demand | on hand | lead time |\n| OAT-500 | ...",
"excerpt_note": "All 412 item rows were measured locally ... sent 201 of 412 ...", // only when clipped
"retry_note": "the previous reply was not valid JSON ..." // only on a reformat retry
}
The app clips file_excerpt at 24,000 characters, on row boundaries, header preserved. The clip keeps both ends of the file — the first rows and the last rows, with a ... [middle of the file omitted ...] ... marker between them — rather than the first N rows, because an item file is very often sorted by vendor, by ABC letter or by value, and keeping only the head hands the model one end of that sort and hides the other. When a clip happens, excerpt_note is sent alongside it, stating how many rows went and how many were dropped. Both exist so the reply cannot mistake the excerpt for the whole book. The facts object is never clipped: every row is measured and every measurement is in facts.skus, and it is the thing the reply is checked against.
The facts object, field for field
| Field | What it holds |
|---|---|
ok, error, measurable | measurable is false when no line carries both a position and a complete policy. facts.actionable_ids is then empty, there is nothing to write a buy against, and no run to make. |
scope, asof, unit, unit_label, unit_plural, periods_per_year | The book this extract covers, the date it was taken, and the declared period. Nothing about the period is inferred: a lead time written 2 is two weeks or two days depending only on this field. |
review_period_default, ordering_cost, holding_rate, fixed_service, policy_mode | The run settings. holding_rate is a decimal, not a percentage. policy_mode is either the ABC/XYZ policy matrix or one service level for every line. |
skus[] | One entry per item line: id (s1, s2, ...), sku, description, vendor, the classification abc, xyz and klass; the demand statistics mean, sd, cv, n_obs and zeros; the policy lead_time, lead_time_sd, review_period, service_level and z; the buffers safety_stock, ss_demand_only and ss_basis; the policy levels reorder_point, order_up_to and inventory_position; the buy order_raw, order_qty, case_pack, moq, rounding_overshoot and rounding_cost; the economics unit_cost, unit_margin, annual_demand, annual_value and the eoq study; the coverage cover_periods and periods_to_stockout; and finally state, flags[] and refusals[]. A refused quantity is null, never a substituted number. |
skus[].state | One of stocked out, below safety stock, at or below the reorder point, covered, above the order-up-to level, overstocked, position unknown, policy incomplete. It is what decides which actions that line admits. |
totals | The book-level counts and values: skus, order_lines, order_units, order_value, ss_units, ss_value, position_value, annual_value, at_risk, refused_lines, priced_lines. |
vendor_rollup[] | The same order lines added up the way an order is actually placed — one entry per vendor, sorted by order value, biggest call first. Each carries vendor, lines, order_lines, order_units, order_value, moq_bound, rounding_units, rounding_cost, at_risk, refused, skus[] and soonest_stockout. moq_bound counts that vendor's lines whose order was lifted to the vendor's minimum because the book wanted less than the vendor will sell; rounding_units and rounding_cost are what the case pack adds on top. These two are the measured case behind a minimum-order or consolidation lever, so the reply can cite a figure rather than assert a lever. They are totals the engine computed — the reply quotes them and never re-derives or cross-sums them. |
drivers | The neutralisation study: base_units and base_value, then one rows[] entry per cause — demand variability, lead-time variability, lead-time length, the service level, the review period — each with units_after, saved_units, saved_value and share. decisive names the winner and decisive_share is what it removes. This is what the buffer is actually paying for, and it is usually not the largest single buffer. |
abc_sensitivity | decisive, the single item whose removal reshuffles the most other ABC letters, with how many moved; largest, the biggest line by annual value; same_as_largest; and a note when nothing moves. The app sends these four members only. |
approx | The shortcuts and what they cost: norminv_max_error, the bound on the inverse-normal approximation; ss_shortcut[], the demand-only safety stock against the one that carries the lead time's own spread, with the uplift and uplift_pct; and eoq[] with eoq_penalty_total, the naive rounding of the square root against the cheapest whole-case quantity and the annual cost between them. Both are findings, not corroboration. |
admissible | The permitted vocabulary. by_sku[sku_id] holds actions[], urgency[] and a why that explains an empty list; alongside it, posture_words[], lever_words[] and urgency_words[]. An empty actions list means that line was refused and must not be written about at all. |
actionable_ids[] | The line ids whose actions list is non-empty. These and only these get a note, exactly one each, in this order. |
refusals[] | Everything the engine declined to compute, with id, sku, what was refused, the cause and the detail of the arithmetic that failed. |
table, causes[] | Parse bookkeeping — rows total/parsed, skipped[] with the reason, the detected format, unknown_columns, wide_periods, columns_read — and the five refusal causes: no demand signal, intermittent demand, no lead time, no cost basis, no inventory position. Every refusal carries exactly one of them. |
facts. The whole measurement is computed
client-side by the free engine (reorderkit.js, RK.analyze({table, unit, scope, asof,
review_period, ordering_cost, holding_rate, service_level})) and it costs nothing. A programmatic
caller has two honest options: replicate the arithmetic yourself — the sample standard deviation
with n minus one, ABC on cumulative annual value, XYZ on the coefficient of variation, the service level
out of the policy matrix, the quantile by inverse normal, both safety stocks, the netted position, the
reorder point, the order-up-to level, the case-pack rounding and the minimum-order lift, and a refusal
for every line the normal distribution cannot carry — or drive the page and take the measurement
JSON it exports. Anything else means sending the model numbers nothing computed, and the admissible
vocabulary the whole contract rests on has nothing behind it. The metered call writes only the
buyer's note.
The engine strips one member before submission: facts.figures, the grounding pool of every value the engine printed, which the app's own figure tracer uses and the model does not need. facts.abc_sensitivity is reduced to its four summary members, and anything whose key begins with an underscore is stripped too.
The output contract
Exactly this shape and nothing else — no prose before it, no code fence around it. The parser reads these fields and no others.
{
"headline": "one or two sentences on what this book is telling the buyer",
"posture": "exactly one string from facts.admissible.posture_words, or null when that list is empty",
"buy_summary": "two or three sentences on the shape of the buy - what it costs, what it is for,
and what is being deferred - or null",
"sku_notes": [
{ "sku_id": "s1",
"action": "verbatim from facts.admissible.by_sku.s1.actions, or null",
"urgency": "verbatim from facts.admissible.by_sku.s1.urgency, or null",
"note": "one to three sentences on why this line is where it is and what happens if it
is left alone" }
],
"vendor_actions": [
{ "vendor": "a vendor name that appears in facts.skus",
"lever": "verbatim from facts.admissible.lever_words",
"rationale": "why this lever, on this vendor, on this book" }
],
"watch_items": ["short strings - what would change this buy before the next review"],
"unverifiable": ["anything a buyer would want to know that this measurement cannot answer"]
}
The rules the app enforces on the reply
| Rule | Why |
|---|---|
Exactly one entry in sku_notes per id in facts.actionable_ids, in that order, and no entry for any other line. | Those are the lines that carry both a position and a policy. The rest were refused and are not the model's to comment on. A note that is out of order is a warning; a missing, duplicated or invented line id is a failure. |
action appears verbatim in that line's admissible.by_sku[id].actions, or is null when the list is empty. | The list is derived from the measured state alone: a position at or below the reorder point admits order now; a position below the safety stock admits expedite as well; a covered line admits order at the next review or hold; an overstocked one admits hold or reduce the level; and any Z-class line also admits review the item. Telling a buyer to expedite a covered line is a false statement about a measured position. |
urgency appears verbatim in that line's admissible.by_sku[id].urgency, or is null. The whole vocabulary is immediate, this cycle, next cycle. | Urgency is how far the position sits from its buffer, not how the note feels about the item. |
Leaving action or urgency null when the list is not empty is reported as a warning. | It is not a lie, but the measurement did support a word and the note declined to use it. |
posture is in facts.admissible.posture_words or null; every lever is in facts.admissible.lever_words. | The posture words are earned by the book: replenish when any line wants an order, rebalance when something is over its level at the same time, hold when nothing wants an order, expedite when a line is at risk. The six levers are the only ones this desk can act on. |
| An action written for a line the measurement refused is a failure, named back by line id. | A line with no position or no complete policy has nothing to order against. Saying what cannot be known about it belongs in the note or in unverifiable, not in an action. |
At least one vendor_action. A book with nothing to renegotiate should say so rather than leave the heading bare. | An empty list reads as an omission, not as an answer. |
One or more watch_items. When the book is clean, the correct answer is a single entry saying so. | Inventing a risk to fill the list is the same failure as inventing a number. |
| Every figure in the reply traces to a value the engine measured or printed, at the precision it was printed to. | The model does not compute. It does not total, ratio, annualise or round anything into existence. A figure that is not in facts is flagged as ungrounded; a value written to fewer decimals than the page prints is accepted as a rounding of a real number, not an invention. |
| The ABC letter and annual value are one observation, and a position under the reorder point and cover shorter than the lead time plus the buffer are one inequality. | Neither pair may be cited as two pieces of corroborating evidence. The letter is a band on annual value; the coverage statement is the reorder-point statement divided through by the mean demand. |
An abridged reply
Real output for the book above, cut down to one line note and one vendor lever.
{
"headline": "Five lines want an order and one of them is already inside its buffer; the buy is 1,284 units at $2,946.32, and 34.1% of the whole book's safety stock is being held against Northwind's lead-time spread rather than against demand.",
"posture": "replenish",
"buy_summary": "The buy is $2,946.32 across 5 lines against a position worth $4,180.55. Most of it is OAT-500 at 432 units, written up to a 730.7-unit order-up-to level from a position of 300. Nothing on the C lines is being brought forward; they sit at or above their levels and can wait for the next review.",
"sku_notes": [
{ "sku_id": "s1",
"action": "order now",
"urgency": "immediate",
"note": "Position 300 against a reorder point of 539.0, so this line is inside the trigger with 0.8 periods of unbuffered cover left. The buffer is 155.7 units, of which only 40.6 would exist without the 0.4-week spread on Northwind's lead time - this line is where that spread costs the most." }
/* ... s7, abridged away ... */
],
"vendor_actions": [
{ "vendor": "Northwind",
"lever": "reduce the lead-time spread",
"rationale": "Neutralising lead-time variability across the whole book removes 208.5 units of safety stock, 34.1% of the total and more than any other cause; Northwind carries the spread on the largest line." }
],
"watch_items": ["SPC-CHL selling in any of the next three weeks - the buffer behind its order does not exist"],
"unverifiable": ["whether the 20 committed units on OAT-500 are already allocated to a specific order, which the extract does not say"]
}
retry_note
explaining what was wrong — reusing the same Idempotency-Key is what stops a
bad first answer costing twice. A reply that fails the contract checks is a different
matter: do not paper over it, name the failing line ids and let a human look.
What is free, and what is metered
Almost all of Reorder Desk costs nothing and never touches this API. The whole measurement — parsing the item file in whatever delimiter it arrives in, the mean and the sample standard deviation, the ABC cut on cumulative annual value and the XYZ cut on the coefficient of variation, the service level from the policy matrix, the quantile with its printed approximation error, both safety stocks and the gap between them, the netted inventory position, the reorder point, the order-up-to level, the whole buy list rounded up to the case pack and lifted to the minimum with the overshoot priced, the economic order quantity against the cheapest whole-case quantity, the driver study by neutralisation, the ABC sensitivity by removal, and every refusal with its cause — is computed in the browser at no cost and with no account. So are the outputs built from it: the replenishment plan markdown, the purchase proposal CSV, the policy CSV and the full measurement JSON. No model call, nothing charged.
Only the buyer's note is metered. That is the pass that gives each actionable line an
action, an urgency and a note, and writes the headline, the posture, the buy summary, the vendor levers,
the watch list and the unverifiable list. It is what /run and /run-stream buy
you, and it is the only part of the app that costs a credit. An API caller must supply facts
themselves, because nothing on the server computes it. If all you want is the numbers, you never need a
token at all.