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
| 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 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"}}
# Open https://lbo-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 review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "lbo-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://lbo-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 review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "lbo-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://lbo-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 review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"lbo-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://lbo-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 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\":\"lbo-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://lbo-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 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 = JSON.generate({ slug: "lbo-desk" })
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://lbo-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 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" => "lbo-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://lbo-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 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\":\"lbo-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="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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "lbo-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://lbo-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 = "lbo-desk";
const TOKEN = "YOUR_TOKEN"; // from https://lbo-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 = "lbo-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://lbo-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 LboDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "lbo-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 = "lbo-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://lbo-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 = JSON.generate(body)
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 = "lbo-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 LboDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "lbo-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 LboDesk.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 review. A missing or
unknown task is still answered as review, and the reply's lane
says so.
| task | what it does |
|---|---|
review | Reviews 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. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | The 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. |
question | string | What 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_note | string | Only 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 id | meaning |
|---|---|
deal_name | Deal name |
company | Company |
ltm_revenue | LTM revenue ($m), required |
ltm_ebitda | LTM EBITDA ($m), required |
entry_multiple | Entry multiple (x LTM EBITDA), required |
min_cash | Cash put on the balance sheet at close ($m) |
adv_fee_pct | Transaction fees (% of enterprise value) |
fin_fee_pct | Financing fees (% of debt raised) |
fin_fee_years | Financing fee amortisation (years) |
rollover | Management rollover equity ($m) |
tl_x | Term loan (x LTM EBITDA) |
tl_rate | Term loan rate (%) |
tl_amort_pct | Term loan amortisation (% of original per year) |
sl_x | Second lien (x LTM EBITDA) |
sl_rate | Second lien rate (%) |
notes_x | Senior notes (x LTM EBITDA) |
notes_rate | Notes rate (%) |
notes_pik | Notes interest paid in kind (accrues, no cash), true or false |
rcf_size | Revolver commitment ($m, undrawn at close) |
rcf_rate | Revolver rate (%) |
sweep_pct | Excess cash swept to prepay debt (%) |
hold_years | Holding period (years), required, 1 to 10 |
rev_growth | Revenue growth (%/yr) |
growth_path | Growth by year (%, comma-separated, overrides rev_growth) |
exit_margin | EBITDA margin in the exit year (%) |
da_pct | D&A (% of revenue) |
capex_pct | Capex (% of revenue) |
nwc_pct | Working capital (% of revenue growth) |
tax_rate | Tax rate (%) |
cash_rate | Interest earned on cash (%) |
exit_multiple | Exit multiple (x exit-year EBITDA) |
exit_fee_pct | Exit fees (% of exit enterprise value) |
mgmt_pool_pct | Management incentive pool (% of exit equity) |
hurdle_irr | Hurdle 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:
| section | contents |
|---|---|
units, inputs, defaults_applied | The unit note, the raw form values by field id, and which exit values were defaulted to the entry values. |
entry | Enterprise value, entry multiple, LTM EBITDA, LTM margin, EV to revenue. |
sources_uses | Uses (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. |
years | One 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. |
exit | Exit year, exit EBITDA, multiple and enterprise value, net debt at exit, exit fees, exit equity, management pool, sponsor ownership and proceeds, rollover proceeds. |
returns | Sponsor 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. |
bridge | The 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. |
credit | Debt 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_math | The 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_entry | Four 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. |
flags | What the browser found, as {code, severity, detail}. See the flag codes. |
rules | The 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.
INPUT = json.load(open("body.json")) # task, facts, 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 LboDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"lbo-desk:review:{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"], [c["field"] for c in review["challenges"]])
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 = `lbo-desk:review:${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.challenges.map((c) => c.field), job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("lbo-desk:review:%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 = "lbo-desk:review:" + 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 = "lbo-desk:review:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
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["challenges"].map { |c| c["field"] }.inspect
<?php
$key = "lbo-desk:review:" . 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 = "lbo-desk:review:" + 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 LboDesk.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. 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}
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:])
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));
}
}
console.log(done, raw.length);
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 = JSON.generate(INPUT)
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 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
def parse_review(text):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
if r.get("verdict") not in ("supportable", "stretched", "not_supportable"):
r["verdict"] = "stretched" # the page's fallback
for k in ("drivers", "challenges", "flag_responses", "structure_options", "diligence_questions"):
r[k] = r.get(k) or []
return r
r = parse_review(job["output"]["output"])
print(r["verdict"], [c["field"] for c in r["challenges"]])
function parseReview(text) {
const t = String(text).trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
if (!["supportable", "stretched", "not_supportable"].includes(r.verdict)) r.verdict = "stretched";
for (const k of ["drivers", "challenges", "flag_responses", "structure_options", "diligence_questions"]) r[k] = r[k] || [];
return r;
}
const r = parseReview(job.output.output);
console.log(r.verdict, r.challenges.map((c) => c.field));
type Review struct {
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Challenges []struct {
Field string `json:"field"`
Severity string `json:"severity"`
Concern string `json:"concern"`
} `json:"challenges"`
ICSummary string `json:"ic_summary"`
}
text := jobOutput // data.output.output from step 5
var r Review
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
fmt.Println(r.Verdict, len(r.Challenges))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
System.out.println(r.get("verdict").asText() + " " + r.get("challenges").size());
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
r["verdict"] = "stretched" unless %w[supportable stretched not_supportable].include?(r["verdict"])
puts r["verdict"], r["challenges"].map { |c| c["field"] }.inspect
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $r["verdict"], " ", count($r["challenges"]), PHP_EOL;
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
Console.WriteLine($"{r.GetProperty("verdict")} {r.GetProperty("challenges").GetArrayLength()}");
Invariants worth asserting
- Every number in the prose is a figure in
facts, as written there (the page checks each one, with rounding tolerance only at the digits written). - Every
challenges[].fieldis one of the buyout field ids above. - Every
drivers[].keyis a key infacts.bridge, itsdirectionisaddsfor a positive value andsubtractsfor a negative one, and no driver is a line whose value is$0.0m. flag_responseshas exactly one entry per code infacts.flags, in the same order, and no others.- The verdict is never
supportablewhenreturns.irris belowreturns.hurdle_irr, or when any flag has high or medium severity. - The verdict is
not_supportablewhenequity_wiped_outorliquidity_shortfallis flagged, or whenirr_below_hurdleormoic_below_2xis flagged at high severity.
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.
| code | severity | meaning |
|---|---|---|
equity_wiped_out | high | Exit equity is zero or negative: net debt and exit fees exceed the exit enterprise value. |
irr_below_hurdle | high or medium | Sponsor IRR is below the hurdle; high when it is more than 5 points (irr_gap_pp) below. |
moic_below_2x | high or medium | Sponsor MOIC is under 2.0x (moic_min_x); high under 1.5x (moic_fail_x). |
multiple_expansion_reliance | high | A higher exit than entry multiple supplies 50% or more (multiple_share_high_pct) of the gross value created. |
multiple_expansion | medium | The exit multiple is above the entry multiple, but supplies less than half of the gross value. |
leverage_very_high | high | Debt at close above 6.5x LTM EBITDA. |
leverage_high | medium | Debt at close above 5x LTM EBITDA. |
coverage_very_low | high | EBITDA covers cash interest less than 1.5x in the weakest year. |
coverage_low | medium | EBITDA covers cash interest less than 2x in the weakest year. |
liquidity_shortfall | high | Cash falls below the minimum even after drawing the revolver, or there is no revolver to draw. |
revolver_drawn | medium | The revolver is drawn to keep cash at the minimum (with no shortfall left over). |
equity_cushion_thin | medium | Equity is less than 30% of total sources. |
margin_expansion | medium | The plan lifts the EBITDA margin by 5 points or more from LTM to the exit year. |
growth_aggressive | medium | Revenue compounds at 15% a year or more over the hold. |
slow_deleveraging | medium | Net debt is still above 4x EBITDA at exit. |
pik_accrual | low | Notes interest accrues in kind, so the notes grow over the hold. |
fees_high | low | Entry fees above 4% of enterprise value. |
short_hold | low | A hold shorter than 3 years leaves little time for paydown or growth. |
long_hold | low | A hold longer than 7 years, beyond most fund lives without an extension. |
pre_tax_loss | low | Pre-tax income is negative in at least one year; losses carry forward. |
no_leverage | low | No 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
import glob, subprocess
def review(inp):
"""Steps 5 and 7: run with an Idempotency-Key, poll, parse."""
digest = hashlib.sha256(json.dumps(inp, sort_keys=True).encode()).hexdigest()[:16]
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(inp).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", f"lbo-desk:review:{digest}:a1")):
req.add_header(h, v)
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while job.get("status") not in ("succeeded", "failed"):
time.sleep(2)
job = call(f"jobs/{job['job_id']}")
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
return parse_review(job["output"]["output"])
results = []
for path in sorted(glob.glob("cases/*.json")):
body = subprocess.run(["node", "make-body.js", path, "Does this case clear the hurdle?"],
capture_output=True, text=True, check=True).stdout
facts = json.loads(json.loads(body)["facts"])
r = review(json.loads(body))
results.append((path, r["verdict"], facts["returns"]["irr"], facts["returns"]["moic"]))
print(f"{path}: {r['verdict']} IRR {facts['returns']['irr']} MOIC {facts['returns']['moic']}")
survivors = [p for p, v, _, _ in results if v != "not_supportable"]
raise SystemExit(0 if survivors else 1)
import { readdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
const LBO = createRequire(import.meta.url)("./lbo.js");
async function review(input) {
const digest = createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16);
let job = (await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": `lbo-desk:review:${digest}:a1` },
body: JSON.stringify(input),
}).then((r) => r.json())).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));
return parseReview(job.output.output);
}
let failed = 0;
for (const name of readdirSync("cases").filter((f) => f.endsWith(".json")).sort()) {
const file = JSON.parse(readFileSync(`cases/${name}`, "utf8"));
const res = LBO.compute(file.deal || file);
if (!res.ok) { console.log(name, "skipped:", res.errors.join(" ")); continue; }
const input = LBO.mustBeObject(LBO.buildInput(res, "Does this case clear the hurdle?"));
const r = await review(input); // one case at a time
console.log(`${name}: ${r.verdict}`);
if (r.verdict === "not_supportable") failed++;
}
process.exitCode = failed ? 1 : 0;
// review(input) wraps steps 5 and 7: run with an Idempotency-Key, poll, parse into Review.
cases, _ := filepath.Glob("cases/*.json") // add "path/filepath" and "os/exec" to the imports
failed := 0
for _, c := range cases {
out, err := exec.Command("node", "make-body.js", c, "Does this case clear the hurdle?").Output()
if err != nil {
panic(err)
}
var input map[string]any
_ = json.Unmarshal(out, &input)
r, err := review(input)
if err != nil {
panic(err)
}
fmt.Printf("%s: %s\n", c, r.Verdict)
if r.Verdict == "not_supportable" {
failed++
}
}
if failed > 0 {
os.Exit(1)
}
// review(input) wraps steps 5 and 7 and returns the parsed reply (a Jackson JsonNode).
int failed = 0;
try (var files = java.nio.file.Files.newDirectoryStream(java.nio.file.Path.of("cases"), "*.json")) {
for (var c : files) {
Process p = new ProcessBuilder("node", "make-body.js", c.toString(), "Does this case clear the hurdle?").start();
String input = new String(p.getInputStream().readAllBytes());
var r = review(input);
System.out.println(c.getFileName() + ": " + r.get("verdict").asText());
if ("not_supportable".equals(r.get("verdict").asText())) failed++;
}
}
System.exit(failed > 0 ? 1 : 0);
# review(input) wraps steps 5 and 7: run with an Idempotency-Key, poll, parse.
failed = 0
Dir.glob("cases/*.json").sort.each do |path|
input = JSON.parse(IO.popen(["node", "make-body.js", path, "Does this case clear the hurdle?"], &:read))
facts = JSON.parse(input["facts"])
r = review(input)
puts "#{path}: #{r['verdict']} IRR #{facts['returns']['irr']} MOIC #{facts['returns']['moic']}"
failed += 1 if r["verdict"] == "not_supportable"
end
exit(failed.zero? ? 0 : 1)
<?php
// review($input) wraps steps 5 and 7: run with an Idempotency-Key, poll, parse.
$failed = 0;
foreach (glob("cases/*.json") as $path) {
$cmd = "node make-body.js " . escapeshellarg($path) . " " . escapeshellarg("Does this case clear the hurdle?");
$input = json_decode(shell_exec($cmd), true);
$r = review($input);
echo $path, ": ", $r["verdict"], PHP_EOL;
if ($r["verdict"] === "not_supportable") $failed++;
}
exit($failed ? 1 : 0);
// Review(input) wraps steps 5 and 7 and returns the parsed reply.
var failed = 0;
foreach (var path in Directory.GetFiles("cases", "*.json").OrderBy(p => p))
{
var psi = new System.Diagnostics.ProcessStartInfo("node") { RedirectStandardOutput = true };
psi.ArgumentList.Add("make-body.js"); psi.ArgumentList.Add(path); psi.ArgumentList.Add("Does this case clear the hurdle?");
using var proc = System.Diagnostics.Process.Start(psi)!;
var input = JsonSerializer.Deserialize<JsonElement>(await proc.StandardOutput.ReadToEndAsync());
var r = await Review(input);
var verdict = r.GetProperty("verdict").GetString();
Console.WriteLine($"{path}: {verdict}");
if (verdict == "not_supportable") failed++;
}
return failed > 0 ? 1 : 0;
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.