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
| 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://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"}}
# Open https://comps-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": "comps-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://comps-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: "comps-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://comps-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":"comps-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://comps-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\":\"comps-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://comps-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: "comps-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://comps-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" => "comps-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://comps-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\":\"comps-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="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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "comps-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://comps-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 = "comps-desk";
const TOKEN = "YOUR_TOKEN"; // from https://comps-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 = "comps-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://comps-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 CompsDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "comps-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 = "comps-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://comps-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 = "comps-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 CompsDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "comps-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 CompsDesk.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 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. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | The 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. |
question | string | What 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_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 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 id | meaning |
|---|---|
title | Analysis title |
as_of | Period the data describe, for example "LTM to 30 June 2026" |
sector | One of general, software, industrials, financials, consumer, healthcare; picks the must-have metrics and the anchor hint |
t_name, t_ticker | Target name and ticker |
t_revenue | Target revenue ($m), required |
t_ebitda, t_net_income, t_fcf | Target EBITDA, net income and free cash flow ($m) |
t_growth | Target revenue growth (%) |
t_net_debt | Target net debt ($m, cash negative); needed to bridge enterprise value to equity |
t_shares, t_price | Target 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:
| section | contents |
|---|---|
units, analysis, sector_guidance | The unit note; title, as-of period and sector; the sector's must-have, optional and skip metrics. |
peer_count, peers_in_statistics, excluded_by_user | How many peers were read, how many count, and the tickers left out of the statistics. |
peers | One 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. |
statistics | Per metric: n and, when n is above 0, max, p75, median, p25, min (Excel QUARTILE.INC), over the peers in the statistics. |
target | The target's inputs, its current multiples when it has a share price, and percentile_rank. |
implied_valuation | Per 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_hint | The lowest and highest 25th-to-75th values across usable methods; the multiple the sector points to. |
outliers, flags, rules | Values 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.
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 CompsDesk.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: 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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"comps-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["ticker"] + " " + c["call"] for c in review["peer_calls"]])
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 = `comps-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.peer_calls.map((c) => c.ticker + " " + c.call), job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("comps-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 = "comps-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 = "comps-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["peer_calls"].map { |c| "#{c['ticker']} #{c['call']}" }.inspect
<?php
$key = "comps-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 = "comps-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 CompsDesk.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\":\"usable_with_caveats\","}
# 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 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
def parse_review(text):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
if r.get("verdict") not in ("sound", "usable_with_caveats", "unreliable"):
r["verdict"] = "usable_with_caveats" # the page's fallback
for k in ("peer_calls", "metric_readings", "flag_responses", "data_requests"):
r[k] = r.get(k) or []
return r
r = parse_review(job["output"]["output"])
print(r["verdict"], r["anchor"]["metric"], [c["ticker"] for c in r["peer_calls"] if c["call"] == "exclude"])
function parseReview(text) {
const t = String(text).trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
if (!["sound", "usable_with_caveats", "unreliable"].includes(r.verdict)) r.verdict = "usable_with_caveats";
for (const k of ["peer_calls", "metric_readings", "flag_responses", "data_requests"]) r[k] = r[k] || [];
return r;
}
const r = parseReview(job.output.output);
console.log(r.verdict, r.anchor.metric, r.peer_calls.filter((c) => c.call === "exclude").map((c) => c.ticker));
type Review struct {
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Anchor struct {
Metric string `json:"metric"`
Reason string `json:"reason"`
} `json:"anchor"`
PeerCalls []struct {
Ticker string `json:"ticker"`
Call string `json:"call"`
Reason string `json:"reason"`
} `json:"peer_calls"`
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, r.Anchor.Metric, len(r.PeerCalls))
// 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("anchor").get("metric").asText() + " " + r.get("peer_calls").size());
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
r["verdict"] = "usable_with_caveats" unless %w[sound usable_with_caveats unreliable].include?(r["verdict"])
puts r["verdict"], r["peer_calls"].select { |c| c["call"] == "exclude" }.map { |c| c["ticker"] }.inspect
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $r["verdict"], " ", $r["anchor"]["metric"], " ", count($r["peer_calls"]), 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("anchor").GetProperty("metric")} {r.GetProperty("peer_calls").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). peer_callshas exactly one entry per ticker infacts.peers, and names no other ticker.anchor.metricisev_revenue,ev_ebitdaorpe, itsfacts.statisticsentry hasnof at leastrules.peers_fail, itsimplied_valuationentry is usable, and for thefinancialssector it ispe.- The
headlinequotes the anchor's median exactly as written infacts.statistics. - Every
metric_readings[].metricis a key offacts.statistics. flag_responseshas exactly one entry per code infacts.flags, in the same order, and no others.- The verdict is
unreliablewhenever a flag has high severity, and neversoundwhen a flag has medium severity.
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.
| code | severity | meaning |
|---|---|---|
too_few_peers | high | Fewer than 3 peers (peers_fail) are in the statistics. |
anchor_multiples_sparse | high | None of EV / EBITDA, EV / Revenue or P / E has 3 or more meaningful values. |
target_size_mismatch | high or medium | The 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_set | medium | Fewer than 4 peers (peers_min) are in the statistics. |
nm_heavy | medium | EV / EBITDA is NM for more than a third of the peers. |
outliers | medium | A 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_range | medium | The 75th percentile of EV / EBITDA or EV / Revenue is more than 2x (wide_range_ratio) the 25th. |
missing_ev_inputs | medium | Enterprise value cannot be built for a peer: market cap or net debt is missing. |
duplicate_peer | medium | A ticker is listed twice; only the first row counts. |
target_in_peers | medium | The target appears in its own peer set. |
margin_order | low | Gross margin above EBITDA margin above net margin does not hold for a peer. |
multiple_range | low | A 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_dispersion | low | The largest peer's revenue is more than 20x the smallest's. |
multiples_ignore_growth | low | Across 5 or more peers, faster growth does not go with a higher EV / Revenue (rank correlation at or below zero). |
mcap_mismatch | low | A given market cap differs from price x shares by more than 2%. |
ebitda_not_meaningful | low | Financial-services sector: EBITDA and gross margin are not meaningful, P / E carries the valuation. |
sector_metric_gap | low | Software sector with no peer carrying both growth and free cash flow, so Rule of 40 is unavailable. |
growth_as_decimal | low | Every 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.