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:
| call | auth | cost | what it does |
|---|---|---|---|
POST /guest | none | free | Mints a guest token for one app. Answers 201 with {token, guest_id, expires_at}. |
GET /me | token | free | Returns {subject_type, subject_id, credits} and nothing else. |
POST /estimate | token | free | Prices an input. Creates no job and charges nothing — but it is authenticated, so it has to come after the token. |
POST /run | token | metered | Starts a review. Returns {job_id}. |
GET /jobs/{job_id} | token | free | Polls one job. The terminal job carries output.output, charged_credits and truncated. |
POST /run-stream | token | metered | The 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
| code | status | what causes it here, and what to do |
|---|---|---|
VALIDATION_ERROR | 400 | The 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). |
UNAUTHORIZED | 401 | The 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_CREDITS | 402 | The 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. |
FORBIDDEN | 403 | The 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_FOUND | 404 | An 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_LIMITED | 429 | Too 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. |
INTERNAL | 500 | A 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.
| # | task | the question it answers | what body carries |
|---|---|---|---|
| 1 | cohort | Is 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[] |
| 2 | validate | Does 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[] |
| 3 | attrib | Do 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[] |
| 4 | report | What 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.
| field | type | meaning |
|---|---|---|
task | string, required | The lane: cohort, validate, attrib or report. See above. |
report | string, required | The 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. |
metrics | string, optional | A 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. |
stage | string | Where 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. |
audience | string | Who 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. |
context | string, optional | Anything 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_facts | object, optional | What 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:
status | means |
|---|---|
confirmed | The 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". |
adjusted | Real, 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_aside | Not a problem here, with the reason that makes it harmless. A set_aside with no reason is worse than no entry at all. |
not_applicable | The 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:
| key | shape | what 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" }
]
}
| key | type | meaning |
|---|---|---|
lane | enum | The 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. |
title | string | Short name for the review, naming the model where the report names it. |
verdict | enum | One of four values, below. The single field a submission gate should branch on. |
headline | string | One sentence naming the single fact that decides the verdict — not a summary of the findings, the one that swung it. |
summary | string | Three 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. |
body | object | The lane's own document. Four shapes, one per lane, never blended — a merged body fails to render. Documented lane by lane below. |
findings | object[] | {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. |
reconciliation | object[] | {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_notes | object[] | {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. |
unassessable | object[] | {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.
verdict | when |
|---|---|
ready | Nothing above info is left. The lane's question is answered and the report supports what it claims. |
ready_with_notes | The worst finding is medium or low. Submittable; read the notes first. |
revise | The worst finding is high. Something needs new analysis or new text before this is reportable — not a wording change. |
not_supported | At 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.
severity | meaning |
|---|---|
blocking | A 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. |
high | The 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. |
medium | Real, bounded or mitigated — worth fixing before submission rather than before the next model. |
low | Worth naming, not worth holding the paper for. |
info | Context 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.
area | covers |
|---|---|
cohort | Data source, design, setting, enrolment window, eligibility, exclusions, missing data, follow-up. |
outcome | The outcome definition, how it was ascertained, the prediction horizon, censoring, competing risks, prevalence. |
predictors | What went into the model, how many candidates there were, how they were selected, events per predictor. |
leakage | Anything knowable only after the index time, and any step that saw the test set — imputation, scaling, selection, tuning. |
validation | The split ladder itself: apparent, internal, external; resampling, tuning, optimism. |
discrimination | AUC, c-statistic, concordance, and their intervals. |
calibration | Slope, calibration-in-the-large, observed-to-expected, Brier, recalibration. |
utility | The threshold and where it came from, sensitivity, specificity, PPV, NPV, net benefit, decision curves. |
attribution | The explainer, the model family it is valid for, the background distribution, the stability of the ranking, and what the ranking is said to mean. |
fairness | Subgroups, sensitive attributes, thin strata, subgroup performance that contradicts the headline. |
reporting | TRIPOD+AI items, code and data availability, funding, registration, ethics, limitations. |
deployment | Intended 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:
- The lane is the one you asked for. Compare
laneagainst thetaskyou sent. A mismatch meanstaskdid not arrive — which is what a wrappedinputkey looks like from the outside, and it is the only symptom that failure has. - Reconciliation covers the prescan exactly. Every
uidyou sent appears once inreconciliation; nouidyou did not send appears at all. This catches a fluent review that dropped your blocking fact. - The verdict matches the worst severity.
not_supportedneeds ablockingfinding,reviseahighone, andreadyneeds nothing aboveinfo. A verdict its own findings contradict is a broken reply, not a judgement call. - The body keys belong to that lane. Four shapes, never blended. A body carrying both
leakage_reviewandchecklistis malformed even though it parses. - The enums are in range.
verdict,severityandareaare closed sets; an unrecognised value renders as an error rather than being coerced to something plausible. truncatedis false. A truncated reply is a prefix, not a report. Retry; do not repair. See below.
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.
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError(RuntimeError):
def __init__(self, code, message, details=None):
super().__init__(f"{code}: {message}")
self.code, self.message, self.details = code, message, details or {}
def call(path, body=None, headers=None):
"""Returns the unwrapped `data`, or raises ApiError with the API error code.
Two headers only: Content-Type and Authorization. There is no X-App-Slug -
the token is already bound to tripod-desk.
"""
payload = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}/{path}", data=payload, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if payload is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as res:
env = json.load(res)
except urllib.error.HTTPError as exc: # 4xx and 5xx carry the envelope too
env = json.loads(exc.read() or b"{}")
if not env.get("ok"):
err = env.get("error") or {}
raise ApiError(err.get("code", "INTERNAL"), err.get("message", "no message"),
err.get("details"))
return env["data"]
print(call("me"))
# {'subject_type': 'user', 'subject_id': 'usr_...', 'credits': 51234}
// Node 18+ or any browser. Paste a token from
// https://tripod-desk.skillsafe.ai/tokens.html, or mint a guest one in step 1.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
class ApiError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details ?? {};
}
}
// call("me") -> GET; call("estimate", input) -> POST with the input object as
// the whole body. Extra headers are for Idempotency-Key on a run.
async function call(path, body, extraHeaders = {}) {
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const env = await res.json();
if (!env.ok) throw new ApiError(env.error.code, env.error.message, env.error.details);
return env.data;
}
console.log(await call("me"));
// { subject_type: 'user', subject_id: 'usr_...', credits: 51234 }
// Two headers, and no X-App-Slug: the token already carries the app.
package main
// Imports used across every Go sample on this page:
// bytes, crypto/sha256, encoding/json, fmt, io, net/http, os, strings, time
const base = "https://api.skillsafe.ai/v1/app-api"
// From https://tripod-desk.skillsafe.ai/tokens.html, or minted in step 1.
var token = func() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}()
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *apiError) Error() string { return e.Code + ": " + e.Message }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
// call returns the raw `data` for the caller to unmarshal into its own struct.
// Two headers only - there is no X-App-Slug in this API.
func call(path string, body any, extra map[string]string) (json.RawMessage, error) {
method := http.MethodGet
var reader io.Reader
if body != nil {
method = http.MethodPost
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, base+"/"+path, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
if env.Error != nil {
return nil, env.Error
}
return nil, fmt.Errorf("INTERNAL: no error body on HTTP %d", res.StatusCode)
}
return env.Data, nil
}
// java.net.http, single file. Imports: java.net.URI, java.net.http.*,
// java.security.MessageDigest, java.util.Map.
public final class TripodDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// From https://tripod-desk.skillsafe.ai/tokens.html, or minted in step 1.
static String token = System.getenv("SKILLSAFE_TOKEN") == null
? "YOUR_TOKEN" : System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static class ApiError extends RuntimeException {
final String code;
ApiError(String code, String message) { super(code + ": " + message); this.code = code; }
}
/** GET when body is null, POST otherwise. Returns the raw response text.
* Two headers: Content-Type and Authorization. There is no X-App-Slug. */
static String call(String path, String jsonBody, Map<String, String> extra) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + token);
if (jsonBody == null) {
b = b.GET();
} else {
b = b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
for (var e : extra.entrySet()) b = b.header(e.getKey(), e.getValue());
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String text = res.body();
// These samples keep the envelope as text and use a real JSON library in
// production (Jackson, Gson). The only thing to get right is the check:
// an envelope with "ok":false carries error.code and never data.
if (text.contains("\"ok\":false")) throw new ApiError("API_ERROR", text);
return text;
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
# From https://tripod-desk.skillsafe.ai/tokens.html, or minted in step 1.
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
class ApiError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code = code
@details = details
end
end
# call("me") -> GET; call("estimate", input) -> POST with the input object as
# the whole body. No X-App-Slug: the token already carries the app.
def call(path, body = nil, extra = {})
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
extra.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
unless env["ok"]
err = env["error"] || {}
raise ApiError.new(err["code"], err["message"], err["details"] || {})
end
env["data"]
end
p call("me")
# {"subject_type"=>"user", "subject_id"=>"usr_...", "credits"=>51234}
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
// From https://tripod-desk.skillsafe.ai/tokens.html, or minted in step 1.
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN");
class ApiError extends RuntimeException {
public string $code;
public array $details;
public function __construct(string $code, string $message, array $details = []) {
parent::__construct("$code: $message");
$this->code = $code;
$this->details = $details;
}
}
// call("me") is a GET; call("estimate", $input) POSTs $input as the whole body.
// Two headers only - there is no X-App-Slug in this API.
function call(string $path, ?array $body = null, array $extra = []): array {
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) $headers[] = "Content-Type: application/json";
foreach ($extra as $k => $v) $headers[] = "$k: $v";
$ch = curl_init(BASE . "/" . $path);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
curl_close($ch);
$env = json_decode($raw, true) ?: [];
if (empty($env["ok"])) {
$err = $env["error"] ?? [];
throw new ApiError($err["code"] ?? "INTERNAL", $err["message"] ?? "no message",
$err["details"] ?? []);
}
return $env["data"];
}
print_r(call("me"));
// Array ( [subject_type] => user [subject_id] => usr_... [credits] => 51234 )
// .NET 8. Usings: System.Net.Http.Json, System.Security.Cryptography,
// System.Text, System.Text.Json.
static class TripodDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
// From https://tripod-desk.skillsafe.ai/tokens.html, or minted in step 1.
public static string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public class ApiError : Exception
{
public string Code { get; }
public ApiError(string code, string message) : base($"{code}: {message}") => Code = code;
}
/// GET when body is null, POST otherwise. Returns the unwrapped `data`.
/// Two headers: Content-Type and Authorization. There is no X-App-Slug.
public static async Task<JsonElement> Call(string path, object? body = null,
Dictionary<string, string>? extra = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
foreach (var kv in extra ?? new()) req.Headers.Add(kv.Key, kv.Value);
var res = await Http.SendAsync(req);
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!env.GetProperty("ok").GetBoolean())
{
var err = env.GetProperty("error");
throw new ApiError(err.GetProperty("code").GetString() ?? "INTERNAL",
err.GetProperty("message").GetString() ?? "no message");
}
return env.GetProperty("data");
}
}
var me = await TripodDesk.Call("me");
Console.WriteLine(me.GetProperty("credits").GetInt32());
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.
# Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
# mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
import json, urllib.request
GUEST_BODY = json.dumps({"slug": "tripod-desk"}).encode()
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=GUEST_BODY, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as res:
status = res.status # 201
guest = json.load(res)["data"]
TOKEN = guest["token"]
print(status, guest["guest_id"], "expires", guest["expires_at"])
# Keep guest_id: it is what lets a later sign-in migrate this wallet. Keep
# expires_at too - a long-lived worker re-mints instead of caching forever.
# A guest token can call /me and /estimate; a metered review cannot.
// Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "tripod-desk" }),
});
console.log(res.status); // 201
const guest = (await res.json()).data;
const guestToken = guest.token;
console.log(guest.guest_id, "expires", guest.expires_at);
// Keep guest_id - a later sign-in migrates the guest wallet with it. A guest
// token can call /me and /estimate but not a metered run.
// Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
guestBody := []byte(`{"slug":"tripod-desk"}`)
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
fmt.Println(guestRes.StatusCode) // 201
var guest struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
token = guest.Data.Token // the package-level token the helper reads
fmt.Println(guest.Data.GuestID, "expires", guest.Data.ExpiresAt)
// A guest token covers /me and /estimate; a metered review needs a personal one.
// Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"tripod-desk\"}"))
.build();
HttpResponse<String> guest = TripodDesk.HTTP.send(guestReq,
HttpResponse.BodyHandlers.ofString());
System.out.println(guest.statusCode()); // 201
System.out.println(guest.body());
// {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_...",
// "expires_at":"2026-08-27T09:14:02Z"}}
//
// Keep guest_id (a later sign-in migrates the wallet with it) and expires_at
// (re-mint rather than cache forever). A guest token cannot start a metered run.
# Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
# mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ "slug" => "tripod-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.code # "201"
guest = JSON.parse(res.body)["data"]
guest_token = guest["token"]
puts "#{guest['guest_id']} expires #{guest['expires_at']}"
# A guest token covers /me and /estimate; a metered review needs a personal one.
<?php
// Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "tripod-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true)["data"];
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 201
curl_close($ch);
echo $status, " ", $guest["guest_id"], " expires ", $guest["expires_at"], PHP_EOL;
$guestToken = $guest["token"];
// A guest token covers /me and /estimate; a metered review needs a personal one.
// Open https://tripod-desk.skillsafe.ai/tokens.html and press "Copy token", or
// mint a guest token here. The slug goes in the BODY of /guest and nowhere else.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/guest")
{
Content = new StringContent("{\"slug\":\"tripod-desk\"}",
Encoding.UTF8, "application/json"),
};
var guestRes = await http.SendAsync(guestReq);
Console.WriteLine((int)guestRes.StatusCode); // 201
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
TripodDesk.Token = guest.GetProperty("token").GetString()!;
Console.WriteLine($"{guest.GetProperty("guest_id").GetString()} expires " +
$"{guest.GetProperty("expires_at").GetString()}");
// A guest token covers /me and /estimate; a metered review needs a personal one.
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")
'
me = call("me")
# Exactly three fields: subject_type, subject_id, credits.
kind = me["subject_type"] # "guest" or "user"
print(kind, me["subject_id"], me["credits"], "credits")
# Branch on subject_type. There is no is_guest flag - a truthiness test on one
# is False for everybody, which reads as "signed in" for a guest token.
if kind == "guest":
print("guest token: /me and /estimate only; a review needs a personal token")
# me.get("user_id") is always None. The identifier is subject_id.
const me = await call("me");
// Exactly three fields: subject_type, subject_id, credits.
console.log(me.subject_type, me.subject_id, me.credits);
// Branch on subject_type. me.is_guest is undefined, so `if (me.is_guest)` is
// false for a guest too - which is exactly backwards.
if (me.subject_type === "guest") {
console.log("guest token: /me and /estimate only; a review needs a personal token");
}
// me.user_id is undefined. The identifier is subject_id for both kinds.
raw, err := call("me", nil, nil)
if err != nil {
panic(err)
}
// Exactly three fields. No UserID, no IsGuest - adding them to this struct just
// gives you a zero value that looks like an answer.
var me struct {
SubjectType string `json:"subject_type"` // "guest" or "user"
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.SubjectID, me.Credits)
if me.SubjectType == "guest" {
fmt.Println("guest token: /me and /estimate only; a review needs a personal token")
}
String meJson = TripodDesk.call("me", null, Map.of());
System.out.println(meJson);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":51234}}
//
// Three fields, and only three: subject_type ("guest" or "user"), subject_id and
// credits. There is no user_id and no is_guest, so branch on subject_type:
//
// boolean guest = "guest".equals(subjectType);
//
// A guest token can call /me and /estimate. A metered review needs a personal
// token from https://tripod-desk.skillsafe.ai/tokens.html.
System.out.println(meJson.contains("\"subject_type\":\"guest\"")
? "guest token: no metered run"
: "personal token: metered runs allowed");
me = call("me")
# Exactly three fields: subject_type, subject_id, credits.
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']} credits"
# Branch on subject_type. me["is_guest"] is nil for everybody, and me["user_id"]
# does not exist - the identifier is subject_id.
if me["subject_type"] == "guest"
puts "guest token: /me and /estimate only; a review needs a personal token"
end
<?php
$me = call("me");
// Exactly three fields: subject_type, subject_id, credits.
echo $me["subject_type"], " ", $me["subject_id"], " ", $me["credits"], PHP_EOL;
// Branch on subject_type. $me["is_guest"] is not set, so an isset() test is
// false for a guest as well, and there is no user_id at all.
if ($me["subject_type"] === "guest") {
echo "guest token: /me and /estimate only; a review needs a personal token", PHP_EOL;
}
var me = await TripodDesk.Call("me");
// Exactly three fields: subject_type, subject_id, credits.
var kind = me.GetProperty("subject_type").GetString(); // "guest" or "user"
Console.WriteLine($"{kind} {me.GetProperty("subject_id").GetString()} " +
$"{me.GetProperty("credits").GetInt32()}");
// Branch on subject_type. TryGetProperty("is_guest", ...) is always false, and
// there is no user_id - subject_id is the identifier for both kinds.
if (kind == "guest")
Console.WriteLine("guest token: /me and /estimate only; a review needs a personal token");
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:
| field | for this app | meaning |
|---|---|---|
model | gpt-5.6-terra | The exact model the run will bind to. |
model_alias | gpt-terra | The 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_bps | 1000 | The app's markup in basis points; 1000 is ten per cent. |
hold_credits | varies | What gets reserved when the run starts. Priced against the full output cap, so it is an upper bound, not the price. |
min_credits | varies | The balance you must clear for the run to start at all. Compare this against credits from /me. |
sponsor_enabled | varies | Whether 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"])
'
REPORT = "\n".join([
"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",
])
# Tab-separated; CSV, semicolons and markdown pipe tables parse too.
METRICS = (
"split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\t"
"cal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold\n"
"development\t12888\t773\t0.060\t0.94\t0.93\t0.95\t0.041\t"
"1.00\t0.00\t0.88\t0.87\t0.31\t0.99\t0.15\n"
"internal\t5524\t331\t0.060\t0.91\t0.90\t0.92\t0.048\t"
"0.71\t0.35\t0.86\t0.84\t0.26\t0.99\t0.15\n"
)
PRESCAN = {
"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"}],
}
INPUT = {
"task": "validate",
"report": REPORT,
"metrics": METRICS,
"stage": "internal",
"audience": "journal",
"context": "Reviewer 2 asked only for a calibration plot. "
"Submission deadline in three weeks.",
"prescan_facts": PRESCAN,
}
# This is the whole body. It is NOT {"input": INPUT} - that shape returns 200 and
# the model never sees a field of it.
est = call("estimate", INPUT)
assert est["model_alias"] == "gpt-terra", est["model_alias"]
print(est["model"], est["model_alias"], est["markup_bps"])
print("hold", est["hold_credits"], "min", est["min_credits"],
"sponsored", est["sponsor_enabled"])
me = call("me")
if not est["sponsor_enabled"] and me["credits"] < est["min_credits"]:
raise SystemExit(f"balance {me['credits']} is below min_credits {est['min_credits']}")
const REPORT = [
"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",
].join("\n");
// Tab-separated; CSV, semicolons and markdown pipe tables parse too.
const METRICS =
"split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\t" +
"cal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold\n" +
"development\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\n" +
"internal\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";
const PRESCAN = {
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" }],
};
const INPUT = {
task: "validate",
report: REPORT,
metrics: METRICS,
stage: "internal",
audience: "journal",
context: "Reviewer 2 asked only for a calibration plot. Submission deadline in three weeks.",
prescan_facts: PRESCAN,
};
// This is the whole body - not { input: INPUT }, which returns 200 while the
// model sees nothing.
const est = await call("estimate", INPUT);
if (est.model_alias !== "gpt-terra") throw new Error(`unexpected model ${est.model_alias}`);
console.log(est.model, est.markup_bps, "hold", est.hold_credits, "min", est.min_credits);
const me = await call("me");
if (!est.sponsor_enabled && me.credits < est.min_credits) {
throw new Error(`balance ${me.credits} is below min_credits ${est.min_credits}`);
}
report := strings.Join([]string{
"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",
"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",
}, "\n")
metrics := "split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\t" +
"cal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold\n" +
"development\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\n" +
"internal\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"
input := map[string]any{
"task": "validate",
"report": report,
"metrics": metrics,
"stage": "internal",
"audience": "journal",
"context": "Reviewer 2 asked only for a calibration plot. Deadline in three weeks.",
"prescan_facts": map[string]any{
"flags": []any{
map[string]any{
"uid": "F1", "id": "TD-LEAK-TESTSET", "severity": "blocking",
"area": "leakage", "label": "Predictor selection ran before the split",
"detail": "The top 40 of 214 candidates were kept by a ranking computed on all records.",
"line": 11,
"evidence": "Predictor selection: SHAP-ranked, top 40 retained",
},
map[string]any{
"uid": "F2", "id": "TD-NO-CALIBRATION", "severity": "high",
"area": "calibration", "label": "Calibration is stated as not assessed",
"detail": "No slope, intercept or O:E appears in the text.", "line": 15,
"evidence": "Calibration: not assessed",
},
},
"free_read_verdict": "not_supported",
"numbers": map[string]any{
"n": 18412, "events": 1104, "predictors": 214, "epv": 5.16, "prevalence": 0.06,
},
"evidence": map[string]any{
"discrimination": "reported", "calibration": "declared_absent",
"classification": "reported", "utility": "absent",
},
},
}
// The body is `input` itself, not map[string]any{"input": input}.
raw, err := call("estimate", input, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
_ = json.Unmarshal(raw, &est)
if est.ModelAlias != "gpt-terra" {
panic("unexpected model alias " + est.ModelAlias)
}
fmt.Println(est.Model, est.HoldCredits, est.MinCredits, est.SponsorEnabled)
// Free: no job is created and nothing is charged. The hold is a reservation.
// The input object, built as JSON text. In production use Jackson or Gson and
// build a Map; the shape is what matters, and the shape is FLAT.
String report = String.join("\\n",
"Model: ICU-DETERIORATE v2 (gradient-boosted trees)",
"Index time: first 6 hours after ICU admission",
"Prediction horizon: 24 hours",
"Primary outcome: in-hospital mortality or unplanned transfer",
"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");
String metrics = String.join("\\n",
"split\\tn\\tevents\\tauc\\tcal_slope\\tcal_intercept\\tthreshold",
"development\\t12888\\t773\\t0.94\\t1.00\\t0.00\\t0.15",
"internal\\t5524\\t331\\t0.91\\t0.71\\t0.35\\t0.15");
// Note the absence of any wrapper key: task, report, metrics, stage, audience,
// context and prescan_facts are all TOP-LEVEL.
String input = """
{"task":"validate",
"report":"%s",
"metrics":"%s",
"stage":"internal",
"audience":"journal",
"context":"Reviewer 2 asked only for a calibration plot.",
"prescan_facts":{"flags":[
{"uid":"F1","id":"TD-LEAK-TESTSET","severity":"blocking","area":"leakage",
"label":"Predictor selection ran before the split",
"detail":"The top 40 of 214 candidates were kept by a ranking computed on all records.",
"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 slope, intercept or O:E appears in the text.",
"line":15,"evidence":"Calibration: not assessed"}],
"free_read_verdict":"not_supported",
"numbers":{"n":18412,"events":1104,"predictors":214,"epv":5.16}}}
""".formatted(report, metrics);
String est = TripodDesk.call("estimate", input, Map.of());
System.out.println(est);
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":2960,"min_credits":340,"sponsor_enabled":false}}
if (!est.contains("\"model_alias\":\"gpt-terra\""))
throw new IllegalStateException("not this app's model: " + est);
// estimate is free and creates no job - but it is authenticated, so step 1 first.
REPORT = [
"Model: ICU-DETERIORATE v2 (gradient-boosted trees)",
"Care setting: two tertiary ICUs, 2019-2023",
"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",
"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"
].join("\n")
METRICS = [
%w[split n events prevalence auc auc_lo auc_hi brier cal_slope cal_intercept
sens spec ppv npv threshold].join("\t"),
%w[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].join("\t"),
%w[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].join("\t")
].join("\n") + "\n"
INPUT = {
"task" => "validate",
"report" => REPORT,
"metrics" => METRICS,
"stage" => "internal",
"audience" => "journal",
"context" => "Reviewer 2 asked only for a calibration plot. 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" => "The top 40 of 214 candidates were kept by a ranking computed on all records.",
"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 slope, intercept or O:E appears in the text.",
"line" => 15, "evidence" => "Calibration: not assessed" }
],
"free_read_verdict" => "not_supported",
"numbers" => { "n" => 18412, "events" => 1104, "predictors" => 214, "epv" => 5.16 },
"evidence" => { "discrimination" => "reported", "calibration" => "declared_absent",
"classification" => "reported", "utility" => "absent" }
}
}
# The whole body, flat. Not {"input" => INPUT}.
est = call("estimate", INPUT)
raise "unexpected model #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
puts "#{est['model']} hold #{est['hold_credits']} min #{est['min_credits']}"
me = call("me")
if !est["sponsor_enabled"] && me["credits"] < est["min_credits"]
abort "balance #{me['credits']} is below min_credits #{est['min_credits']}"
end
<?php
$report = implode("\n", [
"Model: ICU-DETERIORATE v2 (gradient-boosted trees)",
"Care setting: two tertiary ICUs, 2019-2023",
"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",
"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",
]);
$metrics = implode("\n", [
"split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\tcal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold",
"development\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",
"internal\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";
// Flat. Every key here is top-level; there is no "input" wrapper.
$INPUT = [
"task" => "validate",
"report" => $report,
"metrics" => $metrics,
"stage" => "internal",
"audience" => "journal",
"context" => "Reviewer 2 asked only for a calibration plot. 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" => "The top 40 of 214 candidates were kept by a ranking computed on all records.",
"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 slope, intercept or O:E appears in the text.",
"line" => 15, "evidence" => "Calibration: not assessed"],
],
"free_read_verdict" => "not_supported",
"numbers" => ["n" => 18412, "events" => 1104, "predictors" => 214, "epv" => 5.16],
"evidence" => ["discrimination" => "reported", "calibration" => "declared_absent",
"classification" => "reported", "utility" => "absent"],
],
];
$est = call("estimate", $INPUT);
if ($est["model_alias"] !== "gpt-terra") {
throw new RuntimeException("unexpected model " . $est["model_alias"]);
}
echo $est["model"], " hold ", $est["hold_credits"], " min ", $est["min_credits"], PHP_EOL;
$me = call("me");
if (!$est["sponsor_enabled"] && $me["credits"] < $est["min_credits"]) {
throw new RuntimeException("balance below min_credits");
}
var report = string.Join("\n", new[] {
"Model: ICU-DETERIORATE v2 (gradient-boosted trees)",
"Care setting: two tertiary ICUs, 2019-2023",
"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",
"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",
});
var metrics = string.Join("\n", new[] {
"split\tn\tevents\tprevalence\tauc\tauc_lo\tauc_hi\tbrier\tcal_slope\tcal_intercept\tsens\tspec\tppv\tnpv\tthreshold",
"development\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",
"internal\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";
// Anonymous object, serialised flat. No "input" wrapper anywhere.
var input = new
{
task = "validate",
report,
metrics,
stage = "internal",
audience = "journal",
context = "Reviewer 2 asked only for a calibration plot. Deadline in three weeks.",
prescan_facts = new
{
flags = new object[]
{
new { uid = "F1", id = "TD-LEAK-TESTSET", severity = "blocking", area = "leakage",
label = "Predictor selection ran before the split",
detail = "The top 40 of 214 candidates were kept by a ranking computed on all records.",
line = 11, evidence = "Predictor selection: SHAP-ranked, top 40 retained" },
new { uid = "F2", id = "TD-NO-CALIBRATION", severity = "high", area = "calibration",
label = "Calibration is stated as not assessed",
detail = "No slope, intercept or O:E appears in the text.",
line = 15, evidence = "Calibration: not assessed" },
},
free_read_verdict = "not_supported",
numbers = new { n = 18412, events = 1104, predictors = 214, epv = 5.16 },
evidence = new { discrimination = "reported", calibration = "declared_absent",
classification = "reported", utility = "absent" },
},
};
var est = await TripodDesk.Call("estimate", input);
var alias = est.GetProperty("model_alias").GetString();
if (alias != "gpt-terra") throw new Exception($"unexpected model {alias}");
Console.WriteLine($"{est.GetProperty("model").GetString()} " +
$"hold {est.GetProperty("hold_credits").GetInt32()} " +
$"min {est.GetProperty("min_credits").GetInt32()}");
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"))
'
import hashlib, time
SEV = ["blocking", "high", "medium", "low", "info"]
VERDICT_FOR = ["not_supported", "revise", "ready_with_notes", "ready_with_notes", "ready"]
LANE_KEYS = {
"cohort": {"data_source_review", "index_definition", "outcome_definition",
"leakage_review", "sample_size", "must_fix"},
"validate": {"design_review", "metric_review", "calibration", "discrimination",
"utility", "sample_size_for_validation", "must_fix"},
"attrib": {"method_review", "claim_review", "stability", "subgroup_attribution",
"safe_wording", "must_fix"},
"report": {"checklist", "abstract_draft", "limitations", "intended_use_statement",
"open_items", "must_fix"},
}
def run_and_wait(body, attempt=1, interval=2.0, timeout=420.0):
"""POST /run with an idempotency key, then poll jobs/{job_id} to a terminal state."""
payload = json.dumps(body, sort_keys=True).encode()
digest = hashlib.sha256(payload).hexdigest()[:16]
# The lane is part of the body, so it belongs in the key.
key = f"tripod-desk:{digest}:{body.get('task', 'validate')}:a{attempt}"
# body, not {"input": body}.
started = call("run", body, {"Idempotency-Key": key})
job_id = started["job_id"]
deadline = time.monotonic() + timeout
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(f"job {job_id} still {job['status']}")
time.sleep(interval)
def check(report, job, task, sent_uids):
"""The six assertions from the verification list, in order of what they catch."""
if report["lane"] != task:
raise ValueError(f"lane {report['lane']} != task {task}: was the body wrapped?")
if job.get("truncated"):
raise ValueError("truncated: retry with a retry_note, do not repair")
back = [c["flag_uid"] for c in report["reconciliation"]]
if sorted(back) != sorted(sent_uids):
raise ValueError(f"reconciliation {back} != flags sent {sorted(sent_uids)}")
if len(back) != len(set(back)):
raise ValueError("a uid was reconciled twice")
worst = min((SEV.index(f["severity"]) for f in report["findings"]), default=4)
if report["verdict"] != VERDICT_FOR[worst]:
raise ValueError(f"verdict {report['verdict']} contradicts worst finding {SEV[worst]}")
extra = set(report["body"]) - LANE_KEYS[report["lane"]]
if extra:
raise ValueError(f"body carries keys from another lane: {sorted(extra)}")
must_fix = report["body"].get("must_fix") or []
if (report["verdict"] == "not_supported") != bool(must_fix):
raise ValueError("must_fix and not_supported must agree")
return report
job = run_and_wait(INPUT, attempt=1)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
raw = job["output"]["output"]
report = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
check(report, job, INPUT["task"], [f["uid"] for f in PRESCAN["flags"]])
print(report["lane"], report["verdict"], "-", report["headline"])
for f in report["findings"]:
print(f" {f['severity']:8} {f['area']:14} {f['title']}")
for c in report["reconciliation"]:
print(f" {c['flag_uid']:4} {c['status']:15} {c['note'][:70]}")
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const SEV = ["blocking", "high", "medium", "low", "info"];
const VERDICT_FOR = ["not_supported", "revise", "ready_with_notes", "ready_with_notes", "ready"];
const LANE_KEYS = {
cohort: ["data_source_review", "index_definition", "outcome_definition",
"leakage_review", "sample_size", "must_fix"],
validate: ["design_review", "metric_review", "calibration", "discrimination",
"utility", "sample_size_for_validation", "must_fix"],
attrib: ["method_review", "claim_review", "stability", "subgroup_attribution",
"safe_wording", "must_fix"],
report: ["checklist", "abstract_draft", "limitations", "intended_use_statement",
"open_items", "must_fix"],
};
async function runAndWait(body, attempt = 1, intervalMs = 2000) {
const digest = createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16);
// The lane is part of the body, so it belongs in the key.
const key = `tripod-desk:${digest}:${body.task ?? "validate"}:a${attempt}`;
// body, not { input: body }.
const started = await call("run", body, { "Idempotency-Key": key });
let job = started;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, intervalMs));
job = await call(`jobs/${started.job_id}`);
}
return job;
}
function check(report, job, task, sentUids) {
if (report.lane !== task)
throw new Error(`lane ${report.lane} != task ${task}: was the body wrapped?`);
if (job.truncated) throw new Error("truncated: retry with a retry_note, do not repair");
const back = report.reconciliation.map((c) => c.flag_uid).sort();
const sent = [...sentUids].sort();
if (back.join() !== sent.join())
throw new Error(`reconciliation ${back} != flags sent ${sent}`);
const worst = report.findings.length
? Math.min(...report.findings.map((f) => SEV.indexOf(f.severity)))
: 4;
if (report.verdict !== VERDICT_FOR[worst])
throw new Error(`verdict ${report.verdict} contradicts worst finding ${SEV[worst]}`);
const allowed = LANE_KEYS[report.lane];
const extra = Object.keys(report.body).filter((k) => !allowed.includes(k));
if (extra.length) throw new Error(`body carries other lanes' keys: ${extra}`);
const mustFix = report.body.must_fix ?? [];
if ((report.verdict === "not_supported") !== mustFix.length > 0)
throw new Error("must_fix and not_supported must agree");
return report;
}
const job = await runAndWait(INPUT, 1);
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const raw = job.output.output;
const report = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
check(report, job, INPUT.task, PRESCAN.flags.map((f) => f.uid));
console.log(report.lane, report.verdict, "-", report.headline);
for (const f of report.findings) console.log(" ", f.severity, f.area, f.title);
console.log("charged", job.charged_credits, "truncated", job.truncated);
// Always send an Idempotency-Key derived from the input, with the lane in it.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("tripod-desk:%x:%s:a1", sum[:8], input["task"])
// call() adds the two standard headers; the extra map adds the third.
raw, err := call("run", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
type jobState struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
ChargedCredits int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
var job jobState
for {
raw, err := call("jobs/"+started.JobID, nil, nil)
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
if job.Status == "failed" {
panic("run failed")
}
if job.Truncated {
panic("truncated: this is a prefix, not a review")
}
// Take everything from the first { to the last }, then parse.
text := job.Output.Output
text = text[strings.Index(text, "{") : strings.LastIndex(text, "}")+1]
var report struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Findings []struct {
Severity string `json:"severity"`
Area string `json:"area"`
Title string `json:"title"`
} `json:"findings"`
Reconciliation []struct {
FlagUID string `json:"flag_uid"`
Status string `json:"status"`
} `json:"reconciliation"`
}
if err := json.Unmarshal([]byte(text), &report); err != nil {
panic(err)
}
// The assertion that catches a wrapped body: the lane you asked for came back.
if report.Lane != input["task"] {
panic("lane " + report.Lane + " is not the task that was sent")
}
if len(report.Reconciliation) != 2 { // one entry per uid sent, exactly
panic(fmt.Sprintf("reconciliation has %d entries for 2 flags", len(report.Reconciliation)))
}
fmt.Println(report.Verdict, "-", report.Headline, "charged", job.ChargedCredits)
// The key: a content hash, the lane, and an attempt counter. Same key on a retry
// returns the same job; a changed body needs a bumped attempt.
var sha = MessageDigest.getInstance("SHA-256").digest(input.getBytes("UTF-8"));
var hex = new StringBuilder();
for (int i = 0; i < 8; i++) hex.append(String.format("%02x", sha[i]));
String key = "tripod-desk:" + hex + ":validate:a1";
// input is the flat object from step 3 - no wrapper key.
String startedJson = TripodDesk.call("run", input, Map.of("Idempotency-Key", key));
String jobId = startedJson.split("\"job_id\":\"")[1].split("\"")[0];
String jobJson;
while (true) {
jobJson = TripodDesk.call("jobs/" + jobId, null, Map.of());
if (jobJson.contains("\"status\":\"succeeded\"")
|| jobJson.contains("\"status\":\"failed\"")) break;
Thread.sleep(2000);
}
if (jobJson.contains("\"status\":\"failed\"")) throw new IllegalStateException(jobJson);
if (jobJson.contains("\"truncated\":true"))
throw new IllegalStateException("truncated: a prefix, not a review");
// data.output.output is a JSON *string*, so it arrives escaped inside the
// envelope. With a real JSON library: env.get("data").get("output").get("output")
// then parse that text. The invariants worth asserting on the parsed object:
//
// report.lane equals the task you sent (a mismatch means a wrapped body)
// reconciliation one entry per uid you sent, exactly once each
// verdict not_supported iff a blocking finding exists
// body's keys all belong to report.lane
//
System.out.println(jobJson.contains("\"verdict\":\"not_supported\"")
? "not supported - see body.must_fix"
: "check the verdict against the findings");
require "digest"
digest = Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]
key = "tripod-desk:#{digest}:#{INPUT['task']}:a1"
# INPUT itself is the body. Not {"input" => INPUT}.
started = call("run", INPUT, { "Idempotency-Key" => key })
job = nil
loop do
job = call("jobs/#{started['job_id']}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
abort "run failed: #{job['error']}" if job["status"] == "failed"
abort "truncated: a prefix, not a review" if job["truncated"]
raw = job["output"]["output"]
report = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
# The four assertions worth having in every client.
abort "lane #{report['lane']} != #{INPUT['task']}" unless report["lane"] == INPUT["task"]
sent = INPUT["prescan_facts"]["flags"].map { |f| f["uid"] }.sort
back = report["reconciliation"].map { |c| c["flag_uid"] }.sort
abort "reconciliation #{back} != #{sent}" unless back == sent
sev = %w[blocking high medium low info]
worst = report["findings"].map { |f| sev.index(f["severity"]) }.min || 4
expect = %w[not_supported revise ready_with_notes ready_with_notes ready][worst]
abort "verdict #{report['verdict']} contradicts #{sev[worst]}" unless report["verdict"] == expect
puts "#{report['verdict']} - #{report['headline']}"
report["findings"].each { |f| puts " #{f['severity']}\t#{f['area']}\t#{f['title']}" }
puts "charged #{job['charged_credits']}"
<?php
$digest = substr(hash("sha256", json_encode($INPUT)), 0, 16);
$key = "tripod-desk:$digest:{$INPUT['task']}:a1";
// $INPUT is the body. There is no "input" wrapper.
$started = call("run", $INPUT, ["Idempotency-Key" => $key]);
do {
sleep(2);
$job = call("jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") throw new RuntimeException(json_encode($job));
if (!empty($job["truncated"])) throw new RuntimeException("truncated: a prefix, not a review");
$raw = $job["output"]["output"];
$report = json_decode(substr($raw, strpos($raw, "{"),
strrpos($raw, "}") - strpos($raw, "{") + 1), true);
// Lane first: a mismatch is the only symptom a wrapped body has.
if ($report["lane"] !== $INPUT["task"]) {
throw new RuntimeException("lane {$report['lane']} != {$INPUT['task']}");
}
$sent = array_map(fn($f) => $f["uid"], $INPUT["prescan_facts"]["flags"]);
$back = array_map(fn($c) => $c["flag_uid"], $report["reconciliation"]);
sort($sent); sort($back);
if ($sent !== $back) throw new RuntimeException("reconciliation mismatch");
$sev = ["blocking", "high", "medium", "low", "info"];
$worst = 4;
foreach ($report["findings"] as $f) $worst = min($worst, array_search($f["severity"], $sev, true));
$expect = ["not_supported", "revise", "ready_with_notes", "ready_with_notes", "ready"][$worst];
if ($report["verdict"] !== $expect) throw new RuntimeException("verdict contradicts findings");
echo $report["verdict"], " - ", $report["headline"], PHP_EOL;
echo "charged ", $job["charged_credits"], PHP_EOL;
var payload = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
.ToLowerInvariant()[..16];
var key = $"tripod-desk:{digest}:validate:a1";
// input itself is the body - no wrapper key.
var started = await TripodDesk.Call("run", input,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await TripodDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
if (job.GetProperty("status").GetString() == "failed") throw new Exception(job.ToString());
if (job.TryGetProperty("truncated", out var tr) && tr.GetBoolean())
throw new Exception("truncated: a prefix, not a review");
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
raw = raw[raw.IndexOf('{')..(raw.LastIndexOf('}') + 1)];
var report = JsonDocument.Parse(raw).RootElement;
// Lane first: it is the only symptom a wrapped body has.
var lane = report.GetProperty("lane").GetString();
if (lane != "validate") throw new Exception($"lane {lane} is not the task that was sent");
var back = report.GetProperty("reconciliation").EnumerateArray()
.Select(c => c.GetProperty("flag_uid").GetString()).OrderBy(s => s).ToList();
var sent = new[] { "F1", "F2" }.OrderBy(s => s).ToList(); // the uids actually sent
if (!back.SequenceEqual(sent)) throw new Exception("reconciliation mismatch");
var sev = new[] { "blocking", "high", "medium", "low", "info" };
var worst = report.GetProperty("findings").EnumerateArray()
.Select(f => Array.IndexOf(sev, f.GetProperty("severity").GetString()))
.DefaultIfEmpty(4).Min();
var expect = new[] { "not_supported", "revise", "ready_with_notes", "ready_with_notes", "ready" }[worst];
if (report.GetProperty("verdict").GetString() != expect)
throw new Exception("verdict contradicts its own findings");
Console.WriteLine($"{report.GetProperty("verdict").GetString()} - " +
$"{report.GetProperty("headline").GetString()}");
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.
# Server-sent events: the review arrives in chunks, so a UI can show progress.
req = urllib.request.Request(
f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
done = {}
event = None
stage = "reading the report"
with urllib.request.urlopen(req) as stream:
if "text/event-stream" not in stream.headers.get("Content-Type", ""):
# An idempotent replay answers with plain JSON, not a stream.
done = json.load(stream)["data"]
raw = done.get("output", {}).get("output", "")
else:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
if '"summary"' in raw:
stage = "writing the summary"
elif '"body"' in raw:
stage = "building the lane document"
elif '"reconciliation"' in raw:
stage = "reconciling the prescan flags"
elif '"findings"' in raw:
stage = "naming the findings"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
elif line.startswith("data: ") and event == "error":
raise RuntimeError(line[6:])
report = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, report["verdict"], len(report["findings"]), "findings",
done.get("charged_credits"), "truncated", done.get("truncated"))
// Server-sent events: the review arrives in chunks, so a UI can show progress.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
// An idempotent replay answers with plain JSON instead of a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const replay = (await res.json()).data;
console.log("replay", replay.status, replay.charged_credits);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let done = {};
let stage = "reading the report";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
// Events are separated by a blank line, not by a single newline.
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const event = (frame.match(/^event: (.*)$/m) || [])[1];
const dataLine = (frame.match(/^data: (.*)$/m) || [])[1];
if (!event || !dataLine) continue;
if (event === "delta") {
raw += (JSON.parse(dataLine).text ?? "");
if (raw.includes('"summary"')) stage = "writing the summary";
else if (raw.includes('"body"')) stage = "building the lane document";
else if (raw.includes('"reconciliation"')) stage = "reconciling the prescan flags";
else if (raw.includes('"findings"')) stage = "naming the findings";
} else if (event === "done") {
done = JSON.parse(dataLine);
} else if (event === "error") {
throw new Error(dataLine);
}
}
}
const report = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, report.verdict, report.findings.length, "findings",
done.charged_credits, "truncated", done.truncated);
// Server-sent events over the same body and the same Idempotency-Key.
raw, _ := json.Marshal(input)
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
// An idempotent replay answers with plain JSON, not a stream.
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
body, _ := io.ReadAll(res.Body)
fmt.Println("replay:", string(body))
return
}
var text strings.Builder
var event string
stage := "reading the report"
buf := make([]byte, 4096)
var pending strings.Builder
for {
n, err := res.Body.Read(buf)
if n > 0 {
pending.WriteString(string(buf[:n]))
lines := strings.Split(pending.String(), "\n")
pending.Reset()
pending.WriteString(lines[len(lines)-1]) // keep the partial line
for _, line := range lines[:len(lines)-1] {
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
text.WriteString(d.Text)
if strings.Contains(text.String(), `"summary"`) {
stage = "writing the summary"
} else if strings.Contains(text.String(), `"body"`) {
stage = "building the lane document"
}
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(stage, strings.TrimPrefix(line, "data: "))
case strings.HasPrefix(line, "data: ") && event == "error":
panic(strings.TrimPrefix(line, "data: "))
}
}
}
if err != nil {
break
}
}
out := text.String()
fmt.Println(out[strings.Index(out, "{") : strings.LastIndex(out, "}")+1])
// Streaming with java.net.http: read the body as a line stream.
var streamReq = HttpRequest.newBuilder(URI.create(TripodDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + TripodDesk.token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
var stream = TripodDesk.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines());
// An idempotent replay answers with plain JSON instead of a stream.
boolean isStream = stream.headers().firstValue("content-type")
.orElse("").contains("text/event-stream");
var text = new StringBuilder();
var event = new String[] { "" };
var stage = new String[] { "reading the report" };
stream.body().forEach(line -> {
if (line.startsWith("event: ")) {
event[0] = line.substring(7);
} else if (line.startsWith("data: ")) {
String data = line.substring(6);
switch (event[0]) {
case "delta" -> {
// data is {"text":"..."} - parse with a real JSON library.
text.append(data);
if (text.indexOf("\"summary\"") >= 0) stage[0] = "writing the summary";
else if (text.indexOf("\"body\"") >= 0) stage[0] = "building the lane document";
}
case "done" -> System.out.println(stage[0] + " " + data);
case "error" -> throw new IllegalStateException(data);
default -> { }
}
}
});
System.out.println(isStream ? "streamed" : "replayed as plain JSON");
require "net/http"
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(INPUT)
text = +""
event = nil
stage = "reading the report"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
# An idempotent replay answers with plain JSON, not a stream.
unless res["content-type"].to_s.include?("text/event-stream")
puts "replay: #{res.body}"
next
end
buffer = +""
res.read_body do |chunk|
buffer << chunk
while (i = buffer.index("\n"))
line = buffer.slice!(0..i).chomp
if line.start_with?("event: ")
event = line[7..]
elsif line.start_with?("data: ")
data = line[6..]
case event
when "delta"
text << (JSON.parse(data)["text"] || "")
stage = "writing the summary" if text.include?('"summary"')
when "done" then puts "#{stage} #{data}"
when "error" then raise data
end
end
end
end
end
end
report = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{report['verdict']} - #{report['headline']}"
<?php
// Streaming with curl's write callback. Events are separated by a blank line.
$text = "";
$event = "";
$stage = "reading the report";
$buffer = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($INPUT));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: $key",
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION,
function ($ch, $chunk) use (&$text, &$event, &$stage, &$buffer) {
$buffer .= $chunk;
while (($i = strpos($buffer, "\n")) !== false) {
$line = rtrim(substr($buffer, 0, $i));
$buffer = substr($buffer, $i + 1);
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$data = substr($line, 6);
if ($event === "delta") {
$text .= json_decode($data, true)["text"] ?? "";
if (str_contains($text, '"summary"')) $stage = "writing the summary";
} elseif ($event === "done") {
echo $stage, " ", $data, PHP_EOL;
} elseif ($event === "error") {
throw new RuntimeException($data);
}
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$report = json_decode(substr($text, strpos($text, "{"),
strrpos($text, "}") - strpos($text, "{") + 1), true);
echo $report["verdict"], " - ", $report["headline"], PHP_EOL;
var streamReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json"),
};
streamReq.Headers.Add("Authorization", $"Bearer {TripodDesk.Token}");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Headers.Add("Accept", "text/event-stream");
using var http2 = new HttpClient();
var res2 = await http2.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
// An idempotent replay answers with plain JSON instead of a stream.
var isStream = res2.Content.Headers.ContentType?.MediaType == "text/event-stream";
using var reader = new StreamReader(await res2.Content.ReadAsStreamAsync());
var text = new StringBuilder();
var evt = "";
var stage = "reading the report";
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line is null) break;
if (line.StartsWith("event: ")) { evt = line[7..]; continue; }
if (!line.StartsWith("data: ")) continue;
var data = line[6..];
if (evt == "delta")
{
text.Append(JsonDocument.Parse(data).RootElement
.GetProperty("text").GetString());
if (text.ToString().Contains("\"summary\"")) stage = "writing the summary";
}
else if (evt == "done") Console.WriteLine($"{stage} {data}");
else if (evt == "error") throw new Exception(data);
}
var all = text.ToString();
var report2 = JsonDocument.Parse(all[all.IndexOf('{')..(all.LastIndexOf('}') + 1)]).RootElement;
Console.WriteLine($"{(isStream ? "streamed" : "replayed")} " +
$"{report2.GetProperty("verdict").GetString()}");
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
# One report, four lanes, in pipeline order: each one can invalidate the next, so
# stop at the first blocking verdict rather than paying for all four.
LANES = ["cohort", "validate", "attrib", "report"]
BASE_INPUT = {k: v for k, v in INPUT.items() if k != "task"}
reviews = {}
for lane in LANES:
body = dict(BASE_INPUT, task=lane)
est = call("estimate", body) # free; price each lane separately
print(f"{lane:9} hold {est['hold_credits']:6} min {est['min_credits']}")
job = run_and_wait(body, attempt=1) # its own key, because task differs
raw = job["output"]["output"]
rep = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
check(rep, job, lane, [f["uid"] for f in PRESCAN["flags"]])
reviews[lane] = rep
print(f"{rep['lane']:9} {rep['verdict']:16} {rep['headline'][:70]}")
if rep["verdict"] == "not_supported" and lane == "cohort":
# A cohort that is not well posed makes every later number unreadable.
print("stopping: fix the cohort before paying for the other three lanes")
break
# The same fact must arrive at the same severity in every lane it appears in.
for lane, rep in reviews.items():
worst = min((f["severity"] for f in rep["findings"]),
key=lambda s: SEV.index(s), default="info")
print(lane, "worst severity", worst, "must_fix", len(rep["body"].get("must_fix", [])))
// One report, four lanes, in pipeline order. Each lane is its own metered run.
const LANES = ["cohort", "validate", "attrib", "report"];
const { task: _drop, ...BASE_INPUT } = INPUT;
const reviews = {};
for (const lane of LANES) {
const body = { ...BASE_INPUT, task: lane };
const est = await call("estimate", body); // free; price each lane separately
console.log(lane, "hold", est.hold_credits, "min", est.min_credits);
const job = await runAndWait(body, 1); // its own key, because task differs
const raw = job.output.output;
const rep = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
check(rep, job, lane, PRESCAN.flags.map((f) => f.uid));
reviews[lane] = rep;
console.log(rep.lane, rep.verdict, "-", rep.headline);
if (lane === "cohort" && rep.verdict === "not_supported") {
console.log("stopping: fix the cohort before paying for the other three lanes");
break;
}
}
// body keys differ per lane; everything outside body has the same shape.
for (const [lane, rep] of Object.entries(reviews)) {
console.log(lane, Object.keys(rep.body).join(", "));
}
// One report, four lanes, in pipeline order. Only "task" changes.
for _, lane := range []string{"cohort", "validate", "attrib", "report"} {
body := make(map[string]any, len(input))
for k, v := range input {
body[k] = v
}
body["task"] = lane
// Price it first - free, and the four lanes are not priced alike.
if raw, err := call("estimate", body, nil); err == nil {
var est struct {
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
_ = json.Unmarshal(raw, &est)
fmt.Printf("%-9s hold %d min %d\n", lane, est.HoldCredits, est.MinCredits)
}
// A separate key per lane: the lane is part of the body.
payload, _ := json.Marshal(body)
sum := sha256.Sum256(payload)
laneKey := fmt.Sprintf("tripod-desk:%x:%s:a1", sum[:8], lane)
raw, err := call("run", body, map[string]string{"Idempotency-Key": laneKey})
if err != nil {
panic(err)
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
fmt.Println(lane, "started as", started.JobID)
// ...then poll jobs/{id} exactly as in step 4, and assert report.lane == lane.
}
// One report, four lanes, in pipeline order. Only the task value changes, so the
// body is one template with one substitution.
String template = input.replace("\"task\":\"validate\"", "\"task\":\"%s\"");
for (String lane : new String[] { "cohort", "validate", "attrib", "report" }) {
String body = template.formatted(lane);
// Free, and worth doing per lane: "report" is much wordier than "cohort".
String est = TripodDesk.call("estimate", body, Map.of());
System.out.println(lane + " " + est);
// A separate key per lane - the lane is part of the body.
var sha2 = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"));
var hex2 = new StringBuilder();
for (int i = 0; i < 8; i++) hex2.append(String.format("%02x", sha2[i]));
String laneKey = "tripod-desk:" + hex2 + ":" + lane + ":a1";
String started = TripodDesk.call("run", body, Map.of("Idempotency-Key", laneKey));
System.out.println(lane + " started: " + started);
// ...then poll jobs/{job_id} as in step 4, and assert that the parsed
// report's "lane" equals this lane before reading its "body".
}
# One report, four lanes, in pipeline order. Only "task" changes.
%w[cohort validate attrib report].each do |lane|
body = INPUT.merge("task" => lane)
est = call("estimate", body) # free, and the lanes are not priced alike
puts format("%-9s hold %s min %s", lane, est["hold_credits"], est["min_credits"])
# A separate key per lane: the lane is part of the body.
d = Digest::SHA256.hexdigest(JSON.generate(body))[0, 16]
started = call("run", body, { "Idempotency-Key" => "tripod-desk:#{d}:#{lane}:a1" })
job = nil
loop do
job = call("jobs/#{started['job_id']}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
next warn("#{lane} failed") if job["status"] == "failed"
raw = job["output"]["output"]
rep = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
raise "lane #{rep['lane']} != #{lane}" unless rep["lane"] == lane
puts format("%-9s %-16s %s", rep["lane"], rep["verdict"], rep["headline"][0, 70])
break if lane == "cohort" && rep["verdict"] == "not_supported"
end
<?php
// One report, four lanes, in pipeline order. Only "task" changes.
foreach (["cohort", "validate", "attrib", "report"] as $lane) {
$body = array_merge($INPUT, ["task" => $lane]);
$est = call("estimate", $body); // free, and priced per lane
printf("%-9s hold %d min %d\n", $lane, $est["hold_credits"], $est["min_credits"]);
// A separate key per lane: the lane is part of the body.
$d = substr(hash("sha256", json_encode($body)), 0, 16);
$started = call("run", $body, ["Idempotency-Key" => "tripod-desk:$d:$lane:a1"]);
do {
sleep(2);
$job = call("jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") { fwrite(STDERR, "$lane failed\n"); continue; }
$raw = $job["output"]["output"];
$rep = json_decode(substr($raw, strpos($raw, "{"),
strrpos($raw, "}") - strpos($raw, "{") + 1), true);
if ($rep["lane"] !== $lane) throw new RuntimeException("lane mismatch");
printf("%-9s %-16s %s\n", $rep["lane"], $rep["verdict"],
substr($rep["headline"], 0, 70));
if ($lane === "cohort" && $rep["verdict"] === "not_supported") break;
}
// One report, four lanes, in pipeline order. Only the task value changes.
foreach (var lane in new[] { "cohort", "validate", "attrib", "report" })
{
var body = new
{
task = lane,
report,
metrics,
stage = "internal",
audience = "journal",
context = "Reviewer 2 asked only for a calibration plot.",
};
var e = await TripodDesk.Call("estimate", body); // free, priced per lane
Console.WriteLine($"{lane,-9} hold {e.GetProperty("hold_credits").GetInt32()}");
// A separate key per lane: the lane is part of the body.
var d = Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body)))).ToLowerInvariant()[..16];
var started2 = await TripodDesk.Call("run", body,
new Dictionary<string, string> { ["Idempotency-Key"] = $"tripod-desk:{d}:{lane}:a1" });
Console.WriteLine($"{lane} started as {started2.GetProperty("job_id").GetString()}");
// ...then poll jobs/{id} as in step 4, and assert report.lane == lane before
// reading report.body, whose keys differ per lane.
}
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.