← Tieout Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A 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_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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"}}

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
}

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}}

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.

taskwhat it doesthe shape you get back
tieoutReviews 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.
fieldtypewhat goes in it
taskstring, required"tieout"
factsstring, requiredThe 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_namestringThe limited partner the statement is for.
fund_namestringThe fund whose NAV pack you tied to.
periodstringThe reporting period, in words (Q1 2026 (1 Jan - 31 Mar 2026)).
questionstringWhat you want to know, in one or two sentences. Answered in the headline or summary; it never overrides the rules.
retry_notestringOnly 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:

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.

/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

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}

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

Invariants worth asserting

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

causeblocksmeaning
rollupnoAn ending-capital difference that is only the component breaks carried forward.
roundingnoOver the tolerance but within the rounding limit.
signyesThe amount is booked with the wrong sign.
allocation_shareyesThe statement used the other share (ownership instead of commitment, or the reverse).
fee_basisyesThe management fee was computed on a different basis or rate.
missing_lineyesThe NAV pack allocates something the statement does not show.
duplicateyesA line is counted twice.
unitsyesThousands against units.
classificationyesTwo lines whose differences cancel: one amount booked in the wrong line.
timingyesNot proved: a call, distribution or accrual booked in a different period.
carry_waterfallyesNot proved: the carry line, where a waterfall can legitimately differ from pro rata.
unknownyesThe numbers do not show the cause.

The owners

ownerfor
fund_adminAllocations, postings and statement production.
gpFee terms, side letters, carry and waterfall questions.
investor_relationsWhat the LP is told.
preparerA 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 ]

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.