← TRIPOD Desk / API
Tokens

Drive TRIPOD Desk from your own code

Everything the web page does is available over HTTP: send the methods and results of a clinical prediction model — as labelled lines, as prose, or both — with an optional performance table, and get back the same structured validation review the browser renders. Four lanes share one request shape and one response envelope: cohort asks whether the prediction problem is well posed at all, validate reviews the split ladder and the performance numbers, attrib reviews the explainer and every explanation claim made from it, and report writes the TRIPOD+AI reporting pack a journal or a regulator will ask for.

The natural uses are a submission gate in a group's own tooling — refuse to send a manuscript whose cohort lane comes back not_supported — and a batch pass over a directory of model reports that says which ones claim a causal effect from a SHAP ranking, or report an AUC with no calibration behind it, before a reviewer has to find it.

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": "...", "details": { ... } } }

There is no X-App-Slug header. The browser SDK this app ships sends exactly two headers on a normal call — Content-Type: application/json and Authorization: Bearer … — plus Idempotency-Key on a run and Accept: text/event-stream on a stream. The slug tripod-desk appears in one place only: the body of POST /guest. A token is already bound to its app, so nothing downstream needs to be told the slug again, and a header that looks like it should work is simply ignored. People invent that header, spend an afternoon on it, and it was never read.

The endpoints, in the order a caller uses them:

callauthcostwhat it does
POST /guestnonefreeMints a guest token for one app. Answers 201 with {token, guest_id, expires_at}.
GET /metokenfreeReturns {subject_type, subject_id, credits} and nothing else.
POST /estimatetokenfreePrices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token.
POST /runtokenmeteredStarts a review. Returns {job_id}.
GET /jobs/{job_id}tokenfreePolls one job. The terminal job carries output.output, charged_credits and truncated.
POST /run-streamtokenmeteredThe same run as server-sent events: job, delta, done, and error on a failure.

The request body IS the input object. It is never wrapped in an input key.

The body of /estimate, /run and /run-stream is the flat input object — {"task": …, "report": …, …}. Not {"input": {…}}, not {"body": {…}}, not {"data": {…}}. This is the single most important sentence on the page, because the wrong shape does not fail: a wrapped body returns 200, reserves a plausible-looking hold, produces a job that succeeds, and bills you — while the model receives an object with none of the fields it is told to read. What comes back is a fluent review of nothing: a report about a model whose cohort, outcome and metrics it never saw. There is no error code for it and no warning in the reply. The only defence is sending the object flat, which is what every sample on this page does, plus the one assertion in the verification step — read lane back and compare it to the task you sent.

See the request body for the field list.

Error codes

codestatuswhat causes it here, and what to do
VALIDATION_ERROR400The body is not the shape the app expects — most often a missing report, or a prescan_facts sent as a JSON string instead of an object. A body that is not valid JSON at all lands here too. Note what is not a validation error: an absent task (the model picks a lane), an absent metrics (legitimate), and a body wrapped in input (a silent 200).
UNAUTHORIZED401The token is missing, malformed or past its expires_at. The common surprise is a 401 from /estimate: it is free but still authenticated, so it cannot run before step 1. Mint another token with POST /guest, or copy a personal one from the token page.
INSUFFICIENT_CREDITS402The balance is below min_credits. Call /estimate first — it is free — and compare min_credits against credits from /me before you start a batch. A balance between min_credits and hold_credits does not 402: it runs truncated. See truncation.
FORBIDDEN403The token is valid but not for this app, or a guest token tried a metered run. Mint the token against tripod-desk, and sign in for a metered lane — a review is metered, so a guest gets a 403 on /run rather than a 402.
NOT_FOUND404An unknown job_id, or a slug in the /guest body that does not exist. Check for a typo in slug; tripod and tripod-desk are not the same app.
RATE_LIMITED429Too many requests. Back off and retry with the same Idempotency-Key; a tight retry loop with fresh keys is how a batch bills four times for one report.
INTERNAL500A server-side failure. Retry with the SAME Idempotency-Key so a half-finished run is not billed twice. If it repeats on one report and not on others, shorten report — a very long paste with an enormous metrics table is the usual trigger.

The two failure modes with no error code are worth more attention than the seven above. One is the wrapped input key. The other is a reply that looks complete and quietly dropped a blocking fact you handed it in prescan_facts — which is exactly what the reconciliation contract exists to make checkable.

The task field, before anything else

This app is four reviews behind one endpoint. task chooses which one you get, it is always present in a well-formed request, and it changes the shape of body in the reply. Everything else in the input — the report, the metrics table, the stage, the audience, the context, the prescan facts — is identical across all four. Send the same input four times with four different task values and you get four documents about one model.

The four lanes are a pipeline, and they are worth running in this order, because each one can invalidate the next. A cohort whose index time is undefined makes every AUC in validate unreadable; a validation with no calibration makes an attrib claim about "risk drivers" unsupportable; and report is worth writing only once the first three have stopped changing.

#taskthe question it answerswhat body carries
1cohortIs the prediction problem well posed at all? Who is in the cohort, when the clock starts, what the outcome actually is, and whether anything in the predictor set could only be known after the index time.data_source_review[], index_definition, outcome_definition, leakage_review[], sample_size, must_fix[]
2validateDoes the validation support the claim? The split ladder, discrimination, calibration, the classification measures at the stated threshold, and whether clinical utility was assessed at all.design_review[], metric_review[], calibration, discrimination, utility, sample_size_for_validation, must_fix[]
3attribDo the explanations mean what the report says they mean? The explainer against the model family, the reference distribution it was computed against, and whether any attribution has been stated as a cause.method_review, claim_review[], stability, subgroup_attribution[], safe_wording[], must_fix[]
4reportWhat has to be written down before this is submittable? The item-by-item TRIPOD+AI checklist, an abstract draft, the limitations paragraph and an intended-use statement.checklist[], abstract_draft, limitations[], intended_use_statement, open_items[], must_fix[]

The lanes are not interchangeable and they are not additive. A reply never blends two lanes' body shapes — a merged body fails to render — and the lane's own question decides where a fact lands. One example, the one that comes up most: a predictor selection run on the full dataset before the split is a leakage_review row in cohort, a design_review row at level internal in validate, a claim_review entry in attrib when the same ranking is then presented as the model's reasoning, and a checklist row plus a limitations sentence in report. One fact, four places, and it must be reported at the same severity in all four. Let the lane decide where it goes; never let it decide how bad it is.

If task is absent or unrecognised the run does not fail. The model picks the lane the input best fits — a report with a metrics table and no explainer section is validate, a report whose SHAP section is the substance is attrib, a bare methods section with no results is cohort — sets lane to whatever it chose, and says so in the first sentence of summary. What it never does is blend two contracts to cover itself. That fallback exists so a malformed request still returns something useful, not so you can skip the field: read lane from the reply before you read body, and send the lane.

The request body

Every field is top-level. task and report are required; everything else is optional and absent means absent — nothing is defaulted on your behalf, and nothing in the reply may assume a field you did not send.

fieldtypemeaning
taskstring, requiredThe lane: cohort, validate, attrib or report. See above.
reportstring, requiredThe model report itself: the methods and results of a clinical prediction model. Labelled lines (Primary outcome: in-hospital mortality within 24 h), prose, or both — the two are read by different parts of the same pass, so a labelled block followed by three paragraphs of discussion is the best input this app takes. Long reports are clipped in the middle, on line boundaries, with an in-band marker saying how many characters went, because the design is at the top and the results are at the bottom and neither end is safe to drop. When the marker is present the reply says in summary that the middle was not read, and the checks that depended on it go to unassessable.
metricsstring, optionalA performance table, one row per split. TSV, CSV, semicolon-separated or a markdown pipe table — all four parse, and the header is matched by whole tokens, so AUC_lo, aucLo, auc-lo and AUC lo are one column. Recognised columns: split, n, events, prevalence, auc, auc_lo, auc_hi, brier, cal_slope, cal_intercept, oe, sens, spec, ppv, npv, threshold. Common synonyms are accepted for each — c-statistic for auc, CITL or calibration in the large for cal_intercept, recall for sens, precision for ppv, cutoff for threshold. Unknown columns are kept and reported rather than dropped silently.
stagestringWhere the model is: development, internal, external, update or predeploy. It sets the bar, not the findings. An absent external validation is a limitation at development and a blocking objection at predeploy, and the same missing calibration curve reads differently in an update of a published model than in a first report.
audiencestringWho reads the output: journal, regulator, internal_review, clinical_governance or self_check. It changes register and emphasis, never severity. A regulator run leans on intended use, the operating point and the population the claim is made over; a self_check is blunter and shorter; a journal run writes text you can paste.
contextstring, optionalAnything the report does not say — reviewer comments you are answering, which journal, the deadline, what the previous submission was rejected for, a constraint you cannot change. Often the most decisive field in the whole input, and it is not decorative: every claim in it comes back as one context_notes entry marked honoured, contradicted or unverifiable. A stated constraint that nothing in the report reflects is itself a finding.
prescan_factsobject, optionalWhat the browser's own free reader already computed: {flags, free_read_verdict, numbers, evidence, checklist_summary, unassessable}. flags is a contract, the rest is context the reply must not contradict. See below.

Two absences that are not the same thing, and the reply is held to the difference. No metrics at all means the numeric cross-checks were never run — the reply says "no metrics table was supplied", never "the metrics are empty". And in the report text, a field the author explicitly says there is none of (External validation: none) is a confirmed gap, which is worse than a field the report simply never reaches: the first is established, the second is merely unaddressed. Never flatten those two into "missing".

prescan_facts, and the reconciliation contract

In the browser, prescan_facts comes from TDScan, the free in-page reader that runs before anyone signs in. It resolves about forty TRIPOD+AI fields to one of three states — stated, declared_none or missing — derives outcome prevalence and events per candidate predictor, checks every row of the metrics table against itself (Bayes-rule consistency of PPV and NPV, interval ordering, Brier against the base rate, calibration slope and intercept), and names the leakage and causal-attribution patterns that are visible in the text itself. It costs nothing and it never calls a model.

An API caller does not have to reproduce any of that. Sending no prescan_facts at all is legitimate, and the review still works — the model reads report and metrics either way.

What makes it worth sending is the contract. Every uid you put in flags comes back exactly once in the response's reconciliation array — no more, no fewer, and no uid you did not send — each with a status:

statusmeans
confirmedThe reviewer agrees, at the same or a higher severity. A useful confirmed adds the consequence the flag itself does not state — not "yes, the calibration slope is 0.71" but "0.71 means predicted risks are too extreme, so the 0.15 threshold is not the operating point the authors think it is".
adjustedReal, but the severity or the reading changes — and the note says what changed it. Five events per candidate predictor is a high objection for a model fitted on all 214 candidates, and a medium one for a model whose predictor set was fixed in advance from published literature.
set_asideNot a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all.
not_applicableThe flag does not apply to this lane's question — an attribution flag in the cohort lane, a reporting-gap roll-up in validate.

That turns a fact your own tooling established into something the reply is held to. A uid that never appears is a failed run, not a passing one. Asserting both directions in your client is three lines and it catches the one failure this app cares most about: a fluent, well-written review that quietly dropped the blocking fact you handed it. The check is written out in the verification step.

uid is positional and stable within one request: TDScan sorts its flags by severity, then by check id, then by line, and numbers them F1, F2, … in that order. id is the stable name of the check itself — TD-LEAK-TESTSET, TD-NO-CALIBRATION, TD-EPV, TD-CAL-SLOPE — and it is what to match on across runs and across reports. Both travel; the reconciliation keys on flag_uid.

The rest of prescan_facts is arithmetic the reply must not contradict:

keyshapewhat it is for
flags[{uid, id, severity, area, label, detail, line, evidence}]The contract above. severity is one of blocking, high, medium, low, info; area is one of the twelve areas listed with the output contract.
free_read_verdict"ready" | "ready_with_notes" | "revise" | "not_supported"What the free reader concluded from its own flags alone. The model's verdict may differ, and when it is more favourable the summary has to say what justified the move.
numbers{n, events, predictors, epv, prevalence, prevalence_stated, prevalence_derived, prevalence_source, horizon, follow_up}The counts, already computed. epv is events divided by candidate predictors; prevalence_derived is events over n. A null here means not stated — never zero, because a prevalence of zero makes every calibration figure look catastrophic and every Brier score look perfect.
evidence{discrimination, calibration, classification, utility}, each "reported" | "declared_absent" | "absent"The four classes of evidence a prediction-model report has to carry, judged on whether a number exists anywhere — prose or table column — not on whether a heading exists.
checklist_summary{total, reported, partial, not_reported, items: [{no, item, status}], outstanding: [{no, item, status}]}Counts plus two arrays. items is every reporting item with its integer no, its name and the state the reader assigned it; outstanding is exactly the subset whose status is not reported. Both come from one pass in one order, so neither can contradict the other or the counts: items.length == total, and partial + not_reported == outstanding.length. The report lane needs items to re-grade an item the reader marked reported — without the number it cannot name the item without guessing.
unassessable[{item, why}]Checks the free reader could not make. These are not flags and not part of the reconciliation contract; the reply is expected to carry them forward into its own unassessable rather than pretend the check was made.

The output contract

data.output.output is a string holding one JSON object — no preamble, no code fence, no prose outside it. The web app still takes everything from the first { to the last } before parsing, and a caller should do the same: it costs one slice and it survives the small variations a model produces.

Ten keys, identical in all four lanes except body:

{
  "lane": "cohort | validate | attrib | report",
  "title": "short name for this review, naming the model",
  "verdict": "ready | ready_with_notes | revise | not_supported",
  "headline": "one sentence naming the single fact that decides the verdict",
  "summary": "3-6 sentences a reviewer can act on. No restating of the JSON.",
  "body": { },
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking | high | medium | low | info",
      "area": "cohort | outcome | predictors | leakage | validation | discrimination | calibration | utility | attribution | fairness | reporting | deployment",
      "title": "one line",
      "detail": "what is wrong, what it does to the claim, and what makes it this severity and not another",
      "evidence": "the exact sentence, label line or table cell this rests on",
      "line": 11,
      "fix": "the concrete change, with the actual number or analysis named"
    }
  ],
  "reconciliation": [
    { "flag_uid": "F1", "status": "confirmed | adjusted | set_aside | not_applicable", "note": "why" }
  ],
  "context_notes": [
    { "claim": "what the author said", "status": "honoured | contradicted | unverifiable", "note": "what the report actually supports" }
  ],
  "unassessable": [
    { "item": "the check that could not be made", "why": "what was missing from the input" }
  ]
}
keytypemeaning
laneenumThe lane that actually ran. Normally it echoes task; when task was absent or unrecognised it is the lane that was chosen, and summary says so in its first sentence. Read this, not your own request, before you read body — a lane you did not ask for is also what a wrapped input key looks like from the outside.
titlestringShort name for the review, naming the model where the report names it.
verdictenumOne of four values, below. The single field a submission gate should branch on.
headlinestringOne sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it.
summarystringThree to six sentences, and not a restatement of the JSON. It is also where the exceptions are announced: a clipped report, an absent task, a verdict that moved away from free_read_verdict, an EPV that is a lower bound because the predictor count was counted from a list rather than declared.
bodyobjectThe lane's own document. Four shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below.
findingsobject[]{id, severity, area, title, detail, evidence, line, fix}. Ids are F-001, F-002, … in the order reported — note that these are the reply's ids and they are not the prescan's F1, F2 uids. May be empty, and an empty array is a real answer; no placeholder finding is ever emitted to fill it. line is copied from prescan_facts or from the visible report, never estimated, and null is correct when unknown.
reconciliationobject[]{flag_uid, status, note}. One entry per uid you sent in prescan_facts.flags, exactly once, no more and no fewer. Empty when you sent no flags. This is the contract worth asserting.
context_notesobject[]{claim, status, note}, one entry per claim in context. Send an empty context and this is empty; send three claims and expect three entries. An empty context_notes against a paragraph of context means the most decisive field in the input was not read.
unassessableobject[]{item, why} — the checks that genuinely could not be made from what was sent. An honest entry here is preferred to a confident guess, and the prescan's own unassessable items are carried forward into it rather than dropped.

The enums

These strings are shared verbatim with the browser's own free reader, so the two never disagree about what a clean result is called. The renderer keys on them: an unrecognised value renders as an error rather than being coerced to something plausible, so treat them as closed sets.

verdictwhen
readyNothing above info is left. The lane's question is answered and the report supports what it claims.
ready_with_notesThe worst finding is medium or low. Submittable; read the notes first.
reviseThe worst finding is high. Something needs new analysis or new text before this is reportable — not a wording change.
not_supportedAt least one finding is blocking: a claim in the report is not supported by what the report itself reports. body.must_fix is then non-empty.

The verdict follows the findings, mechanically, and never contradicts them. That makes two cheap assertions available to any client: a not_supported verdict with no blocking finding is a broken reply, and so is a ready verdict with a finding above info. Both are worth failing on rather than rendering.

severitymeaning
blockingA stated claim is not supported by what is reported — an undefined index time, an outcome with no definition, a causal statement made from an attribution, an external-validation claim with no external numbers.
highThe result may stand and cannot currently be trusted, or a reviewer will demand new analysis: no calibration behind a reported AUC, five events per candidate predictor, a prediction horizon longer than the follow-up.
mediumReal, bounded or mitigated — worth fixing before submission rather than before the next model.
lowWorth naming, not worth holding the paper for.
infoContext a reviewer should have. info alone still permits ready.

Severity depends on the mitigating facts, and detail names the mitigation whenever the grade moved because of it. A missing external validation at stage: "development" is a limitation; the same absence at stage: "predeploy" is blocking. A predictor selected on the full dataset is blocking when the reported performance comes from the same data and high when an untouched external set exists. If you diff two reports and a severity moved, the reason is in detail.

areacovers
cohortData source, design, setting, enrolment window, eligibility, exclusions, missing data, follow-up.
outcomeThe outcome definition, how it was ascertained, the prediction horizon, censoring, competing risks, prevalence.
predictorsWhat went into the model, how many candidates there were, how they were selected, events per predictor.
leakageAnything knowable only after the index time, and any step that saw the test set — imputation, scaling, selection, tuning.
validationThe split ladder itself: apparent, internal, external; resampling, tuning, optimism.
discriminationAUC, c-statistic, concordance, and their intervals.
calibrationSlope, calibration-in-the-large, observed-to-expected, Brier, recalibration.
utilityThe threshold and where it came from, sensitivity, specificity, PPV, NPV, net benefit, decision curves.
attributionThe explainer, the model family it is valid for, the background distribution, the stability of the ranking, and what the ranking is said to mean.
fairnessSubgroups, sensitive attributes, thin strata, subgroup performance that contradicts the headline.
reportingTRIPOD+AI items, code and data availability, funding, registration, ethics, limitations.
deploymentIntended use, the population the claim is made over, the operating point, monitoring, what happens when the model is wrong.

Twelve areas, and no synonyms: performance, statistics, explainability, bias and ethics are not values. If you bucket findings for a dashboard, bucket on these twelve.

body for task: "cohort"

Whether the prediction problem is well posed. data_source_review has one row per aspect the report actually addresses — provenance, setting, design, enrolment window, eligibility, exclusions, missing data — and a row's status is unassessable rather than ok when the report is silent, because "nothing wrong was found" and "nothing was said" are different answers. index_definition is the one that most often decides the lane: with no index time there is no prediction problem, only a classification of a finished episode, and its adequacy is then absent and the verdict not_supported. leakage_review covers only predictors the report names, and verdict per row is the reviewer's call on whether the value could have been known at the index time. sample_size.epv is events per candidate predictor — candidates, not survivors of selection, because the selection is what spent the events.

"body": {
  "data_source_review": [
    { "aspect": "Data provenance", "finding": "retrospective EHR extract from two tertiary ICUs, 2019-2023",
      "status": "ok | attention | wrong | unassessable", "note": "" }
  ],
  "index_definition": {
    "stated": true,
    "quote": "the exact sentence from the report, or \"\" when there is none",
    "adequacy": "clear | ambiguous | absent",
    "note": "what a second team would have to guess to reproduce the cohort"
  },
  "outcome_definition": {
    "stated": true,
    "ascertainment": "how the outcome was established, and by whom",
    "horizon": "24 hours from the index time",
    "adequacy": "clear | ambiguous | absent",
    "note": ""
  },
  "leakage_review": [
    { "predictor": "discharge disposition",
      "why_suspect": "recorded at the end of the episode being predicted",
      "severity": "blocking | high | medium | low | info",
      "verdict": "leak | likely_leak | defensible | not_a_leak | unassessable",
      "note": "" }
  ],
  "sample_size": {
    "n": 18412, "events": 1104, "candidate_predictors": 214, "epv": 5.16,
    "adequacy": "adequate | marginal | inadequate | unassessable",
    "note": "what the number means for the model that was actually fitted"
  },
  "must_fix": ["the short list that has to change before this cohort supports a prediction claim"]
}

body for task: "validate"

Whether the validation supports the claim. design_review has one row per level the report actually reaches — apparent, internal, external — and a level the report never attempted is a row with status missing, not an absent row: the ladder is the point. metric_review has one row per number worth checking, with the split it came from, so a figure that appears twice at two values is visible. calibration is the section that most often decides this lane: an assessed of false caps the verdict at revise however good the AUC is, because a model can rank perfectly and be wrong about every absolute risk it reports. utility.net_benefit is null unless the report gives one — a decision curve is not inferred from sensitivity and specificity.

"body": {
  "design_review": [
    { "level": "apparent | internal | external",
      "method": "random 70/30 split, single draw",
      "status": "ok | attention | wrong | missing | unassessable",
      "note": "what this level can and cannot establish as performed" }
  ],
  "metric_review": [
    { "metric": "AUC", "value": "0.91", "interval": "0.90-0.92", "split": "internal",
      "status": "ok | attention | wrong | unassessable", "note": "" }
  ],
  "calibration": {
    "assessed": true, "slope": 0.71, "intercept": 0.35, "oe_ratio": null,
    "status": "ok | attention | wrong | unassessable",
    "note": "what the slope does to the stated operating point"
  },
  "discrimination": {
    "value": "0.91", "interval": "0.90-0.92",
    "status": "ok | attention | wrong | unassessable",
    "note": "and whether the interval is compatible with the events available"
  },
  "utility": {
    "assessed": false, "threshold": "0.15", "net_benefit": null,
    "status": "ok | attention | wrong | unassessable",
    "note": "where the threshold came from, if the report says"
  },
  "sample_size_for_validation": {
    "events": 331, "adequate": false,
    "note": "what this many events supports, and what it does not"
  },
  "must_fix": ["the analyses that have to exist before this performance claim is reportable"]
}

body for task: "attrib"

Whether the explanations mean what the report says they mean. method_review.compatible is the first thing to read: KernelSHAP on a tree ensemble is defensible but expensive and approximate, TreeSHAP on a neural network is not valid at all, and a permutation importance presented as a per-patient explanation is a different object entirely. claim_review classifies each explanation claim the report makes as associational, causal or actionable — and a causal or actionable claim from an attribution alone is blocking, because "lactate drives deterioration" and "lowering lactate reduces deterioration" are not statements a SHAP value can support. safe_wording is the useful output of this lane in practice: the sentence you sent, and a sentence that says only what the method supports.

"body": {
  "method_review": {
    "explainer": "TreeSHAP",
    "model_family": "gradient-boosted trees",
    "compatible": "yes | approximate | no | unassessable",
    "background": "100 rows, drawn from the development set - how, the report does not say",
    "status": "ok | attention | wrong | unassessable",
    "note": "what the reference distribution makes the attributions relative to"
  },
  "claim_review": [
    { "claim": "the sentence from the report, quoted",
      "claim_type": "associational | causal | actionable",
      "status": "supported | overstated | unsupported | unassessable",
      "note": "what the method can support instead" }
  ],
  "stability": {
    "assessed": false,
    "note": "whether the ranking was shown to be stable across seeds, folds or background draws"
  },
  "subgroup_attribution": [
    { "subgroup": "patients over 75", "finding": "the top three features differ from the overall ranking",
      "note": "" }
  ],
  "safe_wording": [
    { "original": "Lactate is the strongest driver of deterioration.",
      "rewritten": "Lactate had the largest mean absolute SHAP contribution in this cohort, relative to the background set described above. This is an association within the model, not a causal effect." }
  ],
  "must_fix": ["the claims that have to be rewritten or dropped, and the analyses that would earn them back"]
}

body for task: "report"

The submission pack. checklist is item-by-item, and status is one of reported, partial, not_reported or not_applicable — with not_applicable reserved for items that genuinely do not apply to this design, never used to clear a gap. where points at the place in the report that answers the item, so a reviewer can check the claim, and what_to_add is a sentence you can paste rather than an instruction to write one. abstract_draft is bound by the same rules as the rest of the reply: it may not contain a number the report does not report, and it names the absent evidence rather than writing around it. intended_use_statement is the field most reports are missing entirely, and it is the one a regulator reads first: population, index time, horizon, operating point, and what the model is not for.

"body": {
  "checklist": [
    { "no": 12, "item": "Calibration: method and results",
      "status": "reported | partial | not_reported | not_applicable",
      "where": "Results, paragraph 2 - or \"\" when nothing answers it",
      "what_to_add": "the sentence or analysis that would close the item" }
  ],
  "abstract_draft": "a structured abstract that states only what the report supports, including the absences",
  "limitations": [
    "one limitation per entry, each naming the consequence rather than hedging"
  ],
  "intended_use_statement": "population, index time, horizon, operating point, and what this model must not be used for",
  "open_items": ["what the authors still have to decide, as questions"],
  "must_fix": ["the reporting items that block submission to this audience"]
}

A worked validate reply

Abbreviated but structurally complete — this is the shape of the reply to the validate request in the lane examples, the one whose calibration slope contradicts the report's own "calibration: not assessed":

{
  "lane": "validate",
  "title": "ICU-DETERIORATE v2 - internal validation of a 24-hour deterioration model",
  "verdict": "not_supported",
  "headline": "The reported AUC of 0.91 comes from a split whose predictor selection saw all 18,412 records, and the only calibration figure in the input says predicted risks are too extreme.",
  "summary": "The performance claim as written is not supported by this validation. Predictor selection ran on the full dataset before the 70/30 split, so the internal AUC of 0.91 is an apparent figure with a random holdout attached, not an internal validation; the drop from 0.94 to 0.91 across the split is smaller than that leakage would predict, which is itself evidence the two sets are not independent. The metrics table carries a calibration slope of 0.71 and an intercept of 0.35 on the internal split, while the report text says calibration was not assessed - one of those two is wrong, and if the table is right then the 0.15 threshold is not the operating point the report describes. With 331 events in the internal split and no external validation, the honest claim is a development result with a preliminary internal estimate. Fix the selection, re-report both splits, and add a calibration plot before the discrimination figure is quoted anywhere.",
  "findings": [
    {
      "id": "F-001",
      "severity": "blocking",
      "area": "leakage",
      "title": "Predictor selection ran before the split, so the internal AUC is not out-of-sample",
      "detail": "The report says 214 candidates were SHAP-ranked and the top 40 retained, with no statement that the ranking was computed inside the training fold. A selection that saw the holdout makes the 0.91 an optimistic estimate of the same quantity the 0.94 estimates, not an independent one. The 0.03 gap is the tell: honest internal validation of a 40-predictor boosted model on 5.2 events per candidate normally loses more than that.",
      "evidence": "Predictor selection: SHAP-ranked, top 40 retained",
      "line": 11,
      "fix": "Re-run selection inside each resample - nested cross-validation, or selection on the training partition only - and re-report both figures. If the ranking must stay as published, report the 0.91 as apparent performance."
    },
    {
      "id": "F-002",
      "severity": "high",
      "area": "calibration",
      "title": "The report says calibration was not assessed; the metrics table reports a slope of 0.71",
      "detail": "Those two statements cannot both be true. Taking the table at face value, a slope of 0.71 means predicted risks are systematically too extreme, and an intercept of 0.35 means the model over-predicts in the large. At the stated 0.15 threshold that is not a small correction: the patients crossing it are not the 15-per-cent-risk patients the report describes.",
      "evidence": "Calibration: not assessed / internal row cal_slope 0.71, cal_intercept 0.35",
      "line": 16,
      "fix": "Say which is right. If the slope is real, report it in the text with a calibration plot, and either recalibrate before quoting the threshold or state the operating point in observed-risk terms."
    },
    {
      "id": "F-003",
      "severity": "high",
      "area": "validation",
      "title": "A single random split is the whole validation, and there is no external set",
      "detail": "One 70/30 draw gives one estimate with no variance around it, and the report states external validation as none - an author-confirmed absence rather than an omission. At stage \"internal\" that caps the claim at this population and these two sites.",
      "evidence": "Split strategy: random 70/30 split / External validation: none",
      "line": 13,
      "fix": "Add repeated cross-validation or bootstrap optimism correction for the internal estimate, and state the intended population as the two study ICUs until an external set exists."
    }
  ],
  "reconciliation": [
    { "flag_uid": "F1", "status": "confirmed",
      "note": "Confirmed at blocking. The free reader is right that selection precedes the split; the consequence it does not state is that the small 0.94-to-0.91 gap is itself evidence of dependence between the sets." },
    { "flag_uid": "F2", "status": "adjusted",
      "note": "Adjusted from \"no calibration measure\" to a contradiction: the text says not assessed, the table reports a slope and an intercept. Same severity, different finding, and the fix is different - one of the two sources has to be corrected before either can be quoted." },
    { "flag_uid": "F3", "status": "confirmed",
      "note": "Confirmed at high. 1,104 events over 214 candidates is 5.2, and because selection consumed the full dataset the effective number is lower still, not higher." },
    { "flag_uid": "F4", "status": "confirmed",
      "note": "Confirmed at high rather than medium, because the single split is the only validation in the report and there is no external set behind it." }
  ],
  "context_notes": [
    { "claim": "Reviewer 2 asked only for a calibration plot.", "status": "contradicted",
      "note": "A plot alone cannot answer this: the selection leak changes the estimate the plot would describe. Fix the split first, or the second submission gets the same objection with a figure attached." },
    { "claim": "The submission deadline is in three weeks.", "status": "unverifiable",
      "note": "Nothing in the report bears on this. Re-running selection inside the folds is the cheaper of the two fixes and is compatible with that deadline." }
  ],
  "unassessable": [
    { "item": "Whether the 0.15 threshold was chosen on the training data or the holdout",
      "why": "The report gives the threshold but not its provenance." },
    { "item": "Net benefit at the stated threshold",
      "why": "No decision-curve analysis or net-benefit figure appears in the report or the metrics table." }
  ],
  "body": {
    "design_review": [
      { "level": "apparent", "method": "70% development partition", "status": "ok",
        "note": "AUC 0.94, Brier 0.041. Reported and internally consistent." },
      { "level": "internal", "method": "single random 30% holdout", "status": "wrong",
        "note": "Not independent of the development set: selection saw all records. One draw, so no variance estimate either." },
      { "level": "external", "method": "none", "status": "missing",
        "note": "Stated as none by the authors, which is a confirmed absence rather than an omission." }
    ],
    "metric_review": [
      { "metric": "AUC", "value": "0.94", "interval": "0.93-0.95", "split": "development",
        "status": "attention", "note": "Apparent performance; expected to be optimistic." },
      { "metric": "AUC", "value": "0.91", "interval": "0.90-0.92", "split": "internal",
        "status": "wrong", "note": "Interval is narrow for 331 events, and the estimate is not out-of-sample." },
      { "metric": "Brier", "value": "0.048", "interval": null, "split": "internal",
        "status": "ok", "note": "Better than the 0.060 base rate, so the model carries real information." },
      { "metric": "PPV", "value": "0.26", "interval": null, "split": "internal",
        "status": "ok", "note": "Consistent with sensitivity 0.86, specificity 0.84 and prevalence 0.060 under Bayes' rule." }
    ],
    "calibration": {
      "assessed": true, "slope": 0.71, "intercept": 0.35, "oe_ratio": null,
      "status": "wrong",
      "note": "Only in the table, and contradicted by the text. A slope of 0.71 pushes predicted risks away from the base rate, so the 0.15 threshold selects a different group than the report implies."
    },
    "discrimination": {
      "value": "0.91", "interval": "0.90-0.92", "status": "wrong",
      "note": "The point estimate is inflated by the selection leak, and a +/-0.01 interval on 331 events is tighter than that many events supports."
    },
    "utility": {
      "assessed": false, "threshold": "0.15", "net_benefit": null, "status": "wrong",
      "note": "A threshold is stated with no justification and no decision curve, so the clinical consequence of operating there is not reported."
    },
    "sample_size_for_validation": {
      "events": 331, "adequate": false,
      "note": "331 events supports a discrimination estimate with a wider interval than the one reported, and is thin for a calibration slope on a 40-predictor model."
    },
    "must_fix": [
      "Move predictor selection inside the resampling and re-report both AUCs.",
      "Resolve the calibration contradiction between the text and the table, with a plot.",
      "State the threshold's provenance, or stop quoting an operating point.",
      "Describe the claim as internal to the two study ICUs until an external set exists."
    ]
  }
}

Check the reply before you trust it

The browser does not render a reply verbatim and neither should a caller. Six assertions cover everything this app can get wrong in a way that still looks plausible, and all six are cheap:

One more, and it is the cheapest of all: body.must_fix is non-empty exactly when the verdict is not_supported. The assertion code for all of these is in step 4.

0. A tiny client

One helper that adds the two headers, unwraps data and raises on ok: false. Two headers is the whole story: Content-Type and Authorization. If you find yourself reaching for X-App-Slug, the token already carries the app. Every later step on this page uses this helper.

# 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"
TOKEN="$SKILLSAFE_TOKEN"   # from https://tripod-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
}

# Unwrap the envelope: print data, or exit non-zero with the API error code.
data() {
  python3 -c '
import sys, json
env = json.load(sys.stdin)
if not env.get("ok"):
    err = env.get("error") or {}
    raise SystemExit("%s: %s" % (err.get("code"), err.get("message")))
json.dump(env["data"], sys.stdout)
'
}

call me | data
# {"subject_type": "user", "subject_id": "usr_...", "credits": 51234}
#
# No X-App-Slug header. The slug was only ever needed by POST /guest.

1. Get a token

For a human, the shortest path is the token page: it shows the token this browser already holds, with a copy button and a ready-made shell export, and a sign-in button for a personal token. Nothing on it needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

For a program, POST /guest mints one. The body is {"slug": "tripod-desk"} — this is the one and only place the slug appears in this API — and the call answers 201 Created:

HTTP/1.1 201 Created

{ "ok": true, "data": {
    "token": "sk_guest_...",
    "guest_id": "gst_...",
    "expires_at": "2026-08-27T09:14:02Z"
} }

Three things follow from that shape. expires_at is real, so a long-lived worker re-mints rather than caching forever; a 401 on a previously good token usually means it lapsed. guest_id is worth keeping — it is what lets a later sign-in migrate the guest wallet, and it is the only handle you have on an anonymous session. And a guest token is enough for /me and /estimate but not for a metered run: a review is metered, so /run and /run-stream want a personal token from signing in. A guest attempting a run gets 403 FORBIDDEN, not a 402.

# The token page is the shortest path for a person. It shows the token this
# browser holds and hands you a ready-made shell export:
#
#   https://tripod-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. The slug goes in the BODY;
# there is no X-App-Slug header anywhere in this API.
curl -sS -i -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"tripod-desk"}'

# HTTP/1.1 201 Created
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_...",
#                    "expires_at":"2026-08-27T09:14:02Z"}}

TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"tripod-desk"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')

# A guest token covers /me and /estimate. A metered review needs a personal
# token from signing in on the token page.

2. Check the session and the balance

GET /me returns three fields and nothing else:

{ "ok": true, "data": {
    "subject_type": "guest",
    "subject_id": "gst_...",
    "credits": 1200
} }

Read that literally, because the fields people expect are not there. There is no user_id — the identifier is subject_id whichever kind of subject it is, so a guest's subject_id is its guest_id and a signed-in person's is their user id. There is no is_guest flag, so a truthiness test on it is silently false for everybody, which reads as "this is a real user" for a guest token. Branch on subject_type, which is guest or user. There is no username, no email and no plan field either; if you need a display name, you need your own.

credits is the wallet balance. Compare it against min_credits from step 3 before you run, so a shortfall becomes your own clear message instead of a 402 in the middle of a batch of forty reports.

call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}

# Branch on subject_type. There is no is_guest field to test, and no user_id -
# the id is subject_id for both kinds of subject.
call me | python3 -c '
import sys, json
me = json.load(sys.stdin)["data"]
kind = me["subject_type"]                       # "guest" or "user"
print(kind, me["subject_id"], me["credits"], "credits")
if kind == "guest":
    print("guest token: /me and /estimate only, no metered run")
'

3. Price the run — free, but authenticated

POST /estimate creates no job and charges nothing. It is still an authenticated call, which is the ordering trap: it has to come after step 1. A caller who builds an input, prices it and only then goes looking for a token gets a 401 UNAUTHORIZED on the free call and reads it as a broken endpoint. Mint the token first, price second.

The body is the input object from above, flat. What comes back:

fieldfor this appmeaning
modelgpt-5.6-terraThe exact model the run will bind to.
model_aliasgpt-terraThe stable alias that binding came from — what to log, since the concrete model moves under it. Worth asserting: if this is not gpt-terra, you are not talking to this app.
markup_bps1000The app's markup in basis points; 1000 is ten per cent.
hold_creditsvariesWhat gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price.
min_creditsvariesThe balance you must clear for the run to start at all. Compare this against credits from /me.
sponsor_enabledvariesWhether the app is covering this run rather than your wallet.

The hold is a reservation. The charged_credits you see on the settled job is normally far lower, because a cohort review of a well-written methods section says so in a few hundred tokens while the cap allows for a report lane with a forty-row checklist and an abstract. Budget against hold_credits; report against charged_credits.

Estimates differ by lane, and they differ a lot here. report is by far the wordiest — the checklist alone is one row per TRIPOD+AI item — and cohort is the leanest. If you are running all four lanes over one report, price all four rather than multiplying the cohort figure by four. A long metrics table moves the estimate more than people expect, because every row is read and cross-checked.

# The report and the metrics table as shell variables, so the JSON stays readable.
REPORT=$(cat <<'EOF'
Model: ICU-DETERIORATE v2 (gradient-boosted trees)
Care setting: two tertiary ICUs, 2019-2023
Data source: retrospective EHR extract
Index time: first 6 hours after ICU admission
Prediction horizon: 24 hours
Primary outcome: in-hospital mortality or unplanned transfer to a higher level of care
Outcome ascertainment: discharge coding, adjudicated by two intensivists
Sample size: 18412
Events: 1104
Candidate predictors: 214
Predictor selection: SHAP-ranked, top 40 retained
Split strategy: random 70/30 split
External validation: none
Discrimination: AUC 0.91 (95% CI 0.90-0.92)
Calibration: not assessed
Threshold: 0.15
Attribution method: TreeSHAP with a 100-row background set
EOF
)

METRICS=$(printf '%s\n%s\n%s\n' \
  "split	n	events	prevalence	auc	auc_lo	auc_hi	brier	cal_slope	cal_intercept	sens	spec	ppv	npv	threshold" \
  "development	12888	773	0.060	0.94	0.93	0.95	0.041	1.00	0.00	0.88	0.87	0.31	0.99	0.15" \
  "internal	5524	331	0.060	0.91	0.90	0.92	0.048	0.71	0.35	0.86	0.84	0.26	0.99	0.15")

# Build the body with python3 so the newlines and tabs are escaped correctly.
# THE BODY IS THE INPUT OBJECT ITSELF - there is no "input" key.
INPUT=$(REPORT="$REPORT" METRICS="$METRICS" python3 -c '
import json, os
print(json.dumps({
    "task": "validate",
    "report": os.environ["REPORT"],
    "metrics": os.environ["METRICS"],
    "stage": "internal",
    "audience": "journal",
    "context": "Reviewer 2 asked only for a calibration plot. Submission deadline in three weeks.",
    "prescan_facts": {
        "flags": [
            {"uid": "F1", "id": "TD-LEAK-TESTSET", "severity": "blocking", "area": "leakage",
             "label": "Predictor selection ran before the split",
             "detail": "214 candidates were SHAP-ranked and the top 40 kept, with no statement that the ranking was computed inside the training fold.",
             "line": 11, "evidence": "Predictor selection: SHAP-ranked, top 40 retained"},
            {"uid": "F2", "id": "TD-NO-CALIBRATION", "severity": "high", "area": "calibration",
             "label": "Calibration is stated as not assessed",
             "detail": "No calibration slope, intercept or O:E appears in the text.",
             "line": 15, "evidence": "Calibration: not assessed"},
            {"uid": "F3", "id": "TD-EPV", "severity": "high", "area": "predictors",
             "label": "5.2 events per candidate predictor",
             "detail": "1104 events over 214 candidates.",
             "line": 10, "evidence": "Candidate predictors: 214"},
            {"uid": "F4", "id": "TD-SINGLE-SPLIT", "severity": "medium", "area": "validation",
             "label": "A single random split is the whole validation",
             "detail": "One 70/30 draw gives one estimate with no variance around it.",
             "line": 12, "evidence": "Split strategy: random 70/30 split"}
        ],
        "free_read_verdict": "not_supported",
        "numbers": {"n": 18412, "events": 1104, "predictors": 214, "epv": 5.16,
                    "prevalence": 0.06, "prevalence_source": "derived from events / sample size"},
        "evidence": {"discrimination": "reported", "calibration": "declared_absent",
                     "classification": "reported", "utility": "absent"},
        "checklist_summary": {"total": 47, "reported": 24, "partial": 0, "not_reported": 23,
                              "outstanding": [{"no": 12, "item": "Calibration: method and results",
                                               "status": "not_reported"}]},
        "unassessable": [{"item": "Whether the 100-row background set is representative",
                          "why": "the report does not say how it was drawn"}]
    }
}))')

call estimate "$INPUT" | data
# {"model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000,
#  "hold_credits": 2960, "min_credits": 340, "sponsor_enabled": false}
#
# estimate is FREE - no job, no charge - but it IS authenticated, so it comes
# after step 1. Assert the alias, then compare min_credits against /me.
call estimate "$INPUT" | python3 -c '
import sys, json
est = json.load(sys.stdin)["data"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
print("hold", est["hold_credits"], "min", est["min_credits"],
      "sponsored", est["sponsor_enabled"])
'

4. Run it, poll, and check the reply

POST /run returns {job_id}; poll GET /jobs/{job_id} until status is succeeded or failed. The review is a JSON string at data.output.output — one object, the envelope described in the output contract. The terminal job also carries charged_credits, the real price, and truncated.

The body is the input object itself — again, and for the last time. This is the call where the wrapped-input mistake costs money: {"input": {…}} returns 200, takes the hold, runs, and bills you for a review of an empty object. The reply will be fluent and it will be about nothing. There is no error code and no warning; the only signal is that lane comes back as whatever the model guessed from an empty input, and every reconciliation entry you were promised is missing. Send it flat, and assert lane.

Always send an Idempotency-Key. Derive it from the input the way the web app does — a content hash plus the lane plus an attempt counter, tripod-desk:<hash>:<lane>:a<attempt>. A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a retry safe after a network blip on a report you have already paid to review. Bump the attempt suffix whenever the input actually changed — including when all that changed is task, because the lane is part of the body, and a key replayed against a different body is rejected rather than silently answered from the wrong job.

# Always send an Idempotency-Key derived from the input, with the lane in it.
# A retried request with the same key returns the SAME job instead of billing twice.
KEY="tripod-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):validate: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"])')

# Poll until the job reaches a terminal status.
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

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"validate\",\"verdict\":\"not_supported\", ...}"},
#   "charged_credits":734,"truncated":false}}

# The review is a JSON string inside the envelope, so unwrap it twice, then check
# the invariants before you believe a word of it.
printf '%s' "$OUT" | python3 -c '
import sys, json
job = json.load(sys.stdin)["data"]
rep = json.loads(job["output"]["output"])

assert rep["lane"] == "validate", "wrong lane: %s - did the body get wrapped?" % rep["lane"]
assert not job.get("truncated"), "truncated: this is a prefix, not a review"

sent = {"F1", "F2", "F3", "F4"}
back = [c["flag_uid"] for c in rep["reconciliation"]]
assert sorted(back) == sorted(sent), "reconciliation mismatch: %s" % back

worst = min((["blocking","high","medium","low","info"].index(f["severity"])
             for f in rep["findings"]), default=4)
expect = ["not_supported","revise","ready_with_notes","ready_with_notes","ready"][worst]
assert rep["verdict"] == expect, "verdict %s against worst finding" % rep["verdict"]

print(rep["lane"], rep["verdict"], "-", rep["headline"])
for f in rep["findings"]:
    print("  %-8s %-14s %s" % (f["severity"], f["area"], f["title"]))
print("charged", job.get("charged_credits"))
'

5. Or stream it

POST /run-stream is the same call over server-sent events, with Accept: text/event-stream added to the same headers and the same Idempotency-Key. The events are job ({job_id}, first), delta ({"text": "..."}, a chunk of the review JSON), done (status, charged_credits, truncated) and error on a failure. Two practical details: an idempotent replay of a key that already ran comes back as plain JSON rather than a stream, so check the response Content-Type before you start reading lines; and events are separated by a blank line, so split on \n\n rather than assuming one data: line per event.

For a progress display, do not parse the partial JSON — watch for key names arriving in the accumulating text. "findings" means the review is naming problems, "reconciliation" means it has reached your prescan flags, "body" means it is building the lane's own document, and "summary" means it is nearly done. Substring matching on the quoted key name is enough and it costs nothing. The report lane is the one where this matters: a forty-row checklist takes a while, and the accumulating text is the only honest progress signal there is.

# Server-sent events. Each `delta` carries a chunk of the review JSON; the final
# `done` event 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\":\"validate\",\"title\":\"ICU-DETERIORATE v2"}
# event: delta  {"text":"\",\"verdict\":\"not_supported\","}
# event: done   {"status":"succeeded","charged_credits":734,"truncated":false}
#
# An idempotent replay answers with plain JSON instead of a stream, so a robust
# client checks the content type first.

6. One worked request per lane

Four bodies, one per lane, each a complete request you can POST as-is. They are deliberately small: a real report is the whole methods and results section, and the only things that change between these four are task, stage, audience and how much supporting material is worth sending. The prescan_facts are trimmed here to keep the examples readable; in the browser they carry every flag TDScan raised.

cohort — is the prediction problem well posed?

The lane to run first, and the one worth gating on: if the index time is undefined or a predictor could only be known after it, nothing downstream is worth reading. This lane needs the least support — no metrics at all — because it is about the design, not the numbers.

{
  "task": "cohort",
  "report": "Model: ICU-DETERIORATE v2 (gradient-boosted trees)\nCare setting: two tertiary ICUs, 2019-2023\nData source: retrospective EHR extract\nIndex time: first 6 hours after ICU admission\nPrediction horizon: 24 hours\nPrimary outcome: in-hospital mortality or unplanned transfer to a higher level of care\nOutcome ascertainment: discharge coding, adjudicated by two intensivists\nEligibility: adults admitted to the ICU with at least 6 hours of monitoring\nExclusions: comfort-care-only admissions\nSample size: 18412\nEvents: 1104\nCandidate predictors: 214, including vital-sign trends, laboratory values, the number of nursing notes in the first 24 hours, and discharge disposition\nMissing data: median imputation before the split\n",
  "stage": "development",
  "audience": "internal_review",
  "context": "We are deciding whether to take this to a journal at all. The nursing-note count was included because it improved the AUC.",
  "prescan_facts": {
    "flags": [
      { "uid": "F1", "id": "TD-LEAK-CONTENT", "severity": "blocking", "area": "leakage",
        "label": "discharge disposition is knowable only after the episode",
        "detail": "A predictor recorded at the end of the admission cannot be available 6 hours after it starts.",
        "line": 12, "evidence": "Candidate predictors: 214, including ... and discharge disposition" },
      { "uid": "F2", "id": "TD-LEAK-PROC", "severity": "high", "area": "leakage",
        "label": "Imputation ran before the split",
        "detail": "Median imputation over the whole dataset leaks the holdout's distribution into training.",
        "line": 13, "evidence": "Missing data: median imputation before the split" },
      { "uid": "F3", "id": "TD-EPV", "severity": "high", "area": "predictors",
        "label": "5.2 events per candidate predictor",
        "detail": "1104 events over 214 candidates.", "line": 11,
        "evidence": "Candidate predictors: 214" }
    ],
    "free_read_verdict": "not_supported",
    "numbers": { "n": 18412, "events": 1104, "predictors": 214, "epv": 5.16,
                 "prevalence": 0.06, "prevalence_source": "derived from events / sample size" },
    "evidence": { "discrimination": "absent", "calibration": "absent",
                  "classification": "absent", "utility": "absent" }
  }
}

validate — does the validation support the claim?

The lane that most rewards a metrics table: send one and every row is cross-checked against itself — PPV and NPV against sensitivity, specificity and prevalence; the interval against its own point estimate; Brier against the base rate; the calibration slope and intercept against 1 and 0. Without a table the lane still runs, and the numeric checks land in unassessable instead of in metric_review. This is the request whose reply is written out in full above.

{
  "task": "validate",
  "report": "Model: ICU-DETERIORATE v2 (gradient-boosted trees)\nIndex time: first 6 hours after ICU admission\nPrediction horizon: 24 hours\nPrimary outcome: in-hospital mortality or unplanned transfer\nSample size: 18412\nEvents: 1104\nCandidate predictors: 214\nPredictor selection: SHAP-ranked, top 40 retained\nSplit strategy: random 70/30 split\nExternal validation: none\nDiscrimination: AUC 0.91 (95% CI 0.90-0.92)\nCalibration: not assessed\nThreshold: 0.15\n",
  "metrics": "split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\tcal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold\ndevelopment\t12888\t773\t0.060\t0.94\t0.93\t0.95\t0.041\t1.00\t0.00\t0.88\t0.87\t0.31\t0.99\t0.15\ninternal\t5524\t331\t0.060\t0.91\t0.90\t0.92\t0.048\t0.71\t0.35\t0.86\t0.84\t0.26\t0.99\t0.15\n",
  "stage": "internal",
  "audience": "journal",
  "context": "Reviewer 2 asked only for a calibration plot. Submission deadline in three weeks.",
  "prescan_facts": {
    "flags": [
      { "uid": "F1", "id": "TD-LEAK-TESTSET", "severity": "blocking", "area": "leakage",
        "label": "Predictor selection ran before the split",
        "detail": "214 candidates were SHAP-ranked and the top 40 kept, with no statement that the ranking was computed inside the training fold.",
        "line": 8, "evidence": "Predictor selection: SHAP-ranked, top 40 retained" },
      { "uid": "F2", "id": "TD-NO-CALIBRATION", "severity": "high", "area": "calibration",
        "label": "Calibration is stated as not assessed",
        "detail": "No calibration slope, intercept or O:E appears in the text.",
        "line": 12, "evidence": "Calibration: not assessed" },
      { "uid": "F3", "id": "TD-EPV", "severity": "high", "area": "predictors",
        "label": "5.2 events per candidate predictor",
        "detail": "1104 events over 214 candidates.", "line": 7,
        "evidence": "Candidate predictors: 214" },
      { "uid": "F4", "id": "TD-CAL-SLOPE", "severity": "high", "area": "calibration",
        "label": "Calibration slope 0.71 on the internal split",
        "detail": "Predicted risks are too extreme; the stated 0.15 threshold does not select the group the report describes.",
        "line": null, "evidence": "internal row, cal_slope 0.71" }
    ],
    "free_read_verdict": "not_supported",
    "numbers": { "n": 18412, "events": 1104, "predictors": 214, "epv": 5.16, "prevalence": 0.06 },
    "evidence": { "discrimination": "reported", "calibration": "declared_absent",
                  "classification": "reported", "utility": "absent" }
  }
}

attrib — do the explanations mean what the report says?

Send the explanation section, not just the metrics: this lane reads sentences. The most useful thing it returns is safe_wording — the claim you wrote next to a claim the method can support — and the thing it is strictest about is a causal or actionable statement made from an attribution. A metrics table adds little here, and the background-set description adds a great deal.

{
  "task": "attrib",
  "report": "Model: ICU-DETERIORATE v2 (gradient-boosted trees)\nAttribution method: TreeSHAP with a 100-row background set drawn from the development data\nFeature importance: lactate, respiratory rate trend, and the number of nursing notes are the top three contributors\nInterpretation: lactate is the strongest driver of deterioration in this cohort, so early lactate control should reduce transfers\nSubgroup note: in patients over 75 the nursing-note count becomes the top contributor\nStability: not assessed\n",
  "stage": "internal",
  "audience": "clinical_governance",
  "context": "The governance committee wants to know whether clinicians can act on the top features. Nothing about the model can change before the next release.",
  "prescan_facts": {
    "flags": [
      { "uid": "F1", "id": "TD-ATTRIB-CAUSAL", "severity": "high", "area": "attribution",
        "label": "A causal claim is made from an attribution",
        "detail": "\"lactate is the strongest driver\" and \"early lactate control should reduce transfers\" are causal and actionable claims; SHAP values are neither.",
        "line": 4, "evidence": "lactate is the strongest driver of deterioration in this cohort" },
      { "uid": "F2", "id": "TD-ATTRIB-BACKGROUND-SIZE", "severity": "low", "area": "attribution",
        "label": "100-row background set",
        "detail": "A small reference distribution makes the attributions noisy and makes them relative to whatever those 100 rows are.",
        "line": 2, "evidence": "TreeSHAP with a 100-row background set" },
      { "uid": "F3", "id": "TD-SENSITIVE-PREDICTOR", "severity": "medium", "area": "fairness",
        "label": "The nursing-note count is a proxy for attention, not physiology",
        "detail": "A care-process variable in the top three, and top for the over-75s, means the model may be learning who gets watched.",
        "line": 5, "evidence": "in patients over 75 the nursing-note count becomes the top contributor" }
    ],
    "free_read_verdict": "revise",
    "evidence": { "discrimination": "absent", "calibration": "absent",
                  "classification": "absent", "utility": "absent" }
  }
}

report — the TRIPOD+AI pack

Run this last, and send everything you have: this lane's checklist is only as good as the input it is auditing, and a checklist_summary in prescan_facts keeps its counts honest by handing it the arithmetic rather than letting it recount. It is also the most expensive lane, so price it separately rather than reusing the cohort estimate.

{
  "task": "report",
  "report": "Model: ICU-DETERIORATE v2 (gradient-boosted trees)\nCare setting: two tertiary ICUs, 2019-2023\nData source: retrospective EHR extract\nIndex time: first 6 hours after ICU admission\nPrediction horizon: 24 hours\nPrimary outcome: in-hospital mortality or unplanned transfer\nSample size: 18412\nEvents: 1104\nCandidate predictors: 214\nSplit strategy: random 70/30 split\nExternal validation: none\nDiscrimination: AUC 0.91 (95% CI 0.90-0.92)\nCalibration: not assessed\nThreshold: 0.15\nCode availability: on request\nFunding: institutional\nEthics: IRB approved, waiver of consent\nIntended use: not stated\n",
  "metrics": "split\tn\tevents\tauc\tcal_slope\tcal_intercept\tthreshold\ndevelopment\t12888\t773\t0.94\t1.00\t0.00\t0.15\ninternal\t5524\t331\t0.91\t0.71\t0.35\t0.15\n",
  "stage": "predeploy",
  "audience": "regulator",
  "context": "This goes to the hospital's device committee, not a journal. They will ask what the model is for and who it must not be used on.",
  "prescan_facts": {
    "flags": [
      { "uid": "F1", "id": "TD-INTENDED-USE", "severity": "high", "area": "deployment",
        "label": "Not reported: intended use",
        "detail": "The report never answers it. A reviewer cannot assess what is not there.",
        "line": 18, "evidence": "Intended use: not stated" },
      { "uid": "F2", "id": "TD-EXTERNAL-VALIDATION", "severity": "blocking", "area": "validation",
        "label": "Confirmed absent: external validation",
        "detail": "The report states there is none. At predeploy that is the author's own confirmation of a blocking gap, not an omission.",
        "line": 11, "evidence": "External validation: none" },
      { "uid": "F3", "id": "TD-REPORTING-GAPS-MEDIUM", "severity": "medium", "area": "reporting",
        "label": "7 further reporting items are unanswered",
        "detail": "Enrolment window; missing data; hyperparameters; recalibration; competing risks; subgroups; data availability.",
        "line": null, "evidence": "" }
    ],
    "free_read_verdict": "not_supported",
    "checklist_summary": {
      "total": 47, "reported": 24, "partial": 0, "not_reported": 23,
      "outstanding": [
        { "no": 12, "item": "Calibration: method and results", "status": "not_reported" },
        { "no": 17, "item": "Intended use and target population", "status": "not_reported" },
        { "no": 20, "item": "External validation", "status": "not_reported" }
      ]
    },
    "unassessable": [
      { "item": "Whether the two ICUs differ enough to count as internal-external validation",
        "why": "the report does not break performance down by site" }
    ]
  }
}

Running all four lanes over one report

Because only task changes, the useful shape in code is one base object and a loop. Each lane is a separate metered run, so give each one its own Idempotency-Key — the lane is part of the body, so replaying one key across two lanes is rejected rather than answered.

# One report, four lanes, in pipeline order. $REPORT and $METRICS are from step 3.
for LANE in cohort validate attrib report; do
  BODY=$(LANE="$LANE" REPORT="$REPORT" METRICS="$METRICS" python3 -c '
import json, os
print(json.dumps({
    "task": os.environ["LANE"],
    "report": os.environ["REPORT"],
    "metrics": os.environ["METRICS"],
    "stage": "internal",
    "audience": "journal",
    "context": "Reviewer 2 asked only for a calibration plot.",
}))')

  # A separate key per lane: the lane is part of the body.
  K="tripod-desk:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):$LANE:a1"
  JOB=$(curl -sS -X POST "$BASE/run" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -H "Idempotency-Key: $K" -d "$BODY" \
    | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

  while :; do
    OUT=$(call "jobs/$JOB")
    S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
    case "$S" in succeeded|failed) break;; esac
    sleep 2
  done

  printf '%s' "$OUT" | python3 -c '
import sys, json
job = json.load(sys.stdin)["data"]
rep = json.loads(job["output"]["output"])
print("%-9s %-16s %s" % (rep["lane"], rep["verdict"], rep["headline"][:70]))
'
done

Truncation, retries 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 on the finished job and on the streaming done event. What you hold then is a prefix — the findings may be complete while reconciliation, body and summary are missing or cut mid-string. In this app that is worse than an error, because a prefix of a validate lane can read as a clean metric review with nothing after it, and a prefix of report is a checklist that simply stops before the items nobody answered.

Check the flag before you treat a review as complete, and treat a truncation as a retry rather than a repair. Send the same input with a concrete retry_note and the attempt suffix on the Idempotency-Key incremented, so the new body is not a replay of the old key:

"retry_note": "The previous reply was truncated after findings[]. Return the same
findings, keep reconciliation complete for all four prescan uids, and hold the
checklist to the outstanding items only with shorter what_to_add fields."

The same route handles a reply that fails your own checks in the verification list: a missing reconciliation entry, a verdict its findings contradict, a body carrying another lane's keys. Name the defect in retry_note — it is obeyed exactly — and bump the attempt. Do not append closing braces to truncated JSON; that produces something that parses and is not what the model meant.

A last note on grounding, because it changes how you read a clean review. Every finding names the sentence, the label line or the table cell that produced it, and nothing is invented — not a number, not a citation, not a line number, not an interval. So an empty findings array with a full unassessable array is not a pass; it is a statement that the input did not contain enough to judge. Read the two together, and read unassessable before you tell anyone the model is ready. And read context_notes too: a contradicted entry there is the app telling you that something you asserted is not what the report supports.