← Comps Desk / API
Tokens

Drive Comps Desk from your own code

Everything the web page does is available over HTTP. Send the comparable company analysis the browser computes for one peer set and one target and get the same review back: a verdict on the peer set, the anchor multiple, a keep / question / exclude call on every peer, readings of the statistics, one response per flag, the data to confirm, the methodology note, the valuation read and an IC summary. The natural use is a coverage refresh: a script rebuilds the comps for each target every quarter, asks for the review, and files the valuation read next to the table.

One thing to be clear about before the first call: the model never does the arithmetic. The comps are built by comps.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":"comps-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://comps-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 peer set 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":"comps-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="comps-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://comps-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 comps: verdict (sound, usable_with_caveats, unreliable), headline, the anchor multiple, a keep / question / exclude call on every peer, metric readings, flag responses, data requests, the methodology note, the valuation read, an IC summary and a summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredThe JSON-encoded output of Comps.buildFacts: the analysis header, sector guidance, every peer's figures, margins and multiples, the statistics block, the target, the implied valuation, outliers, flags and rules.
questionstringWhat you want to know, up to 2,000 characters. May be empty. A longer question is cut on a word boundary 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 Comps.mustBeObject first: it must be a JSON object whose task and facts are both strings.

Building the facts

comps.js is plain JavaScript with no dependencies and exports itself to node. Download comps.js next to your script and save a set from the page with Save set .json: it writes {"fields": {...}, "table": "...", "question": "...", "excluded": [...]}, where table is the peer table exactly as pasted (CSV, tab-separated or Markdown). Then let it build the body:

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

The peer table needs a header row with Company or Ticker, and Revenue. Other columns are matched loosely (units in brackets and words such as LTM, adjusted or diluted are ignored): share price, diluted shares, market cap, debt, cash, net debt, revenue, prior-year revenue, revenue growth, gross profit, EBITDA, net income and free cash flow. Money is in $ millions, shares in millions, price in $. Market cap is price x shares unless given; net debt is debt less cash unless given; growth is built from prior-year revenue unless given. At most 25 peers are read, cut on whole rows.

field idmeaning
titleAnalysis title
as_ofPeriod the data describe, for example "LTM to 30 June 2026"
sectorOne of general, software, industrials, financials, consumer, healthcare; picks the must-have metrics and the anchor hint
t_name, t_tickerTarget name and ticker
t_revenueTarget revenue ($m), required
t_ebitda, t_net_income, t_fcfTarget EBITDA, net income and free cash flow ($m)
t_growthTarget revenue growth (%)
t_net_debtTarget net debt ($m, cash negative); needed to bridge enterprise value to equity
t_shares, t_priceTarget diluted shares (m) and share price ($); give both for per-share values and current multiples

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

sectioncontents
units, analysis, sector_guidanceThe unit note; title, as-of period and sector; the sector's must-have, optional and skip metrics.
peer_count, peers_in_statistics, excluded_by_userHow many peers were read, how many count, and the tickers left out of the statistics.
peersOne object per peer: ticker, company, in_stats, market cap, net debt, enterprise value, revenue, EBITDA, net income, and every metric (revenue_growth, gross_margin, ebitda_margin, net_margin, fcf_margin, rule_of_40, ev_revenue, ev_ebitda, pe, fcf_yield, peg) as a value or "NM", with nm_reasons for NM multiples.
statisticsPer metric: n and, when n is above 0, max, p75, median, p25, min (Excel QUARTILE.INC), over the peers in the statistics.
targetThe target's inputs, its current multiples when it has a share price, and percentile_rank.
implied_valuationPer method (ev_revenue, ev_ebitda, pe): peer count, the target's base figure, and at p25, median and p75 the multiple, enterprise value, equity value, per share and versus the current price; or usable: false with a reason.
valuation_range, anchor_hintThe lowest and highest 25th-to-75th values across usable methods; the multiple the sector points to.
outliers, flags, rulesValues outside the 1.5x IQR fences and more than 20% from the median; what the browser found as {code, severity, detail} (see the flag codes); the thresholds behind them.

A trimmed view of the parsed facts for the page's software example (Ledgerline Software against six vertical-software peers):

{
  "units": "USD millions except per-share amounts ($), ratios (x) and percentages",
  "analysis": {
    "title": "Ledgerline Software",
    "as_of": "LTM to 30 June 2026",
    "sector": "software",
    "sector_label": "Software / SaaS"
  },
  "peer_count": 6,
  "peers_in_statistics": 6,
  "peers": [
    {
      "ticker": "NRVL",
      "company": "Norvale Systems",
      "in_stats": true,
      "market_cap": "$10,080.0m",
      "net_debt": "-$400.0m",
      "enterprise_value": "$9,680.0m",
      "revenue": "$1,450.0m",
      "ebitda": "$348.0m",
      "net_income": "$190.0m",
      "revenue_growth": "18.0%",
      "gross_margin": "75.0%",
      "ebitda_margin": "24.0%",
      "net_margin": "13.1%",
      "fcf_margin": "21.0%",
      "rule_of_40": "39.0%",
      "ev_revenue": "6.7x",
      "ev_ebitda": "27.8x",
      "pe": "53.1x",
      "fcf_yield": "3.0%",
      "peg": "2.95x"
    },
    "..."
  ],
  "statistics": {
    "ev_revenue": {
      "n": 6,
      "max": "6.7x",
      "p75": "6.0x",
      "median": "5.4x",
      "p25": "5.3x",
      "min": "4.7x"
    },
    "ev_ebitda": {
      "n": 6,
      "max": "29.2x",
      "p75": "26.8x",
      "median": "23.0x",
      "p25": "21.6x",
      "min": "18.1x"
    }
  },
  "implied_valuation": [
    {
      "method": "ev_revenue",
      "peers": 6,
      "target_base": "$620.0m",
      "p25": {
        "multiple": "5.3x",
        "enterprise_value": "$3,311.2m",
        "equity_value": "$3,231.2m",
        "per_share": "$53.85"
      },
      "median": {
        "multiple": "5.4x",
        "enterprise_value": "$3,350.2m",
        "equity_value": "$3,270.2m",
        "per_share": "$54.50"
      },
      "p75": {
        "multiple": "6.0x",
        "enterprise_value": "$3,696.9m",
        "equity_value": "$3,616.9m",
        "per_share": "$60.28"
      }
    },
    "..."
  ],
  "valuation_range": {
    "enterprise_value": "$2,684.9m to $3,696.9m",
    "equity_value": "$2,604.9m to $3,616.9m",
    "per_share": "$43.42 to $60.28"
  },
  "anchor_hint": "ev_revenue",
  "flags": [
    {
      "code": "multiple_range",
      "severity": "low",
      "detail": "NRVL EV / EBITDA 27.8x, TSLN EV / EBITDA 29.2x, NRVL P / E 53.1x, TSLN P / E 62.9x sit outside the usual ranges (EV / Revenue 0.5x to 20.0x, EV / EBITDA 8.0x to 25.0x, P / E 10.0x to 50.0x)."
    }
  ]
}

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

{
  "task": "review",
  "facts": "{\"units\":\"USD millions except per-share amounts ($), ratios (x) and percentages\",\"analysis\":{\"title\":\"Ledgerline Software\",\"as_of\":\"LTM to 30 June 2026\",\"sector\":\"software\",\"sector_label\":\"Software / SaaS\"},\"sector_guidance\":{\"must_hav...",
  "question": "We are pricing a minority round in Ledgerline. Which multiple should carry the valuation, and is this peer set good enough to put in front of the committee?"
}
# 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: comps-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="comps-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\":\"usable_with_caveats\", ...}"},
#   "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\":\"usable_with_caveats\","}
# 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 usable_with_caveats, an unknown peer call to question, tickers are upper-cased, metric ids and flag codes lower-cased, and missing arrays become empty. A reply with no headline, valuation_read or summary, or with neither peer_calls nor metric_readings, 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["peer_calls"]:
    print(c["call"], c["ticker"], c["reason"])
EOF

Invariants worth asserting

The exclusions are the handoff: the page turns the tickers the review calls exclude into a button that recomputes the statistics and the implied value without them. From code, pass them as exclude to Comps.compute and build a second body for a second review.

The output contract

{
  "lane": "review",
  "verdict": "sound" | "usable_with_caveats" | "unreliable",
  "headline": "one sentence: the anchor multiple's median as written in facts.statistics, the implied range it gives the target, and whether the peer set can carry it",
  "anchor": {"metric": "ev_revenue" | "ev_ebitda" | "pe", "reason": "why this multiple suits this sector and this data"},
  "peer_calls": [
    {"ticker": "a ticker from facts.peers", "call": "keep" | "question" | "exclude",
     "reason": "why, quoting the peer's own figures against the statistics"}
  ],
  "metric_readings": [
    {"metric": "a metric id", "reading": "what the statistics for this metric say, quoting figures"}
  ],
  "valuation_read": "3 to 5 sentences for the valuation page",
  "flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means and what to do about it"}],
  "data_requests": ["a check on the data - a source, a period, a definition or a missing figure"],
  "methodology_note": "the Notes and methodology paragraph for the comps page",
  "ic_summary": "one paragraph a committee member could read in a minute",
  "summary": "two sentences: the verdict and why"
}

Array sizes: one peer_calls entry per peer, 2 to 5 metric_readings (the sector's must-have metrics first), one flag_responses entry per flag, 3 to 6 data_requests. When the input carries a question, the valuation_read or ic_summary answers it directly.

The verdict rules: sound means no flag of high or medium severity (low flags alone do not count against it). usable_with_caveats means at least one medium flag and no high one. unreliable means at least one high flag.

The flag codes

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

codeseveritymeaning
too_few_peershighFewer than 3 peers (peers_fail) are in the statistics.
anchor_multiples_sparsehighNone of EV / EBITDA, EV / Revenue or P / E has 3 or more meaningful values.
target_size_mismatchhigh or mediumThe target's revenue is more than 4x (target_size_mismatch_x) above or below the peer median; high beyond 10x (target_size_far_x).
thin_peer_setmediumFewer than 4 peers (peers_min) are in the statistics.
nm_heavymediumEV / EBITDA is NM for more than a third of the peers.
outliersmediumA peer's EV / Revenue, EV / EBITDA or P / E is beyond 1.5x (outlier_iqr_k) the interquartile range from the quartiles and more than 20% (outlier_min_rel_pct) from the median (needs 4 or more values).
wide_multiple_rangemediumThe 75th percentile of EV / EBITDA or EV / Revenue is more than 2x (wide_range_ratio) the 25th.
missing_ev_inputsmediumEnterprise value cannot be built for a peer: market cap or net debt is missing.
duplicate_peermediumA ticker is listed twice; only the first row counts.
target_in_peersmediumThe target appears in its own peer set.
margin_orderlowGross margin above EBITDA margin above net margin does not hold for a peer.
multiple_rangelowA multiple sits outside the usual range: EV / Revenue 0.5x to 20.0x, EV / EBITDA 8.0x to 25.0x, P / E 10.0x to 50.0x.
size_dispersionlowThe largest peer's revenue is more than 20x the smallest's.
multiples_ignore_growthlowAcross 5 or more peers, faster growth does not go with a higher EV / Revenue (rank correlation at or below zero).
mcap_mismatchlowA given market cap differs from price x shares by more than 2%.
ebitda_not_meaningfullowFinancial-services sector: EBITDA and gross margin are not meaningful, P / E carries the valuation.
sector_metric_gaplowSoftware sector with no peer carrying both growth and free cash flow, so Rule of 40 is unavailable.
growth_as_decimallowEvery growth figure is below 1 with no % sign, so 0.12 was read as 0.12%.

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 peer calls 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 ten (headline, anchor, peer calls, metric readings, valuation read, flag responses, data requests, methodology note, 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.