← Screen Desk / API
Tokens

Drive Screen Desk from your own code

Everything the web page does is available over HTTP. Send the deal material (a teaser, a CIM excerpt or a broker email) with the facts the browser computes for it, and get the same screening memo back: a verdict (further_diligence, pass, hard_pass), a headline, the extracted deal facts, one call per row of your fund's box, a bull and a bear case, red flags with the exact quote, the questions for a first call, one response per prescan flag and a summary. Or send the same material with a meeting description and get a one-page diligence meeting prep: three objectives, 15 to 20 prioritised questions ending with "What haven't we asked about that we should?", benchmarks from the material, red flags to probe and follow-up requests. The natural use is a deal-flow pipeline: a script screens every inbound teaser against the box, files the memo with the deal, and turns the ones worth a call into a meeting prep.

One thing to be clear about before the first call: the model never does the mechanical half. The figures (revenue, EBITDA, margin, growth, price, multiple, customer concentration), the self-checks (a margin that is not EBITDA / revenue, a growth rate the revenue series does not produce, a multiple the price and EBITDA do not imply) and the screen against the fund's criteria are worked out by screenkit.js, the same file the web page loads. The result is sent as facts, a JSON string. The model's job is judgement over that work. See building the facts below.

Two lanes: the task field

Every request names its lane in task, and one system prompt routes on it. There are two:

taskwhat it does
screenA first-look screen of inbound deal flow (deal-screening): a verdict (further_diligence, pass, hard_pass), a headline, eleven deal facts (company, location, sector, description, financials, deal type, valuation, seller motivation, management, customers, key risks), one criteria row per row of the fund's box with the same ids, 2-3 bull and 2-3 bear points with quotes, red flags by severity (critical, important, minor), 3-8 key questions for a first call, one prescan_responses entry per prescan flag and a summary.
prepA one-page prep for a diligence meeting (dd-meeting-prep): management presentation, expert network call, customer reference, advisor check-in or site visit. A headline, logistics, exactly three objectives, 15-20 questions grouped by topic with 5-8 marked must_ask and a ref to the concern (C1), prescan flag (P2) or screen question (K1) they address, benchmarks from the material or to confirm, red flags with a neutral probe, follow-ups and a summary.

A missing or unknown task is still answered, as the closest lane (a meeting field means prep), and the reply's lane names the lane that was used. Always send task and check lane in the reply.

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":"screen-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 material or 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. Every field is a string: facts, meeting and screen must be JSON-encoded strings, not objects. 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. Both lanes are metered, so a run 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://screen-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; both lanes need 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":"screen-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="screen-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://screen-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 run (free)

The input object is exactly what the app's form submits. The first field is task, the lane (see the two lanes):

taskwhat it does
screenThe screening memo: verdict (further_diligence, pass, hard_pass), deal facts, a call per criterion, bull and bear case, red flags, key questions, prescan responses, summary.
prepThe meeting prep: objectives, 15-20 questions with must_ask and ref, benchmarks, red flags to probe, follow-ups, summary.

A missing or unknown task is answered as the closest lane, and the reply's lane names it.

fieldtypemeaning
taskstring, required"screen" or "prep".
materialstring, requiredThe teaser, CIM excerpt or broker email, as pasted. ScreenKit.clipMaterial keeps it to 48,000 characters: longer material loses its middle on paragraph boundaries (the head with the company overview and the tail with the financial summary survive) and a marker line takes its place, [... lines 120-340 (12,345 characters) not sent for length; the facts cover the whole material ...]. Every quote in the reply is searched for in this text.
factsstring, requiredThe JSON-encoded output of ScreenKit.factsFor: the deal name and description, the extracted figures, the qualitative reads, the criteria rows with the browser's calls, the box verdict and the prescan flags. It is computed over the whole material, clipped or not.
questionstring, optionalWhat you want to know, up to 2,000 characters. Left out when empty. A longer question is cut on a word boundary and ends with [...cut for length].
meetingstring, prep onlyA JSON string describing the meeting: type (management, expert, customer, advisor or site), type_label, attendees (up to 600 characters), duration_minutes (a number, 90 by default), focus and concerns, an array of {id: "C1", text} built by ScreenKit.parseConcerns from one concern per line (at most 12, each up to 400 characters).
screenstring, prep only, optionalA JSON string carrying an earlier screen into the prep: verdict, headline, key_questions as {id: "K1", text}, red_flags as {severity, flag} and bear as plain strings. Recon.handoff builds it from a screen reply (see handing the result on).
retry_notestring, optionalLeave it out. The page sets it only on its one reformat retry after an unparseable reply: a plain instruction about the reply's shape.

Every value is a string. facts, meeting and screen hold JSON, but travel JSON-encoded; an object in any of them is a validation_error on a run. The app declares an input schema with task, material and facts required, but /estimate does no body validation: a malformed body (a bare string, facts as an object, no material at all, a lane that does not exist) prices as happily as a good one. So validate on your side before you run: the body must be a JSON object whose task is "screen" or "prep", whose material is a non-empty string and whose facts is a string. The web app builds every input with ScreenKit.buildInput, which guarantees that shape, and passes it through ScreenKit.mustBeObject before pricing or running it.

Building the facts

screenkit.js is plain JavaScript with no dependencies, is served next to the page (screenkit.js, with recon.js for the reply) and exports itself to node. Download it, put the material in a text file, describe your fund's box, and let it build the body. ScreenKit.analyze(text, criteria) runs the whole free pass: the extraction, the self-checks (A.flags, ids P1, P2 ...) and the criteria rows (A.rows, A.box). It is free and local; only the run is metered.

// make-body.js
//   node make-body.js teaser.txt "your question"                     > body.json   (screen)
//   node make-body.js teaser.txt "your question" concerns.txt        > body.json   (prep)
const fs = require("fs");
const ScreenKit = require("./screenkit.js");   // https://screen-desk.skillsafe.ai/screenkit.js

const [materialFile, question = "", concernsFile] = process.argv.slice(2);
const text = fs.readFileSync(materialFile, "utf8");
const name = "Project Keystone";               // shown in facts.deal_name

// The fund's box. Values are strings (mgmt_continuity is true or false); "" drops a criterion (not_set).
const criteria = Object.assign({}, ScreenKit.DEFAULT_CRITERIA, {
  revenue_min: "10", revenue_max: "150", ebitda_min: "3", ebitda_max: "25",
  margin_min: "12", growth_min: "5", ev_min: "", ev_max: "250", multiple_max: "12", top_customer_max: "20",
  sectors: "business services, industrial services, healthcare services, software",
  sectors_excluded: "retail, restaurants, oil and gas, crypto",
  geographies: "United States, Canada", mgmt_continuity: true,
});

const A = ScreenKit.analyze(text, criteria);   // figures, prescan flags P1, P2 ..., criteria rows, box verdict

let body;
if (!concernsFile) {
  body = ScreenKit.buildInput("screen", text, A, { question, name });
} else {
  body = ScreenKit.buildInput("prep", text, A, {
    question, name,
    meeting_type: "management",                // management | expert | customer | advisor | site
    attendees: "Founder and CEO; CFO; VP Operations",
    duration: "90",
    focus: "full",
    concerns: fs.readFileSync(concernsFile, "utf8"),   // one concern per line: C1, C2 ... (at most 12)
    // screen: Recon.handoff(screenReply).screen      // optional: carry a screen in as K1..Kn
  });
}
process.stdout.write(JSON.stringify(ScreenKit.mustBeObject(body)));   // {task, material, facts:"{...}", question, ...}

The fund's box, one key per criterion (ScreenKit.DEFAULT_CRITERIA holds the defaults above):

keyscriteria rowhow the browser calls it
revenue_min, revenue_maxrevenueLatest historical revenue against the range, in $M.
ebitda_min, ebitda_maxebitdaLatest (adjusted where given) EBITDA against the range, in $M.
margin_minmarginEBITDA / revenue, or the stated margin, against the floor in %.
growth_mingrowthRevenue growth computed from the series, or the stated rate or CAGR.
sectors, sectors_excludedsectorA keyword read of the description; the model may overrule it.
geographiesgeographyThe headquarters (or the first place named) against the list; a keyword read.
ev_min, ev_maxevThe stated price, or one implied by a stated multiple of EBITDA.
multiple_maxmultipleThe stated multiple, or price / EBITDA.
top_customer_maxconcentrationThe largest customer's share, or the top-N share when that alone settles it.
mgmt_continuitymanagementWhether management stays or rolls; a keyword read.

Each row's call is meets, misses, unknown (the material is silent) or not_set (no target). The box verdict is hard_pass on any miss of revenue, EBITDA, EV, sector or geography, or on three misses of any kind; pass on any other miss; further_diligence otherwise.

What facts carries once it is parsed:

keycontents
deal_name, descriptionThe name you passed (or one read from the material, possibly empty) and the sentence that best describes the business.
figuresrevenue, ebitda and price as {text, value_musd, period, line}; implied_price as {value_musd, how}; ebitda_margin_pct with margin_basis, revenue_growth_pct with growth_basis ("computed 2023-2024" or "stated"), multiple_x, largest_customer_pct, top_customers ({n, pct}), recurring_pct, revenue_by_year, forecast_revenue and currency. A figure the material does not give is null.
qualitativeheadquarters, places_named, deal_types (platform, add-on, recap, minority, carve-out ...), management (rolling, exiting, mixed or unknown) and seller_motivation.
criteriaOne row per criterion: {id, criterion, target, actual, call, note}, ids as in the table above.
box_verdict, box_misses, box_unknownThe browser's verdict from the rows alone, and the ids that miss or are unknown.
prescan_flags, prescan_countsWhat the self-checks found, as {id, severity, category, message, lines} with ids P1, P2 ... sorted critical first; and the count per severity. Categories: consistency, calculation, projection, earnings_quality, concentration, management, missing.
clippednull, or {lines_not_sent, characters_not_sent} when the material lost its middle.

Worked inputs

The page's Project Keystone example (a teaser for a Midwest commercial HVAC and mechanical services platform) as a screen body, built by make-body.js with the default box. The material and the facts string are abbreviated here (strings cut with ..., three of the ten criteria rows shown); the field names and shapes are exact. Every criterion meets, the box verdict is further_diligence and the prescan finds no flags:

{
  "task": "screen",
  "material": "PROJECT KEYSTONE - CONFIDENTIAL TEASER\n\nKeystone Mechanical (\"Keystone\" or the \"Company\") is an industrial services platform providing commercial HVAC, mechanical and building automation services to hospitals, schools, data centers and light industrial facilities. The Company is headquartered in Columbus, Ohio and operates from six branches across Ohio, Indiana and Kentucky.\n\nInvestment highlights\n- 62% of revenue co ...",
  "facts": "{\"deal_name\":\"Project Keystone\",\"description\":\"Keystone Mechanical (\\\"Keystone\\\" or the \\\"Company\\\") is an industrial services platform provi ...\",\"figures\":{\"revenue\":{\"text\":\"$52.6M\",\"value_musd\":52.6,\"period\":\"FY2024\",\"line\":15},\"ebitda\":{\"text\":\"$8.1M\",\"value_musd\":8.1,\"period\":\"FY2024\",\"line\":16},\"price\":null,\"implied_price\":{\"value_musd\":72.9,\"how\":\"9x x $8.1M\"},\"ebitda_margin_pct\":15.4,\"margin_basis\":\"computed\",\"revenue_growth_pct\":17.1,\"growth_basis\":\"computed 2023-2024\",\"multiple_x\":9,\"largest_customer_pct\":7,\"top_customers\":{\"n\":10,\"pct\":31},\"recurring_pct\":62,\"revenue_by_year\":[\"$38.4M FY2022\",\"$44.9M FY2023\",\"$52.6M FY2024\"],\"forecast_revenue\":[\"$60.5M FY2025E\"],\"currency\":\"USD\"},\"qualitative\":{\"headquarters\":\"united states\",\"places_named\":[\"united states\"],\"deal_types\":[\"platform\",\"add-on\"],\"management\":\"rolling\",\"seller_motivation\":\"The founder and CEO, age 61, is seeking a partner to support succession planning and accel ...\"},\"criteria\":[{\"id\":\"revenue\",\"criterion\":\"Revenue range\",\"target\":\"$10M-$150M\",\"actual\":\"$52.6M (FY2024)\",\"call\":\"meets\",\"note\":\"\"},{\"id\":\"sector\",\"criterion\":\"Sector fit\",\"target\":\"business services, industrial services, healthcare services, software; not retail, restaurants, oil and gas, crypto\",\"actual\":\"Keystone Mechanical (\\\"Keystone\\\" or the \\\"Company\\\") is an indu ...\",\"call\":\"meets\",\"note\":\"Matches industrial services.\"},{\"id\":\"ev\",\"criterion\":\"Deal size / EV\",\"target\":\"<= $250M\",\"actual\":\"$72.9M implied (9x x $8.1M)\",\"call\":\"meets\",\"note\":\"\"}],\"box_verdict\":\"further_diligence\",\"box_misses\":[],\"box_unknown\":[],\"prescan_flags\":[],\"prescan_counts\":{\"critical\":0,\"important\":0,\"minor\":0},\"clipped\":null}",
  "question": "Is this worth a first call with the founder next week?"
}

The meeting prep made from that screen's handoff, as a prep body. The six concerns are the screen's three red flags and three bear points, one per line, and the screen travels with its eight key questions as K1 to K8. The material and facts are the same as above; meeting and screen are abbreviated the same way (three of the six concerns, three of the eight key questions, two red flags and two bear points shown):

{
  "task": "prep",
  "material": "(the same material as the screen body)",
  "facts": "(the same facts string as the screen body)",
  "question": "Is this worth a first call with the founder next week?",
  "meeting": "{\"type\":\"management\",\"type_label\":\"Management presentation\",\"attendees\":\"Founder and CEO; CFO; VP Operations\",\"duration_minutes\":90,\"focus\":\"full\",\"concerns\":[{\"id\":\"C1\",\"text\":\"The $0.9M of owner compensation and one-time add-backs is not broken down; the s ...\"},{\"id\":\"C2\",\"text\":\"Succession is the stated reason for the sale, but no successor CEO or second-lin ...\"},{\"id\":\"C3\",\"text\":\"Financials are summary-level only: no gross margin, working capital, capex, cash ...\"}]}",
  "screen": "{\"verdict\":\"further_diligence\",\"headline\":\"Keystone Mechanical is a $52.6M revenue, $8.1M Adjusted EBITDA Midwest HVAC and mechanical ...\",\"key_questions\":[{\"id\":\"K1\",\"text\":\"What makes up the $0.9M of FY2024 add-backs - how much is owner compen ...\"},{\"id\":\"K2\",\"text\":\"Is the 17% FY2024 growth fully organic, or does it include any acquisi ...\"},{\"id\":\"K3\",\"text\":\"Who is the intended successor to the founder, and does the founder pla ...\"}],\"red_flags\":[{\"severity\":\"important\",\"flag\":\"The $0.9M of owner compensation and one-time add-backs is not broken d ...\"},{\"severity\":\"important\",\"flag\":\"Succession is the stated reason for the sale, but no successor CEO or ...\"}],\"bear\":[\"Adjusted EBITDA leans on $0.9M of add-backs (11.1% of $8.1M); on the p ...\",\"Key-person and succession risk: a 61-year-old founder CEO committing t ...\"]}"
}

Now price it. /estimate is free: it creates no job and charges nothing, and returns hold_credits, min_credits, model, model_alias (gpt-terra) and markup_bps (1000, a 10% markup). hold_credits is a reservation against the full output cap, not the price: you are charged for what the run actually uses, reported afterwards as charged_credits. Price each lane separately; a prep body carries the meeting and the screen and prices differently from a screen body over the same material.

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("screen","prep") and isinstance(b.get("material"),str) and b["material"] and isinstance(b.get("facts"),str)'
INPUT=$(cat body.json)
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')

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

A run is metered, so it needs a personal token from signing in; a guest token gets a 403 here. 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, and put the lane in it. The web app sends "screen-desk:" + task + ":" + hash + ":a" + attempt, for example screen-desk:screen:bd6c8b32-152f:a1. A retried request with the same key returns the same job instead of billing a second run, and because the lane is part of the key, a screen and a meeting prep over the same material never collide. Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a changed body. The web app's hash is ScreenKit.hashInput, a djb2 hash of the input JSON plus its length in hex; any stable content hash works, and 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 and sends it with the attempt suffix bumped to :a2, so the reformat retry is a distinct, separately billed run. The note reads: Your previous reply was not the single valid JSON object the instructions require (<the parse error>). Reply again with ONLY the JSON object for task 'screen' - no prose, no code fences; every key present (empty arrays where there is nothing to say). (with 'prep' for the prep lane). Do the same from code.

# Always send an Idempotency-Key derived from the lane and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="screen-desk:$LANE:$(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\":\"screen\",\"verdict\":\"further_diligence\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream is the same call over server-sent events, with the same personal token and the same lane-bearing Idempotency-Key. 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\":\"screen\",\"verdict\":\"further_diligence\",\"headline\":\"Keystone Mechanical is"}
# 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 (Recon.parseResult) and normalizes it with Recon.normalize(obj, task) (recon.js, which also exports itself to node and loads ./screenkit.js itself). The lane is read from lane (or task); when both are missing it is guessed (criteria or verdict means screen, questions or objectives means prep), and a lane other than the one you sent is surfaced as lane_mismatch. For screen, an unknown verdict is derived (any misses row: pass, otherwise further_diligence), criteria ids are lower-cased and an unknown call becomes unknown, a bull or bear point sent as a bare string becomes {point, quote: ""}, an unknown red-flag severity becomes important and red flags are re-sorted critical first, prescan ids are upper-cased and an unknown prescan call becomes confirmed. For prep, a question with no topic goes under General, must_ask counts only when it is true (or the string "true" or "yes"), ref is upper-cased with spaces removed ("C1,P2"), an unknown benchmark source becomes to_confirm and duration_minutes is read as a whole number. Missing arrays become empty, and present lists the sections the reply actually carried. A screen reply with no headline and no criteria, or a prep reply with no headline and no questions, counts as unparseable and triggers the one retry_note retry.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r.get("lane"), "-", r["headline"])
if r.get("lane") == "prep":
    for q in r["questions"]:
        print("*" if q["must_ask"] is True else " ", q["topic"], "|", q["question"], q["ref"])
else:
    print(r["verdict"])
    for c in r["criteria"]:
        print(c["id"], c["call"], c["actual"])
    for f in r["red_flags"]:
        print(f["severity"], f["flag"])
EOF

Invariants worth asserting

The page holds every reply to the material with Recon.reconcile(r, ctx), where ctx is {analysis: A, material} for screen, plus concerns (the parsed meeting.concerns) and screen_questions (the parsed screen.key_questions) for prep. It returns the statements it checked and the disagreements it found. What it checks:

Handing the result on

The two lanes chain. Recon.handoff(screenReply) turns a normalized screen into the start of a meeting prep: meeting_type ("management"), concerns (the red flags and bear points, one per line, deduplicated, at most 12) and screen (the verdict, headline, key questions as K1 to Kn, red flags and bear case). Edit the concerns if you like, then build the prep body from them; the prep's questions cite them as C1, C2 ... and the screen's questions as K1, K2 ...

const ScreenKit = require("./screenkit.js");
const Recon = require("./recon.js");
const screen = Recon.normalize(Recon.parseResult(replyText), "screen");
const check = Recon.reconcile(screen, { analysis: A, material: text });
console.log(check.statements, "statements,", check.disagreements, "disagreements");
const h = Recon.handoff(screen);                        // {meeting_type, concerns, screen}
const prepBody = ScreenKit.buildInput("prep", text, A, {
  question, name,
  meeting_type: h.meeting_type, attendees: "Founder and CEO; CFO; VP Operations", duration: "90", focus: "full",
  concerns: h.concerns, screen: h.screen,
});

The output contract

screen

{
  "lane": "screen",
  "verdict": "further_diligence" | "pass" | "hard_pass",
  "headline": "one sentence a partner reads first",
  "facts": {
    "company": "", "location": "", "sector": "", "description": "", "financials": "",
    "deal_type": "", "valuation": "", "seller_motivation": "", "management": "",
    "customers": "", "key_risks": ""
  },
  "criteria": [{"id": "revenue", "call": "meets" | "misses" | "unknown" | "not_set", "actual": "", "comment": ""}],
  "bull": [{"point": "", "quote": ""}],
  "bear": [{"point": "", "quote": ""}],
  "red_flags": [{"severity": "critical" | "important" | "minor", "flag": "", "quote": "", "prescan": "P1 or empty"}],
  "key_questions": [""],
  "prescan_responses": [{"id": "P1", "call": "confirmed" | "dismissed", "reason": ""}],
  "summary": "3-5 sentences: the memo's bottom line"
}

facts says "not stated" wherever the material is silent. criteria has one row per row in facts.criteria, same ids, same order; a row the browser left unknown may be decided from the material, with how in comment. Verdicts: further_diligence is worth a first call; pass declines for now (misses on non-core criteria, or concerns that outweigh the fit); hard_pass is clearly outside the box (size, sector or geography misses, or a disqualifying red flag). There are 2-3 bull and 2-3 bear points and 3-8 key questions. Every number is in the material or is arithmetic on it that is shown ("$8.1M / $52.6M = 15.4%"). The screen never recommends a price or tells you to buy.

prep

{
  "lane": "prep",
  "headline": "one sentence: what this meeting must settle",
  "logistics": {"meeting": "", "attendees": "", "duration_minutes": 90, "note": ""},
  "objectives": ["", "", ""],
  "questions": [{"topic": "", "question": "", "must_ask": true, "why": "", "ref": "C1, P2, K1 or empty"}],
  "benchmarks": [{"metric": "", "value": "", "source": "material" | "to_confirm", "quote": "", "use": ""}],
  "red_flags": [{"flag": "", "quote": "", "probe": ""}],
  "follow_ups": [""],
  "summary": "2-4 sentences: how to run the meeting"
}

Exactly three objectives. 15 to 20 questions grouped by topic and shaped to the meeting type, open-ended first, 5-8 of them must_ask, the last exactly "What haven't we asked about that we should?". Every concern in meeting.concerns is addressed by at least one question whose ref is its id. A to_confirm benchmark names what to pull before the meeting (peer margins, industry growth) and writes no figure, because the model has no data beyond the material. Every array is present, empty when there is nothing to say.

Worked replies

Abbreviated from the saved runs the page replays for its examples (arrays shortened; each shown entry is verbatim). The screen reply to the Project Keystone body above:

{
  "lane": "screen",
  "verdict": "further_diligence",
  "headline": "Keystone Mechanical is a $52.6M revenue, $8.1M Adjusted EBITDA Midwest HVAC and mechanical services platform that meets every box criterion; worth a first call, with the $0.9M of add-backs and the founder succession plan as the main items to test.",
  "facts": {
    "company": "Keystone Mechanical (Project Keystone)",
    "location": "Headquartered in Columbus, Ohio; six branches across Ohio, Indiana and Kentucky",
    "sector": "Industrial services - commercial HVAC, mechanical and building automation services",
    "description": "Provides commercial HVAC, mechanical and building automation services to hospitals, schools, data centers and light industrial facilities. 62% of revenue comes from recurring service agreements and repeat maintenance work.",
    "financials": "FY2024 revenue $52.6M, up 17% ($52.6M / $44.9M - 1 = 17.1%); Adjusted EBITDA $8.1M, 15.4% margin ($8.1M / $52.6M). FY2022 revenue $38.4M, EBITDA $5.5M; FY2023 $44.9M, $6.6M; FY2025E $60.5M, $9.6M (15.9%). Adjusted EBITDA includes $0.9M of owner compensation and one-time add-backs in FY2024; before them EBITDA is $8.1M - $0.9M = $7.2M, a 13.7% margin ($7.2M / $52.6M).",
    "deal_type": "Platform investment, with an add-on strategy (14 identified regional HVAC contractors)",
    "valuation": "Price expectations of 9.0x-10.0x FY2024 Adjusted EBITDA, i.e. 9.0 x $8.1M = $72.9M to 10.0 x $8.1M = $81.0M. On EBITDA before add-backs ($7.2M) that range is $72.9M / $7.2M = 10.1x to $81.0M / $7.2M = 11.3x.",
    "seller_motivation": "Founder and CEO, age 61, seeking a partner to support succession planning and accelerate the add-on strategy.",
    "management": "Rolling: the founder intends to remain CEO for at least three years; the senior management team will roll 20% of their proceeds. Whether the founder himself rolls equity is not stated.",
    "customers": "More than 1,400 customers; largest customer 7% of revenue; top 10 customers 31% of revenue. Customer names not stated.",
    "key_risks": "Quality of the $0.9M add-backs (11.1% of Adjusted EBITDA: $0.9M / $8.1M); founder succession, with a successor CEO not identified; FY2025E forecast of $60.5M assumes continued 15.0% growth ($60.5M / $52.6M - 1); technician labour supply; add-on integration execution; competitive process with IOIs due in four weeks."
  },
  "criteria": [
    {
      "id": "revenue",
      "call": "meets",
      "actual": "$52.6M (FY2024)",
      "comment": "Within $10M-$150M."
    },
    {
      "id": "ebitda",
      "call": "meets",
      "actual": "$8.1M Adjusted EBITDA (FY2024)",
      "comment": "Within $3M-$25M; still meets on EBITDA before add-backs of $8.1M - $0.9M = $7.2M."
    },
    {
      "id": "margin",
      "call": "meets",
      "actual": "15.4% (FY2024)",
      "comment": "Above 12%; before the $0.9M add-backs the margin is $7.2M / $52.6M = 13.7%, still above 12%."
    }
  ],
  "bull": [
    {
      "point": "Recurring revenue base: 62% from service agreements and repeat maintenance, serving end markets such as hospitals, schools and data centers.",
      "quote": "62% of revenue comes from recurring service agreements and repeat maintenance work"
    }
  ],
  "bear": [
    {
      "point": "Adjusted EBITDA leans on $0.9M of add-backs (11.1% of $8.1M); on the pre-add-back $7.2M the asking range is 10.1x-11.3x.",
      "quote": "Adjusted EBITDA includes $0.9M of owner compensation and one-time add-backs in FY2024."
    }
  ],
  "red_flags": [
    {
      "severity": "important",
      "flag": "The $0.9M of owner compensation and one-time add-backs is not broken down; the split between a normalised owner salary and genuinely non-recurring items determines the real earnings base and multiple.",
      "quote": "Adjusted EBITDA includes $0.9M of owner compensation and one-time add-backs in FY2024.",
      "prescan": ""
    },
    {
      "severity": "important",
      "flag": "Succession is the stated reason for the sale, but no successor CEO or second-line leadership is identified, and whether the founder rolls equity is not stated (only senior management's 20% roll is).",
      "quote": "the senior management team will roll 20% of their proceeds",
      "prescan": ""
    }
  ],
  "key_questions": [
    "What makes up the $0.9M of FY2024 add-backs - how much is owner compensation above a market CEO salary and how much is one-time, and what were the add-backs in FY2022 and FY2023?",
    "Is the 17% FY2024 growth fully organic, or does it include any acquisitions, pricing or one-off project work?",
    "Who is the intended successor to the founder, and does the founder plan to roll equity alongside the senior management team?"
  ],
  "prescan_responses": [],
  "summary": "Keystone meets all ten box criteria: $52.6M revenue, $8.1M Adjusted EBITDA at a 15.4% margin, 17.1% growth, 7% largest customer, U.S.-based, and an asking range of 9.0x-10.0x ($72.9M-$81.0M). The case rests on 62% recurring revenue, steady margin expansion and a named add-on pipeline. The main concerns are the undisclosed composition of $0.9M of add-backs, which moves the effective multiple to 10.1x-11.3x on a $7.2M pre-add-back base, and a founder succession plan with no named successor. It is worth a first call with the founder, focused on add-back quality, organic growth and succession; this is a screen, not investment advice."
}

The full reply has ten criteria rows (all meets), three bull and three bear points, three red flags and eight key questions; Recon.reconcile checks 32 statements in it and finds no disagreement. The prep reply to the prep body above:

{
  "lane": "prep",
  "headline": "This management presentation must settle what the $0.9M of FY2024 add-backs really are, who runs Keystone after the founder, and whether the FY2025E $60.5M plan is organic and achievable.",
  "logistics": {
    "meeting": "Management presentation",
    "attendees": "Founder and CEO; CFO; VP Operations",
    "duration_minutes": 90,
    "note": "Full-scope session. Direct add-back and financial questions to the CFO, succession and add-on strategy to the founder, technician hiring and branch operations to the VP Operations. Leave about 10 minutes at the end for the closing question and follow-up requests."
  },
  "objectives": [
    "Quality of earnings: the breakdown of the $0.9M of FY2024 owner compensation and one-time add-backs, the add-backs in FY2022 and FY2023, and what that means for the pre-add-back earnings base ($8.1M - $0.9M = $7.2M, where 9.0x-10.0x of $8.1M = $72.9M-$81.0M is 10.1x-11.3x).",
    "Leadership continuity: who succeeds the 61-year-old founder, how deep the second line is, and whether the founder rolls equity alongside management's 20%.",
    "Growth durability: how much of the 17% FY2024 growth and the planned 15.0% to $60.5M in FY2025E ($60.5M / $52.6M - 1) is organic, contracted or recurring, and whether technician hiring can support it."
  ],
  "questions": [
    {
      "topic": "Revenue and growth",
      "question": "What drove the 17% revenue growth in FY2024, and how much came from price, volume, new customers, one-off projects or any acquisitions?",
      "must_ask": true,
      "why": "The organic versus acquired split is not stated, so the growth base and the FY2025E plan cannot yet be assessed.",
      "ref": "C6, K2"
    },
    {
      "topic": "Operations and team",
      "question": "What is the succession plan: who is the intended successor CEO, how ready is the second line of leadership, and what is the timeline?",
      "must_ask": true,
      "why": "Succession is the stated reason for the sale, but no successor or second-line leadership is named.",
      "ref": "C2, C5, K3"
    },
    {
      "topic": "Financial deep-dive",
      "question": "Can you break down the $0.9M of FY2024 owner compensation and one-time add-backs line by line, including what a market-rate CEO salary would be?",
      "must_ask": true,
      "why": "The split between normalised owner pay and non-recurring items sets the real earnings base; on $7.2M pre-add-back the asking range is 10.1x-11.3x.",
      "ref": "C1, C4, K1"
    },
    {
      "topic": "Closing",
      "question": "What haven't we asked about that we should?",
      "must_ask": false,
      "why": "Open floor for issues not covered.",
      "ref": ""
    }
  ],
  "benchmarks": [
    {
      "metric": "FY2024 add-backs",
      "value": "$0.9M",
      "source": "material",
      "quote": "Adjusted EBITDA includes $0.9M of owner compensation and one-time add-backs in FY2024.",
      "use": "$0.9M / $8.1M = 11.1% of Adjusted EBITDA; ask for the line-by-line split."
    },
    {
      "metric": "Peer EBITDA margins for commercial HVAC and mechanical services",
      "value": "Pull peer and precedent-transaction margins before the meeting",
      "source": "to_confirm",
      "quote": "",
      "use": "Judge whether the adjusted margin is in line with comparable service contractors."
    }
  ],
  "red_flags": [
    {
      "flag": "The $0.9M of owner compensation and one-time add-backs is not broken down and is 11.1% of Adjusted EBITDA ($0.9M / $8.1M).",
      "quote": "Adjusted EBITDA includes $0.9M of owner compensation and one-time add-backs in FY2024.",
      "probe": "Can you take us through each add-back and the evidence that it will not recur under new ownership?"
    }
  ],
  "follow_ups": [
    "Line-by-line schedule of FY2022-FY2024 add-backs, including owner compensation versus a market CEO salary",
    "Monthly or quarterly P&L with gross margin by service line, plus balance sheets, working capital and capex for FY2022-FY2024",
    "FY2025E budget build with year-to-date actuals and current backlog"
  ],
  "summary": "Open with the founder's story and the business mix to build rapport, then move to growth and the FY2025E build before the CFO-led add-back deep-dive, which is the most important 20 minutes. Take succession directly with the founder and ask about his own equity plans neutrally. Close with the add-on pipeline, the open question, and the list of follow-up requests."
}

The full reply has 17 questions (7 of them must_ask), eleven benchmarks (eight from the material, three to confirm), five red flags and ten follow-ups, and every one of the six concerns is addressed.

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 criteria and red flags may be complete while the key questions and summary are missing. The web page closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how many it recovered: ten for screen (verdict, headline, facts, criteria, bull, bear, red flags, key questions, prescan responses, summary) and eight for prep (headline, logistics, objectives, questions, benchmarks, red flags, follow-ups, summary). From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.