← LBO Desk / API
Tokens

Drive LBO Desk from your own code

Everything the web page does is available over HTTP. Send the LBO model the browser computes for one leveraged buyout and get the same review back: a verdict, the returns case, the bridge lines that make the money, the assumptions to challenge, one response per flag, structure options backed by the sensitivity grids and hurdle math, diligence questions and an IC summary. The natural use is a buyout screen: a script builds the model for each candidate case (price, leverage, plan, exit), asks for the review, and files the IC summary next to the model.

One thing to be clear about before the first call: the model never does the arithmetic. The LBO model is built by lbo.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 figures. 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":"lbo-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 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://lbo-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; reviewing a buyout 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":"lbo-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="lbo-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://lbo-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 review. A missing or unknown task is still answered as review, and the reply's lane says so.

taskwhat it does
reviewReviews the LBO model: verdict (supportable, stretched, not_supportable), headline, returns case, drivers from the returns bridge, challenges, flag responses, structure options, diligence questions, IC summary and summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredThe JSON-encoded output of LBO.buildFacts: inputs, entry, sources and uses, one object per year of the hold, exit, returns, the returns bridge, credit statistics, hurdle math, four sensitivity grids, flags and rules.
questionstringWhat you want to know, up to 2,000 characters. May be empty. A longer question keeps its beginning and its end, is cut in the middle on word boundaries with a [...] marker, and facts.note_clipped_chars says how much was dropped.
retry_notestringOnly when resubmitting after an unparseable reply: a plain instruction about the reply's shape.

The app declares an input schema with task and facts required, so an estimate of an empty body comes back with missing required field warnings. A warning is not a rejection, and /estimate does no other body validation (a bare string prices as happily as an object), so check the warnings array and the shape yourself before you run. The web app runs every input through LBO.mustBeObject first: it must be a JSON object whose task and facts are both strings.

Building the facts

lbo.js is plain JavaScript with no dependencies and exports itself to node. Download lbo.js next to your script, put the buyout in a JSON file with the field ids below (the page's Save deal .json button writes exactly this file, as {"deal": {...}, "question": "..."}), and let it build the body:

// make-body.js - node make-body.js case.json "your question" > body.json
const fs = require("fs");
const LBO = require("./lbo.js");             // https://lbo-desk.skillsafe.ai/lbo.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = LBO.compute(file.deal || file);
if (!res.ok) throw new Error(res.errors.join(" "));
const body = LBO.mustBeObject(LBO.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));   // {task:"review", facts:"{...}", question:"..."}

The buyout fields (money in $ millions, debt sizes ending in _x are multiples of LTM EBITDA, rates in %; blanks take the defaults shown on the page). LTM revenue, LTM EBITDA, the entry multiple and the holding period are required. A blank exit multiple or exit margin is set equal to the entry value, and facts.defaults_applied says so.

field idmeaning
deal_nameDeal name
companyCompany
ltm_revenueLTM revenue ($m), required
ltm_ebitdaLTM EBITDA ($m), required
entry_multipleEntry multiple (x LTM EBITDA), required
min_cashCash put on the balance sheet at close ($m)
adv_fee_pctTransaction fees (% of enterprise value)
fin_fee_pctFinancing fees (% of debt raised)
fin_fee_yearsFinancing fee amortisation (years)
rolloverManagement rollover equity ($m)
tl_xTerm loan (x LTM EBITDA)
tl_rateTerm loan rate (%)
tl_amort_pctTerm loan amortisation (% of original per year)
sl_xSecond lien (x LTM EBITDA)
sl_rateSecond lien rate (%)
notes_xSenior notes (x LTM EBITDA)
notes_rateNotes rate (%)
notes_pikNotes interest paid in kind (accrues, no cash), true or false
rcf_sizeRevolver commitment ($m, undrawn at close)
rcf_rateRevolver rate (%)
sweep_pctExcess cash swept to prepay debt (%)
hold_yearsHolding period (years), required, 1 to 10
rev_growthRevenue growth (%/yr)
growth_pathGrowth by year (%, comma-separated, overrides rev_growth)
exit_marginEBITDA margin in the exit year (%)
da_pctD&A (% of revenue)
capex_pctCapex (% of revenue)
nwc_pctWorking capital (% of revenue growth)
tax_rateTax rate (%)
cash_rateInterest earned on cash (%)
exit_multipleExit multiple (x exit-year EBITDA)
exit_fee_pctExit fees (% of exit enterprise value)
mgmt_pool_pctManagement incentive pool (% of exit equity)
hurdle_irrHurdle IRR (%)

What facts carries once it is parsed. Every figure is a display string (for example "$1,020.0m", "8.5x", "21.6%"), and the review may quote only those strings:

sectioncontents
units, inputs, defaults_appliedThe unit note, the raw form values by field id, and which exit values were defaulted to the entry values.
entryEnterprise value, entry multiple, LTM EBITDA, LTM margin, EV to revenue.
sources_usesUses (purchase enterprise value, fees, cash to balance sheet), sources (term loan, second lien, senior notes, rollover, sponsor equity as the plug), equity share of sources, debt and net debt to LTM EBITDA at close.
yearsOne object per year of the hold: revenue, EBITDA and margin, cash and PIK interest, tax, net income, capex, working capital, levered free cash flow, mandatory repayment, cash sweep, revolver draw, cash shortfall, each tranche's ending balance, cash, total and net debt, leverage and interest coverage.
exitExit year, exit EBITDA, multiple and enterprise value, net debt at exit, exit fees, exit equity, management pool, sponsor ownership and proceeds, rollover proceeds.
returnsSponsor equity and proceeds, MOIC, IRR, hurdle, irr_vs_hurdle_pp, total equity gain, revenue and EBITDA CAGR, exit_ebitda_vs_ltm, ebitda_margin_change_pp.
bridgeThe returns bridge, a list of {key, value, share_of_gross_value_created}: ebitda_growth, multiple_change, net_debt_reduction, transaction_costs, and where present equity_floor and management_pool.
creditDebt at close and exit, debt paid down and its share of entry debt, leverage at exit, weakest interest coverage, peak net leverage, cumulative levered free cash flow, total cash and PIK interest, revolver commitment.
hurdle_mathThe highest entry multiple (and enterprise value) that still clears the hurdle, the exit multiple needed for the hurdle, and the exit multiple for a 1.0x MOIC.
grid_irr_entry_by_exit, grid_moic_entry_by_exit, grid_irr_growth_by_exit, grid_irr_leverage_by_entryFour 5x5 sensitivity grids: IRR and MOIC by entry and exit multiple, IRR by revenue growth shift and exit multiple, IRR by total leverage and entry multiple. The centre cell of each is the base case. With no debt the leverage grid is only a note.
flagsWhat the browser found, as {code, severity, detail}. See the flag codes.
rulesThe thresholds behind the flags and the verdict, for example moic_min_x, moic_fail_x and irr_gap_pp.

A trimmed view of the parsed facts for the page's steady-compounder example (Meridian Pump Services, 8.5x in, 8.5x out, five years):

{
  "units": "money in $ millions; multiples in x EBITDA; rates in %",
  "entry": { "enterprise_value": "$1,020.0m", "entry_multiple": "8.5x", "ltm_ebitda": "$120.0m",
             "ltm_margin": "20.0%", "ev_to_revenue": "1.70x" },
  "sources_uses": { "equity_pct_of_sources": "49.7%", "debt_to_ltm_ebitda": "4.5x", "net_debt_to_ltm_ebitda": "4.3x", ... },
  "returns": { "sponsor_equity": "$503.9m", "sponsor_proceeds": "$1,339.9m", "moic": "2.66x",
               "irr": "21.6%", "hurdle_irr": "20.0%", "irr_vs_hurdle_pp": "+1.6pp", ... },
  "bridge": [
    { "key": "ebitda_growth",      "value": "$667.1m", "share_of_gross_value_created": "66%" },
    { "key": "multiple_change",    "value": "$0.0m",   "share_of_gross_value_created": "n/a" },
    { "key": "net_debt_reduction", "value": "$344.1m", "share_of_gross_value_created": "34%" },
    { "key": "transaction_costs",  "value": "-$50.8m", "share_of_gross_value_created": "n/a" },
    { "key": "management_pool",    "value": "-$74.7m", "share_of_gross_value_created": "n/a" }
  ],
  "hurdle_math": { "max_entry_multiple_for_hurdle": "8.80x", "max_entry_enterprise_value_for_hurdle": "$1,055.9m",
                   "exit_multiple_needed_for_hurdle": "8.01x", "exit_multiple_for_1x_moic": "3.76x" },
  "flags": [],
  ...
}

The same case as a request body (the facts string is abbreviated here):

{
  "task": "review",
  "facts": "{\"units\":\"money in $ millions; multiples in x EBITDA; rates in %\",\"inputs\":{\"deal_name\":\"Quillmont / Meridian Pump Services\",\"company\":\"Meridian Pump Services\",\"ltm_revenue\":600,\"ltm_ebitda\":120,\"entry_multiple\":8.5,...",
  "question": "Is a five-year hold at 8.5x worth taking to the committee if the exit multiple does not expand, and what has to go right?"
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js 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.

5. Run it, then poll

POST /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. Derive it from the input as the web app does, with the lane and an attempt counter: lbo-desk:review:<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. The web app uses a short hash of the input JSON; any stable hash works, the samples below use the first 16 hex digits of a SHA-256.

If the reply cannot be parsed as one JSON object, the web app retries exactly once: it adds a retry_note field to the same input (a plain instruction to reply with only the JSON object for task review, every array present) and sends it with the attempt suffix bumped to :a2, so the reformat retry is a distinct, separately billed run. Do the same from code.

# 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="lbo-desk:review:$(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\":\"review\",\"verdict\":\"stretched\", ...}"},
#   "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. Each delta event carries {"text": "..."}, a chunk of the reply, and the final done event carries status, charged_credits and truncated. 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\":\"review\",\"verdict\":\"stretched\","}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

data.output.output 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 stretched, an unknown severity to medium, keys, fields and flag codes are lower-cased, and missing arrays become empty. A reply with no headline, returns_case or summary, or with neither drivers nor challenges, counts as unparseable and triggers the one retry_note retry. Then it checks the reply against the facts it sent. You should do the same.

# review.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("review.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["verdict"], "-", r["headline"])
for c in r["challenges"]:
    print(c["severity"], c["field"], c["concern"])
EOF

Invariants worth asserting

The output contract

{
  "lane": "review",
  "verdict": "supportable" | "stretched" | "not_supportable",
  "headline": "one sentence: the returns case in plain words, with the sponsor IRR and MOIC and whether it clears the hurdle",
  "returns_case": "3 to 5 sentences for the returns page of an IC memo: price paid, how it is financed, how the money is made over the hold, and what the case depends on",
  "drivers": [
    {"key": "a key from facts.bridge", "direction": "adds" | "subtracts",
     "reading": "why this line moves the equity value the way it does in this deal, quoting its figure"}
  ],
  "challenges": [
    {"field": "one input field id", "severity": "high" | "medium" | "low",
     "concern": "why this assumption may be wrong or flattering, quoting the relevant figure",
     "test": "what to check in diligence, or which grid cell or hurdle figure shows the sensitivity"}
  ],
  "flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this deal and what to do about it"}],
  "structure_options": [
    {"option": "a change to price, leverage, tranche mix or terms the deal team could consider",
     "evidence": "the grid cell or hurdle figure from facts that supports it, quoted exactly",
     "tradeoff": "what the change costs or risks"}
  ],
  "diligence_questions": ["a question for management, lenders or advisers that would resolve a key uncertainty"],
  "ic_summary": "one paragraph an investment committee member could read in a minute: the recommendation framed as analysis, the key numbers, the main risk",
  "summary": "two sentences: the verdict and why"
}

Array sizes: 2 to 4 drivers (largest absolute value first), 3 to 6 challenges, one flag_responses entry per flag, 1 to 3 structure_options, 3 to 6 diligence_questions. When the input carries a question, the returns_case or ic_summary answers it directly.

The verdict rules: supportable needs the IRR at or above the hurdle, MOIC at or above rules.moic_min_x, an exit multiple no higher than the entry multiple and no high- or medium-severity flag. stretched covers a case that can be made but rests on something fragile (a medium flag, an IRR within rules.irr_gap_pp points below the hurdle, multiple expansion, a flagged plan, leverage or coverage, a revolver draw). not_supportable is an IRR more than rules.irr_gap_pp points below the hurdle, MOIC under rules.moic_fail_x, exit equity wiped out, or a liquidity shortfall; it governs when both apply.

The flag codes

Thresholds come from facts.rules; the defaults are shown.

codeseveritymeaning
equity_wiped_outhighExit equity is zero or negative: net debt and exit fees exceed the exit enterprise value.
irr_below_hurdlehigh or mediumSponsor IRR is below the hurdle; high when it is more than 5 points (irr_gap_pp) below.
moic_below_2xhigh or mediumSponsor MOIC is under 2.0x (moic_min_x); high under 1.5x (moic_fail_x).
multiple_expansion_reliancehighA higher exit than entry multiple supplies 50% or more (multiple_share_high_pct) of the gross value created.
multiple_expansionmediumThe exit multiple is above the entry multiple, but supplies less than half of the gross value.
leverage_very_highhighDebt at close above 6.5x LTM EBITDA.
leverage_highmediumDebt at close above 5x LTM EBITDA.
coverage_very_lowhighEBITDA covers cash interest less than 1.5x in the weakest year.
coverage_lowmediumEBITDA covers cash interest less than 2x in the weakest year.
liquidity_shortfallhighCash falls below the minimum even after drawing the revolver, or there is no revolver to draw.
revolver_drawnmediumThe revolver is drawn to keep cash at the minimum (with no shortfall left over).
equity_cushion_thinmediumEquity is less than 30% of total sources.
margin_expansionmediumThe plan lifts the EBITDA margin by 5 points or more from LTM to the exit year.
growth_aggressivemediumRevenue compounds at 15% a year or more over the hold.
slow_deleveragingmediumNet debt is still above 4x EBITDA at exit.
pik_accruallowNotes interest accrues in kind, so the notes grow over the hold.
fees_highlowEntry fees above 4% of enterprise value.
short_holdlowA hold shorter than 3 years leaves little time for paydown or growth.
long_holdlowA hold longer than 7 years, beyond most fund lives without an extension.
pre_tax_losslowPre-tax income is negative in at least one year; losses carry forward.
no_leveragelowNo debt is raised, so returns come from the plan and the exit multiple alone.

8. Use it in a buyout screen

The verdict is built to gate on. Put each candidate case (a different price, debt package, plan or exit) in its own file under cases/, build a body for each with make-body.js, review them one after another, and keep the ones that survive. not_supportable means the arithmetic does not carry the price or the leverage; stretched passes with the challenges you should keep next to the model. Price each case with /estimate first if the balance is tight, and run the cases serially rather than in a burst so you do not hit rate_limited.

#!/bin/sh
# Screen every case in cases/*.json; print one verdict line per case and
# exit non-zero if any case is "not_supportable".
set -e
API="https://api.skillsafe.ai/v1/app-api"
FAIL=0
for CASE in cases/*.json; do
  node make-body.js "$CASE" "Does this case clear the hurdle without multiple expansion?" > body.json
  INPUT=$(cat body.json)
  KEY="lbo-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
  JOB=$(curl -sS -X POST "$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 "$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 "$CASE: $V"
  [ "$V" = not_supportable ] && FAIL=1
done
exit $FAIL

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 drivers and challenges may be complete while the IC summary is missing. The web page closes the cut-off JSON, shows the sections that arrived and says how many of the nine (headline, returns case, drivers, challenges, flag responses, structure options, diligence questions, IC summary, summary) it recovered. From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.