← MedChem Desk / API
Tokens

Drive MedChem Desk from your own code

MedChem Desk takes a screening hit list — identifiers, SMILES and assay values — and returns one of four medicinal-chemistry reports over it. Everything below is the same HTTP surface the page itself uses, so anything the app can do, a script can do.

Base URL: https://api.skillsafe.ai/v1/app-api. App slug: medchem-desk. Every response is either {"ok":true,"data":{…}} or {"ok":false,"error":{"code":"…","message":"…"}}.

Two things that will cost you an afternoon if you skip them

  1. The run body is the input object itself. There is no input wrapper key. A wrapped body still returns 200 with a plausible-looking hold, because the platform accepts any JSON object — but the model then never sees task and reviews an empty hit list at full price. If you are unsure whether your payload is reaching the prompt, send it to /estimate twice: once complete, once with only {"task":"triage"}. The hold must drop materially. If the two holds are the same, your fields are not being priced and they are not being read.
  2. There is no app-slug header. The slug travels in the POST /guest token exchange and nowhere else; the request headers are Content-Type and Authorization only. A bogus slug header is ignored and still returns 200, so do not use one as a check that you are talking to the right app.

The lanes

task is the first field of the payload and the router of the whole prompt. Send exactly one of these:

taskstagewhat comes back in body
seriesinspectclusters, singletons, sar, property_landscape, outliers, data_quality
triagedecidecalls, ranking, rank_basis, kill_reasons, hold_unblockers, progress_set_note
admetverifyrisks, panel, insilico, sequence
designproduceproposals, design_rules, do_not, round_shape

The input fields

Taken from readForm() in app.js — this is what the page actually sends.

fieldtypemeaning
taskstring, requiredseries, triage, admet or design.
compoundsstring, requiredA tab-separated table. One leading # comment line saying how many compounds were sent out of how many were scanned, then a header line beginning row, then one line per compound. Columns: row id smiles formula mw hac rings ar_rings rotb hbd hba tpsa fsp3 charge potency pActivity LE logD selectivity solubility liabilities call_hint. The page builds this in the browser from the user's paste; a script may build it directly.
target_classstringkinase, gpcr, protease, ion-channel, nuclear-receptor, ppi, phenotypic, other.
stagestringhit-finding, hit-to-lead, lead-optimisation, candidate-selection.
routestringoral, cns-oral, iv, inhaled, topical.
modalitystringreversible or covalent. In a covalent programme a warhead is graded as an intended feature rather than a liability.
focus_idsstringWhitespace- or comma-separated compound identifiers. Confines compound-level output to those.
contextstringFree-text project context. This is what makes the report yours rather than a lecture.
carrystringConclusions from a previous lane, handed forward. The page fills it from a handoff button.
prescan_factsobject{counts, posture_hint, flags[], flag_count, sampling}. Each flag is {id, severity, label, compound_ids, detail}, and the model must reconcile every id exactly once in coverage_check. flag_count is the array's own length — never a separate tally.
retry_notestringOnly on a reformat retry. Tells the model its previous reply did not parse.

The output envelope

Identical across all four lanes; only body differs. This is the shape normalize() in app.js parses, so a field it requires is a field a client should require too.

{
  "lane": "triage",
  "report_name": "…",
  "series_name": "…",
  "posture": "advance | iterate | deprioritise",
  "verdict": "one sentence naming the deciding fact",
  "compound_count": 22,
  "exec_summary": "…",
  "assumptions": ["…"],
  "open_questions": ["…"],
  "findings": [{
    "id": "MD-001",
    "severity": "critical | high | medium | low | info",
    "family": "reactive | genotoxic | metabolic | interference | chelator | property | data",
    "title": "…", "compound_ids": ["KIN-003"],
    "evidence": "…", "why_it_matters": "…", "action": "…"
  }],
  "coverage_check": [{"id": "alert-genotoxic", "addressed": true, "note": "…"}],
  "body": { },
  "summary": "…"
}

Error codes

codeHTTPwhat to do
unauthorized401The token is missing, malformed or expired. Mint a new one (step 2).
forbidden403The token belongs to a different app. Tokens are per-slug.
payment_required402The balance cannot cover the hold. Call /estimate first and compare against /me; a 402 after submit is a client-side omission, not a user error.
validation_error400The body was not a JSON object, or a field was the wrong type. Check that you did not wrap the payload in an input key.
rate_limited429Back off and retry. Never tight-loop.
not_found404Wrong job id, or a collection the release does not declare.
internal500Retry once with the SAME idempotency key. A retry with a new key is a second billable run.

1. Get a token

The friendly route is /tokens.html: it reads the token this browser already holds for medchem-desk, shows whether it is a guest or a personal token, and copies it or a ready-made shell export. No developer console needed.

A guest token is enough for /me and /estimate — the whole pricing path is free. Running a lane is metered, so it needs a personal token, which comes from signing in.

2. Mint a guest token from code

This is the only call that names the app slug. Everything after it is bearer-token only.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"medchem-desk"}'

Answers 201 with {"token":"aut_…","guest_id":"…","expires_at":"…"} — note there is no subject_type in this response; that field comes from /me. Store the token: a fresh POST /guest mints a NEW subject, and any per-user record written under the old one stays with the old one.

There is no user_id and no is_guest field anywhere in this API. /me returns {"subject_type","subject_id","credits"} and subject_type"guest" or "user" — is the only correct thing to branch on.

3. Check the session and the balance

curl -s "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $TOKEN"

subject_type is guest or user; credits is the balance in credits (10,000 credits = $1.00). Compare it against the hold from step 4 before you run, and you will never see a 402.

4. Price the run for free

/estimate creates no job, spends nothing, and returns the model binding as well as the price. It is also the fastest way to confirm your payload is real: hold_credits must drop materially when you strip the hit list out.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"triage","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

Returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits, min_credits and sponsor_enabled. Show the hold as reserved, never as the price — the actual charge is usually far lower, because the hold prices the full output cap. Re-estimate on every lane switch: the four lanes have different output caps and therefore different holds.

5. Run a lane and poll the job

Send an Idempotency-Key header. A content hash of the payload plus the lane plus an attempt counter. Without one, a network blip on the response leg bills you twice for one review. With one, the retry is free.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: triage-9f2c1a-a1" \
  -d '{"task":"triage","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

Returns {"job_id":"job_…","status":"queued"}. Poll GET /jobs/{job_id} until status is succeeded, failed or cancelled. On success the report text is at data.output.output, the actual charge at data.charged_credits, and data.truncated is true when the balance forced a reduced output cap — in which case render what arrived and say it was cut short rather than presenting a clipped report as complete.

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_abc123" -H "Authorization: Bearer $TOKEN"

6. Or stream it

POST /run-stream is the same body and the same headers, with Accept: text/event-stream. Deltas arrive as SSE data: lines; the terminal event carries the job. This is what the page uses, which is why the progress card can advance on real section headings arriving rather than on a timer.

curl -sN -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: triage-9f2c1a-a1" \
  -d '{"task":"triage","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

7. One worked example per lane

The same three-compound table through all four lanes. Only task changes — and, on the later lanes, carry, which is how the page's handoff buttons pass one lane's conclusions into the next.

Read the series — task: "series"

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"series","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

body.clusters groups by scaffold; each cluster's best_id is one of its own compound_ids. body.sar carries a confidence of supported, suggestive or unsupported, and anything resting on fewer than three compounds comes back unsupported by contract — three points is where a trend starts, and the prompt refuses to pretend otherwise.

Call every compound — task: "triage"

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"triage","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

body.calls holds exactly one entry per compound row, each with call in progress / hold / kill, the deciding_liability, and agrees_with_prescan — a boolean the page verifies against the row's own call_hint column and flags when the claim is wrong. Disagreeing with the scanner is legitimate and often right; claiming agreement that is not there is a defect.

Profile and panel — task: "admet"

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"admet","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"From the Compound triage on the CDK12 anilinopyrimidine series: posture iterate. progress: KIN-004. hold: KIN-013. kill: KIN-003 (aromatic nitro).","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

body.panel is a work order, not a wish list: tier-1 is what runs on everything next week and tier-3 is the one-off on the compound you believe in. Every gate is falsifiable, with a number wherever a number is honest. body.insilico names the public ADME and toxicity collections by what they contain and always carries the applicability-domain caveat — a model trained on marketed drugs says very little about a covalent fragment.

Design the next round — task: "design"

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"design","compounds":"# 3 of 3 compounds. The whole list was sent.\nrow\tid\tsmiles\tformula\tmw\thac\trings\tar_rings\trotb\thbd\thba\ttpsa\tfsp3\tcharge\tpotency\tpActivity\tLE\tlogD\tselectivity\tsolubility\tliabilities\tcall_hint\n2\tKIN-004\tCN1CCN(c2ccc(Nc3nccc(-c4cccnc4)n3)cc2)CC1\tC20H22N6\t346.44\t26\t4\t3\t3\t1\t6\t56.4\t0.3\t0\t7.5 nM\t8.12\t0.428\t2\t63\t145uM\tnone\tprogress\n3\tKIN-003\tO=[N+]([O-])c1ccc(Nc2nccc(-c3cccnc3)n2)cc1CN1CCOCC1\tC20H20N6O3\t392.42\t29\t4\t3\t4\t1\t9\t102.19\t0.2\t0\t>10000 nM\t5\t\t3.1\t2\t15uM\taromatic-nitro:critical panel-lead:medium\tkill\n4\tKIN-013\tNc1ncnc2c1c(-c1ccccc1)nn2C\tC12H11N5\t225.25\t17\t3\t3\t1\t2\t5\t69.2\t0.083\t0\tmissing\t\t\t\t\t\taromatic-amine:high\thold","target_class":"kinase","stage":"hit-to-lead","route":"oral","modality":"reversible","focus_ids":"","context":"CDK12 programme, once-daily oral, we will not go past 450 daltons.","carry":"From the ADMET and panel lane: aromatic nitro on KIN-003 is a critical genotoxicity alert; KIN-013 has no potency reading; the series is flat with Fsp3 below 0.15.","prescan_facts":{"counts":{"rows":3,"parsed":3},"posture_hint":"iterate","flags":[{"id":"alert-genotoxic","severity":"critical","label":"2 compounds carry a genotoxic liability","compound_ids":["KIN-003","KIN-013"],"detail":"aromatic nitro group (1), unsubstituted aromatic amine (1)"},{"id":"no-potency","severity":"medium","label":"1 compound has no potency reading","compound_ids":["KIN-013"],"detail":"cannot be ranked on activity and must not be assumed inactive"}],"flag_count":2,"sampling":{"sent":3,"total":3,"dropped":0,"reason":""}}}'

body.proposals holds four to twelve entries, each naming a real parent_compound_id from the table and testing exactly one hypothesis — a compound that changes three things at once teaches nothing. body.do_not is the plausible-but-wrong move this dataset rules out, and it is the field most worth reading twice.

8. What the report contract will not let you skip

Rate limits and quotas

/estimate, /me and /guest are free. Data endpoints share 120 requests per minute per IP; vector similarity is tighter at 30 per minute. Records cap at 64 KB per document, which is why long reports are stored with their lane detail trimmed rather than refused.