Driving Entry Desk from your own code
Everything the web app does is available over HTTP. Paste a register, pick a lane, and get back one JSON object. The deterministic arithmetic the browser does for free is not done server-side, so if you drive the API directly you should send your own prescan facts — that is what the model is held accountable to.
Base URL and headers
https://api.skillsafe.ai/v1/app-api
One header on every request:
Authorization: Bearer <token>— get one from the token page, no developer console needed.
The token is app-scoped, so the slug is not a header. There is no X-App-Slug header — a token minted for this app addresses this app and nothing else. The slug appears in exactly one place: the body of POST /guest, which is how you get a token in the first place.
curl -sS -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug": "entry-desk"}'
# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
A guest token is enough for /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in on the token page.
The body of /estimate, /run and /run-stream is the input object itself, not wrapped in an input key. Its fields are listed under the input fields below.
The response envelope
Every response has the same two shapes. Branch on error.code, never on the message text — messages are for humans and will change.
// success
{"ok": true, "data": { ... }}
// failure
{"ok": false, "error": {"code": "VALIDATION_ERROR",
"message": "human-readable",
"details": { ... }}}
Error codes
| code | HTTP | What it means and what to do |
|---|---|---|
UNAUTHORIZED | 401 | No token, a malformed token, or a token for a different app. Mint a new one from the token page. |
FORBIDDEN | 403 | A guest token on a metered lane. Sign in for a personal token, or ask the publisher to enable sponsorship. |
NOT_FOUND | 404 | The job id does not exist, or the token belongs to a different app. |
VALIDATION_ERROR | 400 | The input failed validation. error.details names the offending field - usually task set to something outside the three lanes. |
PAYMENT_REQUIRED | 402 | The balance is below min_credits. Never let a user reach this: compare hold_credits against /me first. |
RATE_LIMITED | 429 | Too many requests. Back off and retry with a growing delay; the app-api budget is shared across your whole account. |
INTERNAL | 500 | A platform fault. Retry once with the same Idempotency-Key so you are not billed twice. |
1. A tiny client helper
Two headers on every call: the bearer token and the app slug. Success is always {"ok": true, "data": {...}}; a failure carries error.code, so branch on the code and not on the message text.
# Every call needs two things: the app slug and a bearer token.
# Keep the token in a shell variable so it never lands in your history.
SLUG="entry-desk"
TOKEN="YOUR_TOKEN" # from https://entry-desk.skillsafe.ai/tokens.html
BASE="https://api.skillsafe.ai/v1/app-api"
# A tiny helper: $1 is the path, $2 is the JSON body (optional).
ssapp() {
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
fi
}
import json
import urllib.request
SLUG = "entry-desk"
TOKEN = "YOUR_TOKEN" # from https://entry-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError(Exception):
"""Carries the platform's error code so callers can branch on it."""
def __init__(self, code, message, details=None):
super().__init__(f"{code}: {message}")
self.code, self.message, self.details = code, message, details
def call(path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if data else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
err = payload.get("error") or {}
raise AppError(err.get("code", "unknown"), err.get("message", str(e)), err.get("details"))
# Success is always {"ok": true, "data": {...}}.
return payload["data"]
const SLUG = "entry-desk";
const TOKEN = "YOUR_TOKEN"; // from https://entry-desk.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details;
}
}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (!res.ok) {
const e = payload.error || {};
throw new AppError(e.code || "unknown", e.message || res.statusText, e.details);
}
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
slug = "entry-desk"
token = "YOUR_TOKEN" // from https://entry-desk.skillsafe.ai/tokens.html
base = "https://api.skillsafe.ai/v1/app-api"
)
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+"/"+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
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.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public final class EntryDesk {
static final String SLUG = "entry-desk";
static final String TOKEN = "YOUR_TOKEN"; // from https://entry-desk.skillsafe.ai/tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/** Returns the raw JSON body. Use your JSON library of choice to read it. */
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new IllegalStateException("HTTP " + res.statusCode() + ": " + res.body());
}
return res.body();
}
}
require "json"
require "net/http"
require "uri"
SLUG = "entry-desk"
TOKEN = "YOUR_TOKEN" # from https://entry-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError < StandardError
attr_reader :code, :details
def initialize(code, message, details = nil)
super("#{code}: #{message}")
@code = code
@details = details
end
end
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless res.is_a?(Net::HTTPSuccess)
e = payload["error"] || {}
raise AppError.new(e["code"] || "unknown", e["message"] || res.message, e["details"])
end
payload["data"]
end
<?php
const SLUG = "entry-desk";
const TOKEN = "YOUR_TOKEN"; // from https://entry-desk.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Exception {
public string $errorCode;
public $details;
public function __construct(string $code, string $message, $details = null) {
parent::__construct("$code: $message");
$this->errorCode = $code;
$this->details = $details;
}
}
function call(string $path, ?array $body = null) {
$headers = ["Authorization: Bearer " . TOKEN, ];
$opts = ["http" => ["method" => $body === null ? "GET" : "POST",
"ignore_errors" => true]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . "/" . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (isset($payload["error"])) {
$e = $payload["error"];
throw new AppError($e["code"] ?? "unknown", $e["message"] ?? "request failed",
$e["details"] ?? null);
}
return $payload["data"];
}
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class EntryDesk
{
const string Slug = "entry-desk";
const string Token = "YOUR_TOKEN"; // from https://entry-desk.skillsafe.ai/tokens.html
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> Call(string path, object body = null)
{
var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
var payload = JsonDocument.Parse(text).RootElement;
if (payload.TryGetProperty("error", out var err))
{
throw new InvalidOperationException(
$"{err.GetProperty("code").GetString()}: {err.GetProperty("message").GetString()}");
}
return payload.GetProperty("data");
}
}
2. Who am I, and can I afford it
GET /me is free. subject_type is user for a personal token and guest for an anonymous one. Only a personal token can run a metered lane, and credits is the balance you compare the hold against.
ssapp me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":184250,
# "app":{"slug":"entry-desk","model":"gpt-5.6-terra","markup_bps":1000}}}
me = call("me")
print(me["subject_type"], me["credits"], "credits")
# subject_type is "user" for a personal token and "guest" for an anonymous one.
# Only a personal token can run a metered lane.
const me = await call("me");
console.log(me.subject_type, me.credits, "credits");
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits, "credits")
String me = EntryDesk.call("me", null);
System.out.println(me);
me = call("me")
puts "#{me["subject_type"]} #{me["credits"]} credits"
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], " credits\n";
var me = await EntryDesk.Call("me");
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} " +
$"{me.GetProperty("credits").GetInt32()} credits");
3. Price it before you run it
POST /estimate is free and creates no job. It returns the model binding and hold_credits - the amount reserved, which is almost always more than the settled charge because the hold prices the full output cap. The hold differs per lane, so re-estimate whenever you change task.
# The input shape is documented in full below. `task` comes first: it selects the lane.
read -r -d '' INPUT <<'JSON'
{
"task": "triage",
"period": "2026-07",
"register": "description\tvendor\ttype\tamount\tdate\tsupport\nQ3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142",
"coa": "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
"convention": "days",
"materiality": "1000.00"
}
JSON
ssapp estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":3120,"min_credits":420,"sponsor_enabled":false}}
#
# estimate is FREE and creates no job. hold_credits is what gets RESERVED, not
# what you pay - settlement charges what the run actually used.
register = (
"description\tvendor\ttype\tamount\tdate\tsupport\n"
"Q3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142"
)
run_input = {
"task": "triage",
"period": "2026-07",
"register": register,
"coa": "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
"convention": "days",
"materiality": "1000.00",
}
est = call("estimate", run_input)
assert est["model_alias"] == "gpt-terra"
print("reserves", est["hold_credits"], "credits; minimum", est["min_credits"])
# The hold differs per lane, so re-estimate whenever you change `task`.
if me["credits"] < est["min_credits"]:
raise SystemExit("balance below the minimum - top up before running")
const register =
"description\tvendor\ttype\tamount\tdate\tsupport\n" +
"Q3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142";
const runInput = {
task: "triage",
period: "2026-07",
register,
coa: "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
convention: "days",
materiality: "1000.00"
};
const est = await call("estimate", runInput);
console.log("reserves", est.hold_credits, "credits");
runInput := map[string]any{
"task": "triage",
"period": "2026-07",
"register": "description\tvendor\ttype\tamount\tdate\tsupport\nQ3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142",
"coa": "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
"convention": "days",
"materiality": "1000.00",
}
raw, err = call("estimate", runInput)
if err != nil {
panic(err)
}
var est struct {
ModelAlias string `json:"model_alias"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
_ = json.Unmarshal(raw, &est)
fmt.Println("reserves", est.HoldCredits, "credits on", est.ModelAlias)
String input = """
{
"task": "triage",
"period": "2026-07",
"register": "description\\tvendor\\ttype\\tamount\\tdate\\tsupport\\nQ3 audit fieldwork complete\\tHelm & Roe LLP\\tap accrual\\t48000.00\\t2026-07-31\\tEL-2026-0142",
"coa": "6410\\tProfessional fees\\texpense\\n2110\\tAccrued liabilities\\tliability",
"convention": "days",
"materiality": "1000.00"
}
""";
String est = EntryDesk.call("estimate", input);
System.out.println(est);
run_input = {
"task" => "triage",
"period" => "2026-07",
"register" => "description\tvendor\ttype\tamount\tdate\tsupport\n" \
"Q3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142",
"coa" => "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
"convention" => "days",
"materiality" => "1000.00"
}
est = call("estimate", run_input)
puts "reserves #{est["hold_credits"]} credits on #{est["model_alias"]}"
$runInput = [
"task" => "triage",
"period" => "2026-07",
"register" => "description\tvendor\ttype\tamount\tdate\tsupport\n"
. "Q3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142",
"coa" => "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
"convention" => "days",
"materiality" => "1000.00",
];
$est = call("estimate", $runInput);
echo "reserves ", $est["hold_credits"], " credits\n";
var runInput = new Dictionary<string, object>
{
["task"] = "triage",
["period"] = "2026-07",
["register"] = "description\tvendor\ttype\tamount\tdate\tsupport\n" +
"Q3 audit fieldwork complete\tHelm & Roe LLP\tap accrual\t48000.00\t2026-07-31\tEL-2026-0142",
["coa"] = "6410\tProfessional fees\texpense\n2110\tAccrued liabilities\tliability",
["convention"] = "days",
["materiality"] = "1000.00",
};
var est = await EntryDesk.Call("estimate", runInput);
Console.WriteLine($"reserves {est.GetProperty("hold_credits").GetInt32()} credits");
4. Run it, and poll to terminal
POST /run returns a job_id. Send an Idempotency-Key on every run, derived from the task, the register, the period and an attempt counter - a retry after a network blip then returns the first job instead of billing a second one. Poll GET /jobs/{id} with a growing delay; never tight-loop.
# Send an Idempotency-Key on EVERY run. Derive it from (task, register, period,
# attempt) so a retried request after a network blip returns the first job rather
# than billing a second one.
KEY="entry-desk:triage:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal. Back off; do not tight-loop.
while :; do
STATE=$(ssapp "jobs/$JOB")
STATUS=$(printf '%s' "$STATE" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { echo "$STATE"; exit 1; }
sleep 2
done
printf '%s' "$STATE" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
# The output is ONE JSON object - the envelope documented below.
import hashlib
import time
# Idempotency-Key on every run, including any retry of the same input.
seed = json.dumps([run_input["task"], run_input["register"], run_input["period"]])
key = f'entry-desk:{run_input["task"]}:{hashlib.sha256(seed.encode()).hexdigest()[:16]}:a1'
def run_and_wait(run_input, key, timeout=180):
body = json.dumps(run_input).encode()
req = urllib.request.Request(f"{BASE}/run", data=body, method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
deadline = time.time() + timeout
delay = 1.0
while time.time() < deadline:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
return job
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "the run failed")
time.sleep(delay)
delay = min(delay * 1.5, 5.0) # back off, never tight-loop
raise TimeoutError(job_id)
job = run_and_wait(run_input, key)
result = json.loads(job["output"]["output"])
print(result["verdict"])
print("charged", job.get("charged_credits"), "credits")
if job.get("truncated"):
print("the answer was cut short by the available balance")
const enc = new TextEncoder().encode(
JSON.stringify([runInput.task, runInput.register, runInput.period]));
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
const key = `entry-desk:${runInput.task}:${digest}:a1`;
const res = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(runInput)
});
const { data: { job_id } } = await res.json();
let job, delay = 1000;
for (;;) {
job = await call(`jobs/${job_id}`);
if (job.status === "succeeded") break;
if (job.status === "failed") throw new Error(job.error || "the run failed");
await new Promise(r => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 5000);
}
const result = JSON.parse(job.output.output);
console.log(result.verdict, "- charged", job.charged_credits, "credits");
seed, _ := json.Marshal([]any{runInput["task"], runInput["register"], runInput["period"]})
sum := sha256.Sum256(seed)
key := fmt.Sprintf("entry-desk:%v:%x:a1", runInput["task"], sum[:8])
body, _ := json.Marshal(runInput)
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct{ JobID string `json:"job_id"` } `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
delay := time.Second
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
ChargedCredits int `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output)
fmt.Println("charged", job.ChargedCredits, "credits")
break
}
if job.Status == "failed" {
panic("the run failed")
}
time.Sleep(delay)
if delay < 5*time.Second {
delay = delay * 3 / 2 // back off, never tight-loop
}
}
// Idempotency-Key on every run: (task, register, period, attempt).
String seed = "triage|" + input.hashCode() + "|2026-07";
String key = "entry-desk:triage:" + Integer.toHexString(seed.hashCode()) + ":a1";
HttpRequest start = HttpRequest.newBuilder(URI.create(EntryDesk.BASE + "/run"))
.header("Authorization", "Bearer " + EntryDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = EntryDesk.CLIENT
.send(start, HttpResponse.BodyHandlers.ofString()).body();
// Read job_id out of `started`, then poll GET jobs/{id} with a growing delay
// until status is "succeeded" or "failed".
System.out.println(started);
require "digest"
seed = JSON.generate([run_input["task"], run_input["register"], run_input["period"]])
key = "entry-desk:#{run_input["task"]}:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a1"
uri = URI("#{BASE}/run")
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)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
delay = 1.0
loop do
job = call("jobs/#{job_id}")
if job["status"] == "succeeded"
result = JSON.parse(job["output"]["output"])
puts result["verdict"]
puts "charged #{job["charged_credits"]} credits"
break
end
raise "the run failed" if job["status"] == "failed"
sleep delay
delay = [delay * 1.5, 5.0].min
end
$seed = json_encode([$runInput["task"], $runInput["register"], $runInput["period"]]);
$key = "entry-desk:" . $runInput["task"] . ":" . substr(hash("sha256", $seed), 0, 16) . ":a1";
$headers = ["Authorization: Bearer " . TOKEN,
"Content-Type: application/json", "Idempotency-Key: " . $key];
$opts = ["http" => ["method" => "POST", "ignore_errors" => true,
"header" => implode("\r\n", $headers),
"content" => json_encode($runInput)]];
$started = json_decode(file_get_contents(BASE . "/run", false,
stream_context_create($opts)), true);
$jobId = $started["data"]["job_id"];
$delay = 1;
while (true) {
$job = call("jobs/$jobId");
if ($job["status"] === "succeeded") {
$result = json_decode($job["output"]["output"], true);
echo $result["verdict"], "\n";
echo "charged ", $job["charged_credits"], " credits\n";
break;
}
if ($job["status"] === "failed") {
throw new RuntimeException("the run failed");
}
sleep($delay);
$delay = min($delay * 2, 5);
}
using System.Security.Cryptography;
var seed = JsonSerializer.Serialize(new[] {
runInput["task"], runInput["register"], runInput["period"] });
var digest = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(seed)))[..16].ToLowerInvariant();
var key = $"entry-desk:{runInput["task"]}:{digest}:a1";
var start = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run");
start.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
start.Headers.Add("Idempotency-Key", key);
start.Content = new StringContent(JsonSerializer.Serialize(runInput),
Encoding.UTF8, "application/json");
var started = JsonDocument.Parse(
await (await Client.SendAsync(start)).Content.ReadAsStringAsync()).RootElement;
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
var delay = 1000;
while (true)
{
var job = await EntryDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded")
{
var result = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict").GetString());
break;
}
if (status == "failed") throw new InvalidOperationException("the run failed");
await Task.Delay(delay);
delay = Math.Min(delay * 3 / 2, 5000);
}
5. Or stream it
POST /run-stream takes the same body over Server-Sent Events and is what the app itself uses, so the progress card can advance as sections arrive. Concatenate every delta.text in order to rebuild the single JSON object. If the stream dies, keep what arrived - the sections that completed are still usable.
# run-stream is the same input over Server-Sent Events. The app itself uses this
# so it can advance the progress card as sections arrive.
curl -sSN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"lane\":\"triage\","}
# event: delta data: {"text":"\"period\":\"2026-07\","}
# event: done data: {"charged_credits":2840,"truncated":false}
#
# Concatenate every delta.text in order to rebuild the one JSON object.
def run_stream(run_input, key, on_delta):
"""Yields the assembled output. on_delta(text) is called as chunks arrive."""
body = json.dumps(run_input).encode()
req = urllib.request.Request(f"{BASE}/run-stream", data=body, method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw, event, done = "", None, {}
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
raw += payload["text"]
on_delta(payload["text"])
elif event == "done":
done = payload
return raw, done
raw, done = run_stream(run_input, key, lambda t: None)
result = json.loads(raw) # one JSON object, assembled from the deltas
print(result["title"], "-", done.get("charged_credits"), "credits")
# Partial results are worth keeping: if the stream dies, whatever arrived in `raw`
# still contains the sections that completed. Do not discard it.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
"Accept": "text/event-stream"
},
body: JSON.stringify(runInput)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = {};
while (true) {
const { value, done: finished } = await reader.read();
if (finished) 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(7);
else if (line.startsWith("data: ")) {
const payload = JSON.parse(line.slice(6));
if (event === "delta") raw += payload.text;
else if (event === "done") done = payload;
}
}
}
const result = JSON.parse(raw);
console.log(result.title, "-", done.charged_credits, "credits");
body, _ = json.Marshal(runInput)
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw.WriteString(d.Text)
}
}
fmt.Println(raw.String())
HttpRequest stream = HttpRequest.newBuilder(URI.create(EntryDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + EntryDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
EntryDesk.CLIENT.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ") && "delta".equals(event[0])) {
// read the "text" field out of the data payload and append it
raw.append(line.substring(6));
}
});
System.out.println(raw);
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["Accept"] = "text/event-stream"
req.body = JSON.generate(run_input)
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|
line = line.chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << JSON.parse(line[6..])["text"]
end
end
end
end
end
result = JSON.parse(raw)
puts result["title"]
$headers = ["Authorization: Bearer " . TOKEN,
"Content-Type: application/json", "Idempotency-Key: " . $key,
"Accept: text/event-stream"];
$opts = ["http" => ["method" => "POST", "header" => implode("\r\n", $headers),
"content" => json_encode($runInput)]];
$fh = fopen(BASE . "/run-stream", "r", false, stream_context_create($opts));
$raw = "";
$event = null;
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\n");
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"];
}
}
fclose($fh);
$result = json_decode($raw, true);
echo $result["title"], "\n";
var stream = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = new StringContent(JsonSerializer.Serialize(runInput),
Encoding.UTF8, "application/json");
var res = await Client.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string evt = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var payload = JsonDocument.Parse(line[6..]).RootElement;
raw.Append(payload.GetProperty("text").GetString());
}
}
var result = JsonDocument.Parse(raw.ToString()).RootElement;
Console.WriteLine(result.GetProperty("title").GetString());
The input fields
Taken from app.js, which is what actually builds the payload — not from intent. task is documented first because it is the only field that changes the shape of what comes back.
| field | type | meaning |
|---|---|---|
task | string, required | The lane. One of triage, entries or schedule. Documented first because everything else is shared: it is the only field that changes what comes back. |
register | string, required | The register of unbooked items - one row per item, delimited by tab, comma, semicolon, pipe or aligned whitespace, with or without a header row. This is the one work object all three lanes read. |
period | string, required | The close period as YYYY-MM. The app also accepts Jul-2026 and July 2026 in the UI and normalises them before sending. |
coa | string, optional | Your chart of accounts: one account per line as code, name and optionally its kind (expense, asset, liability, revenue). Supplying it is what lets account codes be validated rather than merely echoed. |
convention | string, optional | days (default) or months - how a term-dated item is prorated across the period. |
materiality | string, optional | A two-decimal amount. Items under it are flagged, never dropped, and a systematic schedule is never suspended by it. |
prescan | object, optional but strongly recommended | The deterministic facts the browser computed: every item typed and priced, each one's computed period portion and method, its full schedule, and the flags array. Sending it is what makes the model accountable - it must return one reconciliation entry per flag entry, and its amounts are checked against these. |
triage_verdicts | object, optional | Only meaningful on the entries lane: a map of register row number to the verdict a previous triage run gave it. With it, only rows marked book get an entry. This is the handoff. |
clip_note | string, optional | Set when the register was longer than one run can carry, telling the model how many rows were profiled locally versus sent so it does not invent the rest. |
retry_note | string, optional | Set only on the automatic reformat retry, describing what was wrong with the previous reply. The retry reuses an idempotency key derived from the same input. |
The output contract
One JSON object, no prose and no code fence. The envelope is identical across all three lanes; only body differs. Every array is present even when empty, and every amount is a plain two-decimal string with no currency symbol and no thousands separator.
{
"lane": "triage",
"lane_inferred": false,
"period": "2026-07",
"title": "one line naming the batch and the period",
"posture": "ready-to-review | queries-outstanding | blocked",
"verdict": "one sentence a controller could read on its own",
"summary": "3-6 sentences",
"item_count": 8,
"assumptions": ["..."],
"open_questions": ["..."],
"findings": [
{"id": "ED-001", "row": 6, "severity": "critical | high | medium | low",
"title": "...", "why": "...", "fix": "..."}
],
"reconciliation": [
{"flag_id": "JE-DUPE", "row": 7,
"status": "confirmed | noted | set-aside | superseded",
"finding_id": "ED-001", "note": "..."}
],
"next_lane": {"lane": "entries", "reason": "..."},
"body": { }
}
A note on skipped_rows[].this_period_not_booked_here in the entries lane: it is that row's own computed period figure, so that totals.debit plus the sum of those values equals prescan.total_this_period. That identity is a completeness check — is every row either booked or explicitly listed — and not an expected posting amount. It sums every row, so a duplicated row inflates it by construction. Do not show it to a user as a target the batch failed to reach. The field is named for the row rather than the period because in the duplicate case the period does receive the amount, just from the other occurrence.
A note on reconciliation: it answers the prescan.flags you sent, one entry per flag entry, keyed on flag_id and row. The same flag_id legitimately appears on several rows, and the right status can differ per row — a round amount backed by an engagement letter is set-aside while the same flag on an unsupported estimate is confirmed. finding_id is many-to-one: one finding may answer several flag entries.
body — the triage lane
One entry per register row, in register order, none omitted.
"body": {
"items": [
{"row": 1,
"description": "echo of the register's description",
"type": "ap_accrual | prepaid | depreciation | payroll | deferred_revenue | reclass | lease | unknown",
"type_agrees_with_prescan": true,
"verdict": "book | defer | reject | query",
"belongs_in_period": "2026-07",
"cutoff_test": "the date that decides the period, and what it decides",
"materiality_test": "the amount against the floor",
"approver": "Accounting manager",
"amount_this_period": "16000.00",
"reasoning": "2-4 sentences",
"confidence": "high | medium | low"}
],
"counts": {"book": 6, "defer": 0, "reject": 1, "query": 1},
"batch_total_this_period": "116478.58"
}
body — the entries lane
Debits equal credits on every entry. The app checks it in cents.
"body": {
"entries": [
{"row": 2,
"title": "July release of the D&O premium",
"basis": "120000.00",
"basis_source": "policy POL-88231, 12-month term 2026-07-15 to 2027-07-14",
"lines": [
{"account": "6800", "account_name": "Insurance expense", "department": "G&A",
"debit": "5589.04", "credit": "", "memo": "Jul 2026 release"},
{"account": "1420", "account_name": "Prepaid expenses", "department": "G&A",
"debit": "", "credit": "5589.04", "memo": "Jul 2026 release"}
],
"total_debit": "5589.04",
"total_credit": "5589.04",
"memo": "audit-grade: what, whose, which period, per what support",
"support": ["POL-88231"],
"approver": "Accounting manager",
"reverses": false,
"reversal_date": "",
"review_notes": ["..."]}
],
"totals": {"debit": "21589.04", "credit": "21589.04"},
"skipped_rows": [
{"row": 6, "reason": "dated in the prior month", "this_period_not_booked_here": "25000.00"}
]
}
body — the schedule lane
The periods must sum to the basis. The app recomputes this in cents and will contradict a false claim.
"body": {
"schedules": [
{"row": 2,
"accrual_name": "D&O insurance premium",
"basis": "120000.00",
"basis_derivation": "the contractual full-term amount, from policy POL-88231",
"support_reference": "POL-88231",
"convention": "days",
"term_start": "2026-07-15",
"term_end": "2027-07-14",
"period_portion": "5589.04",
"already_booked": "0.00",
"this_period_accrual": "5589.04",
"periods": [
{"period": "2026-07", "amount": "5589.04", "note": "17 of 365 days, part month"},
{"period": "2026-08", "amount": "10191.78", "note": ""}
],
"sums_to_basis": true,
"reversal_note": "does not reverse; the balance releases over the term",
"draft_je": "Dr 6800 Insurance expense 5589.04 / Cr 1420 Prepaid expenses 5589.04 - Jul 2026 release per POL-88231"}
],
"release_total_this_period": "30949.31",
"unschedulable": [
{"row": 1, "reason": "an AP accrual with no term - a single-period item"}
]
}
Rate limits and good manners
/estimateand/meare free and create no job. Call them freely; debounce anyway if a keystroke triggers them./runand/run-streamare metered. Send anIdempotency-Keyon every one, and reuse it on a retry of the same input.- On
429, back off with a growing delay. The budget is shared across your whole account, including the web app. - Poll
/jobs/{id}no faster than once a second, growing to five.
What this API will not do
- It does not post anything. There is no ledger connection and no write path to any accounting system. Every response is a draft for review.
- It is not accounting, audit or tax advice.
- It does no lease accounting and no currency translation; both are refused explicitly rather than approximated.
- It does not do the deterministic arithmetic for you. The prorations, the cent-exact schedules and the twenty checks run in the browser. Over the API you send them as
prescanor you get an answer nothing is holding to account.