← scRNA Desk / API
Tokens

Drive scRNA Desk from your own code

Everything the web page does is available over HTTP: post one per-cell QC metrics table, name a lane in task, and get the same structured worksheet back as one JSON object. The natural uses are the ones a browser tab is bad at — re-running the QC read over every new library as it comes off the sequencer, holding a nightly check that no new sample has drifted, or wiring the read into the step where a metrics export is first written, so the thresholds get argued about before anyone filters anything.

Say the important thing first: scRNA Desk has your metrics, not your genes. It never sees an expression matrix, it cannot tell you a cell type is present or rare, and it is not a diagnostic tool. It documents the consequences of the thresholds you send on the table you send, and names what contradicts itself. If a required column is missing, the reply says so rather than inventing a distribution to fill the hole. Build on that boundary rather than against 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": "VALIDATION_ERROR", "message": "human-readable", "details": { ... }}}

The slug identifies the app. It travels in the POST /guest body when you mint a token, and after that the token itself carries it — there is no per-request app header to set.

Error codes

codeHTTPwhat it meanswhat to do
UNAUTHORIZED401No token, a malformed token, or one issued for another app.Mint a fresh token. A guest token is enough for /me and /estimate.
PAYMENT_REQUIRED402The balance cannot cover this run's minimum.Top up. /estimate is free, so check it before every run and you will not meet this.
VALIDATION_ERROR400The input object is not shaped as the app expects.Read error.details. Usually a missing metrics string or a task that is not a lane id.
NOT_FOUND404Unknown job id, or an endpoint this app does not expose.Check the id you are polling came from this app's /run.
RATE_LIMITED429Too many requests from this token or IP.Back off and retry. Never tight-loop a poll; a second between polls is plenty.
INTERNAL500The platform failed, not your call.Retry once with the same Idempotency-Key so a partially-charged run is not billed twice.

The input object

The run body is the input object itself — the fields below sit at the top level of the JSON you post, not nested under an input key.

1. task — the lane, and the first field to get right

task selects which worksheet you get. It is a plain string and there are exactly two values. Every other field is shared between them, because both lanes read the same work object: one pasted metrics table.

tasklanewhat comes backsource skill
qcQC readPer-sample verdicts, the threshold call, what the cut costs in cells, what could be lost. Seven named checks.@anthropics/single-cell-rna-qc
integrateIntegration setupThe scvi-tools model, the covariates, the training budget, the falsifiable post-integration checks. Seven named checks.@anthropics/scvi-tools

An absent or unrecognised task does not error: the model picks the closest lane and names the lane it produced in the reply's lane field. Read lane rather than assuming you got what you asked for.

2. Everything else

fieldtyperequiredmeaning
metricsstringyesThe per-cell QC metrics table, as text. adata.obs written out with to_csv, or a Seurat meta.data export. Comma, tab, pipe, semicolon and markdown tables all parse; # comment lines are ignored.
speciesstringnohuman (default), mouse or other. Decides the mitochondrial and ribosomal gene prefixes quoted back to you.
tissuestringnoFree text: tissue, preparation, chemistry. This is what lets the reply say whether a high mitochondrial fraction is expected.
modalitystringnorna (default), rna_cross_tech, citeseq, atac, multiome, spatial_ref. Routes the scvi-tools model.
labelsstringnonone (default), partial, full. Anything but none routes scVI to scANVI.
mad_counts, mad_genes, mad_mtstring or numbernoMAD multipliers. Defaults 5, 5, 3 — the scverse best-practice values.
mt_hardstring or numbernoHard mitochondrial percentage cutoff. Default 8.
min_genesstring or numbernoMinimum genes per cell. Default 100.
doublet_threshstring or numbernoDoublet-score cutoff. Default 0.2.
contextstringnoDesign, downstream goal, what has already been done to the matrix. Up to 4,000 characters.
handoffobjectno{from_lane, verdict, notes[]}. Present when this run follows the other lane in the same sitting.
engine_factsobjectno, but send itThe free in-browser read. See below — without it the reply has only the raw table to reason from, and the reconciliation the app is built around cannot happen.

3. engine_facts, and why you should compute it

The web page never asks the model for arithmetic. It computes the distributions, the MAD bounds, the per-sample retention, the condition-versus-sample cross-tabulation and a routed scvi-tools model in the browser, sends all of it as engine_facts, and then checks the reply against it: every flag must be addressed exactly once, and a flag the reply ignored is shown to the user as ignored.

From code you have two honest options. Send engine_facts yourself — the shape is documented by the factsFor function in /cellscan.js, which is plain, dependency-free JavaScript you can read or reuse — or omit it and accept that the reply is working from the table alone, with no ground truth to be held to. Do not send a hand-written engine_facts whose numbers you have not actually computed: the whole point of the field is that it is not the model's arithmetic, and a fabricated one is worse than none.

The output contract

One JSON object per run, the same envelope in both lanes. This is taken from the parsing code in /app.jsparseResult strips any code fence and takes the outermost {...}, then normalize coerces every field, so a missing optional field degrades rather than throwing.

{
  "lane": "qc",                       // "qc" | "integrate" - read this, do not assume
  "dataset_name": "string",
  "posture": "clear | caution | blocked",
  "verdict": "one sentence",
  "exec_summary": "one paragraph",
  "samples": [                        // one entry per engine sample ref, S1..Sn
    {
      "ref": "S1", "sample": "donor1",
      "headline": "string", "detail": "string",
      "severity": "none | low | medium | high | critical",
      // qc lane keys:        metrics_read, threshold_call, retention_note, watch_for[]
      // integrate lane keys: batch_role, model_note, covariates[], risk
    }
  ],
  "findings":   [{"id": "F-001", "severity": "critical|high|medium|low",
                  "title": "", "detail": "", "samples": ["S1"], "mitigation": ""}],
  "checks":     [{"check": "the fixed name, verbatim", "status": "pass|fail|partial|unknown",
                  "evidence": "", "requirement": ""}],
  "steps":      [{"id": "P-001", "stage": "inspect|correct|filter|model|verify",
                  "action": "", "rationale": "", "evidence": ""}],
  "parameters": [{"name": "", "value": "", "unit": "", "rationale": ""}],
  "coverage_check": [{"key": "MT-HIGH:S3", "addressed": true, "note": ""}],
  "assumptions": ["..."], "open_questions": ["..."], "evidence_gaps": ["..."],
  "summary": "closing paragraph"
}

Two contract details that matter if you are parsing this yourself. The seven check names are fixed per lane and appear in a fixed order — the app compares them case-insensitively against its own list and reports any that are off-contract, so you can key on them. And coverage_check keys are the engine's flag keys (MT-HIGH:S3, BATCH-CONFOUND:stimulated, OVERFILTER), one entry per flag; a key that is not one of yours means the reply invented it.

The seven named checks, per lane

#task: "qc"task: "integrate"
1Required metric columns presentModel matches the modality and label situation
2Counts are raw integersRaw count layer named in setup_anndata
3Mitochondrial scale and cutoff suit the tissueBatch key names the technical unit
4MAD thresholds reproduce the engine retentionCondition is not corrected away
5Ambient RNA handled before filteringHighly variable gene selection is batch-aware
6Doublets handled by a doublet caller, not by MADTraining budget fits the cell count
7Rare populations survive the cutPost-integration checks are falsifiable

Step 1 — get a token

A guest token is free to mint and is enough for /me and /estimate. A metered run needs a personal token, which comes from signing in. The shortest path to the token this browser already holds, with a ready-made shell export, is the token page.

# Mint a guest token. Free, and enough for /me and /estimate.
# The reply carries {token, expires_at}; keep the token and send it as a bearer.
# For a personal token, sign in at https://scrna-desk.skillsafe.ai/tokens.html
# and press Copy shell export.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug": "scrna-desk"}'

Step 2 — GET /me

Confirms the token works, tells you whether you are a guest or a signed-in user, and gives the credit balance you should compare against /estimate before running anything.

# Who am I, and what is my balance?
# subject_type is "user" for a personal token, "guest" for a guest one.
# credits is in platform credits; 10,000 credits is $1.00.
curl -s "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"

Step 3 — POST /estimate (free)

Nothing is billed and no job is created. The reply carries model, model_alias, markup_bps, hold_credits and min_credits. hold_credits is a reservation sized for the full output cap, not the price — the actual charge is usually far lower. Estimate per lane: the two lanes have different prompt sections and different output caps, so their holds differ.

# Free: prices the run without creating a job or billing anything.
# Send the SAME object you would send to /run, including task -
# a different payload prices a different run.
# Assert model_alias == "gpt-terra" and markup_bps == 1000 if you
# want to be sure you are talking to the app you think you are.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "qc", "metrics": "barcode,sample,condition,n_genes_by_counts,total_counts,pct_counts_mt,doublet_score\ndonor1_AAACCCAAGCGT-1,donor1,control,2214,8931,4.82,0.041\ndonor1_AAACGCTTCAGC-1,donor1,control,2402,9518,3.97,0.052\ndonor2_AACCATGCAAGT-1,donor2,control,1908,7382,5.35,0.061\ndonor2_AAGGTTCCTCCA-1,donor2,control,187,301,9.14,0.048\ndonor3_ACAGCTAGGTCA-1,donor3,stimulated,1121,3090,11.21,0.058\ndonor3_ACGATGTCAGGT-1,donor3,stimulated,1043,2874,12.60,0.437", "species": "human", "tissue": "PBMC, fresh, 10x 3' v3.1", "modality": "rna", "labels": "none", "mad_counts": "5", "mad_genes": "5", "mad_mt": "3", "mt_hard": "8", "min_genes": "100", "doublet_thresh": "0.2", "context": "Three-donor pilot; donor3 is the stimulated arm and ran on a different day."}'

Step 4 — POST /run, then poll

/run returns a job_id immediately. Poll GET /jobs/{job_id} until status is terminal (succeeded or failed); the worksheet text is at data.output.output. Send an Idempotency-Key header on every run: it is what stops a network blip from billing the same worksheet twice. Derive it from the lane plus a hash of the input, so two lanes over one table are two distinct keys.

# Metered. Needs a personal token.
# Send an Idempotency-Key header derived from (task, input, attempt):
#   Idempotency-Key: scrna-desk:qc:<hash-of-input>:a1
# The reply is {job_id}; poll GET /jobs/{job_id} about once a second
# until status is succeeded or failed, then read data.output.output.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "qc", "metrics": "barcode,sample,condition,n_genes_by_counts,total_counts,pct_counts_mt,doublet_score\ndonor1_AAACCCAAGCGT-1,donor1,control,2214,8931,4.82,0.041\ndonor1_AAACGCTTCAGC-1,donor1,control,2402,9518,3.97,0.052\ndonor2_AACCATGCAAGT-1,donor2,control,1908,7382,5.35,0.061\ndonor2_AAGGTTCCTCCA-1,donor2,control,187,301,9.14,0.048\ndonor3_ACAGCTAGGTCA-1,donor3,stimulated,1121,3090,11.21,0.058\ndonor3_ACGATGTCAGGT-1,donor3,stimulated,1043,2874,12.60,0.437", "species": "human", "tissue": "PBMC, fresh, 10x 3' v3.1", "modality": "rna", "labels": "none", "mad_counts": "5", "mad_genes": "5", "mad_mt": "3", "mt_hard": "8", "min_genes": "100", "doublet_thresh": "0.2", "context": "Three-donor pilot; donor3 is the stimulated arm and ran on a different day."}'

Step 5 — POST /run-stream (SSE)

Same billing, same input, but the reply is a text/event-stream of deltas and the page can show progress. The web app uses this one. The six section keys arrive in a fixed order — verdict, samples, findings, checks, parameters, coverage_check — which is exactly how the progress card knows what stage it is at, and how a stream that dies early can still be parsed for the sections that arrived.

# Metered, streamed as SSE. Note task: "integrate" and the handoff object,
# which is how the integration lane runs on the QC read's conclusion.
# Accept: text/event-stream, and send the same Idempotency-Key discipline.
# Concatenate the delta payloads; the result is the JSON object above.
# If the stream dies, parse what arrived - the section order is fixed.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "integrate", "metrics": "barcode,sample,condition,n_genes_by_counts,total_counts,pct_counts_mt,doublet_score\ndonor1_AAACCCAAGCGT-1,donor1,control,2214,8931,4.82,0.041\ndonor1_AAACGCTTCAGC-1,donor1,control,2402,9518,3.97,0.052\ndonor2_AACCATGCAAGT-1,donor2,control,1908,7382,5.35,0.061\ndonor2_AAGGTTCCTCCA-1,donor2,control,187,301,9.14,0.048\ndonor3_ACAGCTAGGTCA-1,donor3,stimulated,1121,3090,11.21,0.058\ndonor3_ACGATGTCAGGT-1,donor3,stimulated,1043,2874,12.60,0.437", "species": "human", "tissue": "PBMC, fresh, 10x 3' v3.1", "modality": "rna", "labels": "none", "mad_counts": "5", "mad_genes": "5", "mad_mt": "3", "mt_hard": "8", "min_genes": "100", "doublet_thresh": "0.2", "context": "Three-donor pilot; donor3 is the stimulated arm and ran on a different day.", "handoff": {"from_lane": "qc", "verdict": "The default 8% mitochondrial cutoff removes donor3 entirely, and donor3 is the only sample carrying the stimulated condition.", "notes": ["mt_hard = 12 (donor3 median is 11.21%)", "critical: condition confounded with sample"]}}'

A worked example per lane, end to end

Both examples use the same seven-row table so the difference between them is only the lane. The table is deliberately small and deliberately awkward: donor2 carries an ambient-looking droplet at 301 UMIs, donor3 sits above the 8% mitochondrial cutoff, donor3 is the only sample with the stimulated condition, and one cell scores 0.437 on the doublet score. The full 180-cell version, with a saved reply for each lane, is on the app's front page behind Load the example metrics table — that path is free and needs no account.

task: "qc"

What comes back: three samples entries carrying metrics_read, threshold_call, retention_note and watch_for; the seven qc checks; steps with the ambient-correction step at stage: "correct" ahead of anything at stage: "filter"; and parameters naming the MAD multipliers, the hard cutoff, the minimum genes and the doublet cutoff it recommends.

# The qc lane, priced for free. Swap /estimate for /run to actually run it.
# Expect posture "caution" or "blocked" on this table: the 8% cutoff removes
# donor3 entirely, which is a decision rather than a default.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "qc", "metrics": "barcode,sample,condition,n_genes_by_counts,total_counts,pct_counts_mt,doublet_score\ndonor1_AAACCCAAGCGT-1,donor1,control,2214,8931,4.82,0.041\ndonor1_AAACGCTTCAGC-1,donor1,control,2402,9518,3.97,0.052\ndonor2_AACCATGCAAGT-1,donor2,control,1908,7382,5.35,0.061\ndonor2_AAGGTTCCTCCA-1,donor2,control,187,301,9.14,0.048\ndonor3_ACAGCTAGGTCA-1,donor3,stimulated,1121,3090,11.21,0.058\ndonor3_ACGATGTCAGGT-1,donor3,stimulated,1043,2874,12.60,0.437", "species": "human", "tissue": "PBMC, fresh, 10x 3' v3.1", "modality": "rna", "labels": "none", "mad_counts": "5", "mad_genes": "5", "mad_mt": "3", "mt_hard": "8", "min_genes": "100", "doublet_thresh": "0.2", "context": "Three-donor pilot; donor3 is the stimulated arm and ran on a different day."}'

task: "integrate"

What comes back: three samples entries carrying batch_role, model_note, covariates and risk; the seven integrate checks; and parameters naming the model class, the counts layer, the batch key, the highly-variable-gene count, max_epochs and each covariate. On this table the reply has to say plainly that no integration method separates stimulated from donor3 — that is a design fact, not a parameter.

# The integrate lane, priced for free, carrying the qc lane's verdict
# in handoff - which is exactly what the app's handoff button sends.
# Swap /estimate for /run-stream to run it with progress.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "integrate", "metrics": "barcode,sample,condition,n_genes_by_counts,total_counts,pct_counts_mt,doublet_score\ndonor1_AAACCCAAGCGT-1,donor1,control,2214,8931,4.82,0.041\ndonor1_AAACGCTTCAGC-1,donor1,control,2402,9518,3.97,0.052\ndonor2_AACCATGCAAGT-1,donor2,control,1908,7382,5.35,0.061\ndonor2_AAGGTTCCTCCA-1,donor2,control,187,301,9.14,0.048\ndonor3_ACAGCTAGGTCA-1,donor3,stimulated,1121,3090,11.21,0.058\ndonor3_ACGATGTCAGGT-1,donor3,stimulated,1043,2874,12.60,0.437", "species": "human", "tissue": "PBMC, fresh, 10x 3' v3.1", "modality": "rna", "labels": "none", "mad_counts": "5", "mad_genes": "5", "mad_mt": "3", "mt_hard": "8", "min_genes": "100", "doublet_thresh": "0.2", "context": "Three-donor pilot; donor3 is the stimulated arm and ran on a different day.", "handoff": {"from_lane": "qc", "verdict": "The default 8% mitochondrial cutoff removes donor3 entirely, and donor3 is the only sample carrying the stimulated condition.", "notes": ["mt_hard = 12 (donor3 median is 11.21%)", "critical: condition confounded with sample"]}}'

Rate limits, idempotency and good manners

What this app will not do