Drive Tieout Desk from your own code
Everything the web page does is available over HTTP. Send the tie-out facts the browser computes
from one limited partner's capital account statement and the fund's NAV pack for the same period,
and get the same review back: a release verdict, an explanation, owner and blocking flag for every
break, the questions for the fund administrator and a note for the review file. The natural use is
the last step of a quarter-end close. After the statements are produced, a script ties each one to
the NAV pack, files the release note with the statement, and holds the batch when a verdict is not
release.
One thing to be clear about before the first call: the model never does the arithmetic.
Both documents are read, footed, allocated and compared line by line by tieout.js, the
same file the web page loads, and the result is sent as facts, a JSON string.
The model's job is judgement over those facts. See building the facts below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest endpoint takes {"slug":"tieout-desk"} in its
body), so no slug header is needed afterwards. Send your token as Authorization: Bearer …
on every call.
The input object IS the request body. There is no {"input": …} wrapper.
A wrapped body returns a 200 with an unknown field 'input' warning, and the model never
sees your facts.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
A guest token can call /me and /estimate. A tie-out review is metered, so it needs a personal token from
signing in.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://tieout-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a tie-out review needs a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"tieout-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered tie-out review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "tieout-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered tie-out review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "tieout-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered tie-out review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"tieout-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered tie-out review.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"tieout-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered tie-out review.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "tieout-desk" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered tie-out review.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "tieout-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://tieout-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered tie-out review.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"tieout-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that adds the headers, unwraps data and raises on error.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="tieout-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://tieout-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
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, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "tieout-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://tieout-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
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)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "tieout-desk";
const TOKEN = "YOUR_TOKEN"; // from https://tieout-desk.skillsafe.ai/tokens.html
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 (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "tieout-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://tieout-desk.skillsafe.ai/tokens.html
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(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
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.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class TieoutDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "tieout-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
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.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "tieout-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://tieout-desk.skillsafe.ai/tokens.html
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 = body.to_json
end
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']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "tieout-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class TieoutDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "tieout-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me tells you whether the token is a guest or a person, and what the balance is.
subject_type is guest or user — a guest can
price a run but cannot start one — and credits is the wallet balance in credits.
Compare it against min_credits from the next step before you run, so a shortfall
surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.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)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await TieoutDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the review (free)
The input object is exactly what the app's form submits. The first field is
task. This app has one lane, so it is always tieout. A missing
or unknown task is still answered as tieout, and the reply's
lane field says so.
task | what it does | the shape you get back |
|---|---|---|
tieout | Reviews the tie-out facts: decides whether the statement can go to the LP, explains every break with a cause and an owner, lists what could not be checked, writes the administrator queries and a file note. | verdict (release, hold or cannot_tie), headline, basis_read, breaks, not_checked, admin_queries, release_note, summary. |
| field | type | what goes in it |
|---|---|---|
task | string, required | "tieout" |
facts | string, required | The JSON-encoded output of Tieout.buildFacts: the tie-out lines, the shares, the fee basis, the tolerance, the flags, the counts, the verdict floor and the unexplained breaks. A string, not an object. |
lp_name | string | The limited partner the statement is for. |
fund_name | string | The fund whose NAV pack you tied to. |
period | string | The reporting period, in words (Q1 2026 (1 Jan - 31 Mar 2026)). |
question | string | What you want to know, in one or two sentences. Answered in the headline or summary; it never overrides the rules. |
retry_note | string | Only when resending after an unparseable reply, or to ask for a shorter one. |
The app declares an input schema with task and facts required and every
field a string. So a correct call to /estimate or /run returns
input_checked: true and an empty warnings array. Any warning means the
body is wrong. Warnings never stop a run, so check them before you pay.
Building the facts
The page computes facts in your browser before any model runs. It reads the LP statement
and the NAV pack, foots each one on itself (lines F1 and F2), allocates
every fund-level component to the LP (ownership share for income, expenses, gains and carry;
commitment share for contributions and distributions; commitment x annual rate for a rate-based
management fee), and compares each result with the statement. An API caller must build
facts the same way. The object carries:
lines: one entry per component, each withid,component,label(as printed),status(agrees,rounding,break,missing,not_checkable),lp_value,lp_effect(its effect on capital),expected,diff(lp_effectminusexpected),fund_value,share,basisandhints(the causes the browser proved, as"code: detail"), and sometimesimplied_share,implied_rate_pctandparts.shares:cap_shareandcap_source,commit_shareandcommit_source,implied_begin_share,implied_end_share,commitment,total_commitments. Shares are fractions, so 0.038 is 3.8%.flags(code, severity, detail),countsper status,verdict_floor(the strictest verdict the arithmetic already forces) andunexplained(ids of breaks with no proved cause).tolerance,rounding_limit,fee_basis(rateorpro_rata),fee_rate,months,column_usedandcolumn_name, pluslp_name,fund_nameandperiod.
tieout.js is plain JavaScript with no dependencies and exports itself to node. Download
it from this app and build the body with the same code the page runs:
// make-body.js - build the request body exactly as the web page does.
// Download https://tieout-desk.skillsafe.ai/tieout.js next to this file first.
const fs = require("fs");
const T = require("./tieout.js");
const lpText = fs.readFileSync("lp-statement.txt", "utf8"); // the LP capital account statement, as pasted
const navText = fs.readFileSync("nav-pack.txt", "utf8"); // the fund's NAV pack for the same period
const profile = T.analyze(lpText, navText, {
col: 0, // which numeric column of the statement to read (0 = the first)
tolerance: "1.00", // agrees within this; the rounding limit is max(10 x tolerance, 10)
fee_basis: "rate", // "rate" = commitment x fee_rate% x months/12; "pro_rata" = share of the fund's fee line
fee_rate: "1.5",
months: "3",
ownership: "", // empty = use the share the statement states
});
const body = T.buildInput({
profile,
lp_name: "Alder Family Office LLC",
fund_name: "Cedar Ridge Credit Opportunities Fund II, L.P.",
period: "Q1 2026 (1 Jan - 31 Mar 2026)",
question: "The fee side letter says 1.5% on commitment. Is the statement right to go out?",
});
process.stdout.write(JSON.stringify(body)); // {task, facts, lp_name, fund_name, period, question}
The worked example, the Cedar Ridge statement the page ships as an example, is this body (the facts string shown decoded and shortened to two of its eleven lines):
{
"task": "tieout",
"facts": "<the object below, JSON-encoded as ONE string>",
"lp_name": "Alder Family Office LLC",
"fund_name": "Cedar Ridge Credit Opportunities Fund II, L.P.",
"period": "Q1 2026 (1 Jan - 31 Mar 2026)",
"question": "The fee side letter says 1.5% on commitment. Is the statement right to go out?"
}
{
"lp_name": "Alder Family Office LLC",
"fund_name": "Cedar Ridge Credit Opportunities Fund II, L.P.",
"period": "Q1 2026 (1 Jan - 31 Mar 2026)",
"column_used": 1,
"column_name": "",
"tolerance": 1,
"rounding_limit": 10,
"fee_basis": "rate",
"fee_rate": 1.5,
"months": 3,
"shares": {
"cap_share": 0.038,
"cap_source": "stated: Ownership",
"commit_share": 0.04,
"commit_source": "LP commitment / total commitments",
"implied_begin_share": 0.038,
"implied_end_share": 0.038059,
"commitment": 10000000,
"total_commitments": 250000000
},
"lines": [
{
"id": "L6",
"component": "Management fee",
"label": "Management fee",
"status": "break",
"lp_value": -35625,
"lp_effect": -35625,
"expected": -37500,
"diff": 1875,
"fund_value": -937500,
"share": null,
"basis": "commitment 10,000,000.00 x 1.5% x 3/12",
"hints": [
"fee_basis: the statement's fee equals the fund's fee line x the ownership share 3.8% rather than the commitment rate"
],
"implied_share": 0.038,
"implied_rate_pct": 1.425
},
{
"id": "L9",
"component": "Ending capital",
"label": "Closing capital",
"status": "break",
"lp_value": 7126595,
"lp_effect": 7126595,
"expected": 7124720,
"diff": 1875,
"fund_value": 187252500,
"share": null,
"basis": "LP beginning capital plus every recomputed component",
"hints": [
"rollup: equals the differences on L4 (-12,000.00), L6 (1,875.00), L7 (12,000.00) carried into ending capital, which sum to 1,875.00: nothing new is wrong on this line"
]
},
{
"...": "9 more lines: L1-L5, L7, L8, F1, F2"
}
],
"flags": [],
"counts": {
"agrees": 7,
"rounding": 0,
"break": 4,
"missing": 0,
"not_checkable": 0
},
"verdict_floor": "hold",
"unexplained": []
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# the node snippet above, or take the worked example from this page.
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.
INPUT = json.load(open("body.json")) # task, facts, lp_name, fund_name, period, question
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation against the full output
# cap, not the price of the run.
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
_ = json.Unmarshal(raw, &input)
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
System.out.println(call("estimate", input));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":...,"min_credits":...,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
var est = await TieoutDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
/estimate is free: it creates no job and charges nothing. hold_credits is
what a run would reserve against the full output cap, not the price; the real price is
charged_credits on the finished job, normally much lower.
5. Run it, then poll
A review is metered, so POST /run needs a signed-in (personal) token;
a guest token gets a 403. /run returns a job_id; poll
GET jobs/{job_id} until status is succeeded or
failed. The reply is the string at data.output.output. The terminal job
also carries charged_credits (the real price) and the truncated flag.
Always send an Idempotency-Key on /run and
/run-stream. Derive it from the input as the web app does, with the lane and an attempt
counter: tieout-desk:tieout:<hash>:a1. A retried request with the same key returns
the same job instead of billing a second run. Replaying a key with a different body is a
409, so bump the attempt suffix when you resend a changed body.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="tieout-desk:tieout:$(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 sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"tieout\",\"verdict\":\"hold\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"tieout-desk:tieout:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), 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"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
review = json.loads(job["output"]["output"])
print(review["verdict"], [(b["line"], b["cause"]) for b in review["breaks"]])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `tieout-desk:tieout:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const review = JSON.parse(job.output.output);
console.log(review.verdict, review.breaks.map((b) => b.line + " " + b.cause), job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("tieout-desk:tieout:%x:a1", sum[:8])
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()
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output, job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "tieout-desk:tieout:" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
require "digest"
key = "tieout-desk:tieout:#{Digest::SHA256.hexdigest(INPUT.to_json)[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 = INPUT.to_json
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
review = JSON.parse(job["output"]["output"])
puts review["verdict"], review["breaks"].map { |b| "#{b['line']} #{b['cause']}" }.inspect
<?php
$key = "tieout-desk:tieout:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$review = json_decode($job["output"]["output"], true);
echo $review["verdict"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "tieout-desk:tieout:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await TieoutDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var review = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(review.GetProperty("verdict"));
6. Or stream it
POST /run-stream is the same call over server-sent events, with the same
Idempotency-Key. Each delta event carries {"text": "..."}, a
chunk of the reply, and the final done event carries status,
output.output (the whole reply), charged_credits and
truncated. Read the reply from done when it is there and fall back to the
concatenated deltas. A browser client may receive progress ticks rather than text deltas; the
finished job from step 5 always has the whole reply.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -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 {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"tieout\",\"verdict\":\"hold\","}
# event: done {"status":"succeeded","output":{"output":"<the whole reply>"},
# "charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw # the whole reply
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
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(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const text = (done && done.output && done.output.output) || raw; // the whole reply
console.log(done.status, done.charged_credits, done.truncated);
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
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.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[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
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"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
data.output.output (or output.output on the done event) is a
string holding one JSON object. The web app strips any code fence, takes everything from the first
{ to the last }, parses it and normalizes it: an unknown
verdict falls back to cannot_tie, an unknown cause to
unknown, an unknown owner to fund_admin, line ids are
upper-cased, and an entry with no valid line id (L4, F1) is dropped. Then
it checks the reply against the facts it sent. You should do the same.
# The reply is a string inside the envelope; review.json (step 5) holds it.
python3 - <<'EOF'
import json
t = open("review.json").read().strip()
t = t[t.index("{"): t.rindex("}") + 1] # drop any code fence
r = json.loads(t)
print(r["verdict"], "-", r["headline"])
for b in r["breaks"]:
print(" ", b["line"], b["cause"], b["owner"], "BLOCKS" if b["blocking"] else "")
for n in r["not_checked"]:
print(" not checked", n["line"], n["reason"])
EOF
CAUSES = {"rollup", "rounding", "sign", "allocation_share", "fee_basis", "missing_line", "duplicate",
"units", "classification", "timing", "carry_waterfall", "unknown"}
OWNERS = {"fund_admin", "gp", "investor_relations", "preparer"}
def parse(text):
t = text.strip()
r = json.loads(t[t.index("{"): t.rindex("}") + 1])
if r.get("verdict") not in ("release", "hold", "cannot_tie"):
r["verdict"] = "cannot_tie" # the page's fallback
for k in ("breaks", "not_checked", "admin_queries"):
r[k] = r.get(k) if isinstance(r.get(k), list) else []
for b in r["breaks"]:
b["cause"] = b.get("cause") if b.get("cause") in CAUSES else "unknown"
b["owner"] = b.get("owner") if b.get("owner") in OWNERS else "fund_admin"
b["blocking"] = b.get("blocking") is True or str(b.get("blocking")).lower() == "true"
return r
review = parse(job["output"]["output"])
facts = json.loads(INPUT["facts"])
# The coverage invariant the web page checks first:
need = sorted(l["id"] for l in facts["lines"] if l["status"] in ("break", "missing", "rounding"))
assert sorted(b["line"] for b in review["breaks"]) == need, "every break answered exactly once"
rank = {"release": 0, "hold": 1, "cannot_tie": 2}
assert rank[review["verdict"]] >= rank[facts["verdict_floor"]], "verdict looser than the floor"
const CAUSES = ["rollup", "rounding", "sign", "allocation_share", "fee_basis", "missing_line", "duplicate",
"units", "classification", "timing", "carry_waterfall", "unknown"];
function parse(text) {
const t = text.trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
if (!["release", "hold", "cannot_tie"].includes(r.verdict)) r.verdict = "cannot_tie";
for (const k of ["breaks", "not_checked", "admin_queries"]) r[k] = Array.isArray(r[k]) ? r[k] : [];
for (const b of r.breaks) if (!CAUSES.includes(b.cause)) b.cause = "unknown";
return r;
}
const facts = JSON.parse(INPUT.facts);
const r = parse(job.output.output);
const need = facts.lines.filter((l) => ["break", "missing", "rounding"].includes(l.status)).map((l) => l.id).sort();
console.assert(JSON.stringify(r.breaks.map((b) => b.line).sort()) === JSON.stringify(need), "every break answered exactly once");
text := job.Output.Output
text = text[strings.Index(text, "{") : strings.LastIndex(text, "}")+1]
var review struct {
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Breaks []struct {
Line string `json:"line"`
Cause string `json:"cause"`
Owner string `json:"owner"`
Blocking bool `json:"blocking"`
} `json:"breaks"`
NotChecked []struct {
Line string `json:"line"`
Reason string `json:"reason"`
} `json:"not_checked"`
AdminQueries []string `json:"admin_queries"`
ReleaseNote string `json:"release_note"`
}
_ = json.Unmarshal([]byte(text), &review)
fmt.Println(review.Verdict, len(review.Breaks), "breaks")
// With Jackson: ObjectMapper m = new ObjectMapper();
// JsonNode job = m.readTree(call("jobs/" + jobId, null)).get("data");
// String text = job.get("output").get("output").asText();
// JsonNode r = m.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
// r.get("verdict").asText();
// for (JsonNode b : r.get("breaks")) System.out.println(b.get("line").asText() + " " + b.get("cause").asText());
text = job["output"]["output"]
r = JSON.parse(text[text.index("{")..text.rindex("}")])
puts r["verdict"]
r["breaks"].each { |b| puts "#{b['line']} #{b['cause']} #{b['owner']} blocking=#{b['blocking']}" }
<?php
$text = $job["output"]["output"];
$r = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
echo $r["verdict"], PHP_EOL;
foreach ($r["breaks"] as $b) echo $b["line"], " ", $b["cause"], " ", $b["owner"], PHP_EOL;
var text = job.GetProperty("output").GetProperty("output").GetString()!;
var r = JsonSerializer.Deserialize<JsonElement>(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)]);
Console.WriteLine(r.GetProperty("verdict"));
foreach (var b in r.GetProperty("breaks").EnumerateArray())
Console.WriteLine($"{b.GetProperty("line")} {b.GetProperty("cause")} {b.GetProperty("owner")}");
Invariants worth asserting
- Every line whose status is
break,missingorroundingappears inbreaksexactly once; everynot_checkableline appears innot_checkedexactly once; no line thatagreesappears in either. - A proved cause (
rollup,rounding,sign,allocation_share,fee_basis,missing_line,duplicate,units,classification) is used only when that code is in the line's ownhints. roundingandrollupnever block; every other cause blocks.- The verdict is never less strict than
facts.verdict_floor(release < hold < cannot_tie);releaseonly when nothing blocks. - Every amount in the prose is a value in
facts(a line's figures, a share, a rate, the tolerance, or a figure quoted in abasisorhintsstring).
The output contract
{
"lane": "tieout",
"verdict": "release" | "hold" | "cannot_tie",
"headline": "one sentence: can this statement go to the LP, and why",
"basis_read": "2-4 sentences on the shares and fee basis the check used and any flag that makes them doubtful",
"breaks": [
{"line": "L6", "cause": "fee_basis", "explanation": "...", "action": "...",
"owner": "fund_admin" | "gp" | "investor_relations" | "preparer", "blocking": true}
],
"not_checked": [{"line": "L10", "reason": "..."}],
"admin_queries": ["one specific question to the fund administrator per open point"],
"release_note": "3-6 sentences for the review file",
"summary": "2-3 sentences for the reviewer who reads nothing else"
}
The cause codes
| cause | blocks | meaning |
|---|---|---|
rollup | no | An ending-capital difference that is only the component breaks carried forward. |
rounding | no | Over the tolerance but within the rounding limit. |
sign | yes | The amount is booked with the wrong sign. |
allocation_share | yes | The statement used the other share (ownership instead of commitment, or the reverse). |
fee_basis | yes | The management fee was computed on a different basis or rate. |
missing_line | yes | The NAV pack allocates something the statement does not show. |
duplicate | yes | A line is counted twice. |
units | yes | Thousands against units. |
classification | yes | Two lines whose differences cancel: one amount booked in the wrong line. |
timing | yes | Not proved: a call, distribution or accrual booked in a different period. |
carry_waterfall | yes | Not proved: the carry line, where a waterfall can legitimately differ from pro rata. |
unknown | yes | The numbers do not show the cause. |
The owners
| owner | for |
|---|---|
fund_admin | Allocations, postings and statement production. |
gp | Fee terms, side letters, carry and waterfall questions. |
investor_relations | What the LP is told. |
preparer | A paste or reading problem on the page (wrong column, mis-mapped line). |
Worked example: the Cedar Ridge reply
For the Cedar Ridge body in step 4 the facts carry four breaks, each with a proved cause, and a
verdict_floor of hold. A reply that meets the contract looks like this (the
wording of your reply will differ; the verdict, lines, causes and blocking flags should not):
{
"lane": "tieout",
"verdict": "hold",
"headline": "Hold: the statement ties except for a management fee charged at the ownership share instead of the 1.5% commitment rate and a 12,000 reclass between income and realized gain.",
"basis_read": "Income, expenses and gains were allocated at the stated ownership share of 3.8%, which beginning capital confirms. Contributions and distributions used the commitment share of 4%, from the 10,000,000 commitment over 250,000,000 of total commitments. The management fee was checked on the rate basis, commitment x 1.5% x 3/12. No flags were raised.",
"breaks": [
{
"line": "L4",
"cause": "classification",
"explanation": "Investment income shows 149,500 against a recomputed 161,500, a difference of 12,000. L7 is over by exactly the same 12,000, so one amount of income was booked as realized gain.",
"action": "Move 12,000 from realized gain back to interest and fee income and reissue the statement.",
"owner": "fund_admin",
"blocking": true
},
{
"line": "L6",
"cause": "fee_basis",
"explanation": "The statement charges a management fee of 35,625, which is the fund fee line of 937,500 at the 3.8% ownership share. The side letter basis, 10,000,000 x 1.5% x 3/12, gives 37,500, so the LP was undercharged by 1,875.",
"action": "Recompute the fee on commitment at 1.5% and confirm with the GP that the side letter rate applies.",
"owner": "fund_admin",
"blocking": true
},
{
"line": "L7",
"cause": "classification",
"explanation": "Realized gain shows 57,600 against a recomputed 45,600, over by 12,000. This is the other side of the L4 reclass: the amount belongs in investment income.",
"action": "Reverse the 12,000 out of realized gain together with the L4 correction.",
"owner": "fund_admin",
"blocking": true
},
{
"line": "L9",
"cause": "rollup",
"explanation": "Closing capital of 7,126,595 against a recomputed 7,124,720 follows from the differences on L4, L6 and L7 carried forward, which sum to 1,875. Nothing new is wrong on this line.",
"action": "No separate fix; it clears when L6 is corrected.",
"owner": "fund_admin",
"blocking": false
}
],
"not_checked": [],
"admin_queries": [
"L6: the statement shows a management fee of 35,625 but commitment x 1.5% x 3/12 gives 37,500. Which basis did you apply, and can you confirm the side letter rate?",
"L4 and L7: investment income is 149,500 against 161,500 and realized gain 57,600 against 45,600. Was 12,000 of income posted to realized gain?"
],
"release_note": "Tied the Q1 2026 capital account statement of Alder Family Office LLC to the Cedar Ridge Credit Opportunities Fund II NAV pack at a tolerance of 1. Both documents foot. Seven lines agree. The management fee on L6 differs by 1,875 because it was charged pro rata rather than at 1.5% of commitment, and 12,000 sits in realized gain (L7) instead of investment income (L4). Closing capital differs only by the roll-up of those lines. The statement is held until the administrator corrects the fee and the reclass.",
"summary": "Do not send this statement yet: the fee is 1,875 short of the 1.5% side letter rate and 12,000 of income is classed as realized gain. Both causes are proved, so the fix is known and the statement can go out once they are corrected."
}
8. Use it in CI
The verdict is built to gate on. release means nothing blocks and the statement can go
out. hold means every blocking break has a proved cause, so the fix is known: send the
admin queries and tie again after the correction. cannot_tie means a difference has no
proved cause, a document does not foot, or there is no ownership share.
#!/bin/sh
# Gate a statement batch on the review: fail the job unless the verdict is "release".
set -e
node make-body.js > body.json # the node snippet from step 4
INPUT=$(cat body.json)
KEY="tieout-desk:tieout:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
V=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];print(json.loads(t[t.index("{"):t.rindex("}")+1])["verdict"])')
echo "verdict: $V"
[ "$V" = release ]
verdict = review["verdict"]
print("verdict:", verdict)
for b in review["breaks"]:
if b["blocking"]:
print(" blocks:", b["line"], b["cause"], "->", b["owner"])
raise SystemExit(0 if verdict == "release" else 1)
console.log("verdict:", r.verdict);
for (const b of r.breaks.filter((x) => x.blocking)) console.log(" blocks:", b.line, b.cause, b.owner);
if (r.verdict !== "release") throw new Error(`statement held: ${r.verdict}`);
if review.Verdict != "release" {
fmt.Println("statement held:", review.Verdict)
os.Exit(1)
}
// if (!"release".equals(r.get("verdict").asText())) System.exit(1);
exit(r["verdict"] == "release" ? 0 : 1)
<?php
exit($r["verdict"] === "release" ? 0 : 1);
return r.GetProperty("verdict").GetString() == "release" ? 0 : 1;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true.
What you hold then is a prefix of the reply: the breaks may be complete while
admin_queries, release_note and summary are missing. The web
page closes the cut-off JSON and shows the sections that arrived. From code, check the flag before
you treat a reply as complete. Then resubmit with a retry_note asking for a shorter
reply, and increment the attempt suffix on the Idempotency-Key.