← Cyto Desk / API
Tokens

Drive Cyto Desk from your own code

Everything the web page does is available over HTTP: post one flow cytometry acquisition's keyword block and get back either an audit of whether the file can be gated at all, a review of the spillover matrix and the marker-to-detector map, or the instrument-and-acquisition part of a minimum-information methods section. The natural uses are a nightly job that walks a core facility's output directory and flags every file whose event count cannot support the assay it was booked for, and a pre-analysis gate in a pipeline that refuses to process an acquisition whose audit comes back not-analysable.

The browser and the API send the same input object. The one thing the browser does that you do not have to reproduce is the free local prescan — see prescan, honestly below.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

There is no app-slug header. The only header the API needs is Authorization: Bearer <token> — the token is minted per app and carries the app identity itself, so every call below is authenticated by the token alone. The one place the slug appears is the body of POST /guest, which is how you get a token in the first place. Errors you will actually meet:

statuscodewhat it means, and what to do
401unauthorizedNo token, or a token from another app. Tokens are per app; mint one at /tokens.html.
402payment_requiredBalance below min_credits. Call /estimate first — it is free — and compare against /me.
403forbiddenA guest token tried to run a metered app. Use a personal token.
422invalid_inputThe input object is not an object, or task is not a string. Note the shape gotcha below.
429rate_limitedBack off and retry. Never tight-loop.
500upstream_errorRetry once with the same Idempotency-Key so you are not billed twice.

Shape gotcha. The request body is the input object. Do not wrap it in an input key: a body of {"input": {...}} returns 200 and quietly hides your task field from the model, so you get a lane you did not ask for.

Start with task: it picks the lane

One app, one model, one system prompt, three lanes. task routes, and it is the first field to get right — the three lanes answer genuinely different questions about the same file, and their output bodies do not overlap.

taskthe questionbody keys in the replyposture values
acq Can this file be gated at all, and what will the gating be blind to? findings[], channels[] analysable · analysable-with-caveats · not-analysable
panel Does this panel measure what it claims, and where will compensation eat the answer? pairs[], markers[], controls[] panel-sound · panel-workable · panel-redesign
report What can the methods section honestly say? sections[], checklist[] report-ready · report-gaps · report-blocked

Every lane also returns the same envelope: task, title, posture, confidence (high/medium/low), verdict, exec_summary, assumptions[], open_questions[], coverage_check[], artifacts[], next_steps[] and summary. Because the envelope is identical across lanes, one deserializer handles all three and only the inner body is per-lane.

If task is missing or unrecognised the model picks the closest lane, sets task to what it chose, and says so in exec_summary rather than blending two contracts. Do not rely on that — send the field.

The input object

Exactly what app.js submits, field for field:

fieldtypewhat it is
taskstring, requiredacq, panel or report.
keywordsstringThe FCS keyword block, one $KEY value per line. This is the primary input and everything else is optional.
panelstringThe panel table as CSV: marker,fluorochrome,detector,laser,expression. Optional, and only the panel lane leans on it hard.
spilloverstringThe compensation matrix as a labelled CSV grid. Optional — if the keyword block carries $SPILLOVER you can leave this empty.
intentstringOne of gate, publish, qc, inherit, troubleshoot. Changes what the answer prioritises.
populationsstringWhat you need to measure and roughly how rare. This is what turns the event count into a real power statement — the single highest-value optional field.
contextstringFree text. What the sample is, what changed, what went wrong.
prescanobjectThe deterministic facts. See below.
{
  "task": "acq",
  "keywords": "$FCSVERSION FCS3.1\n$PAR 11\n$TOT 250000\n$CYT LSRFortessa X-20\n$BTIM 14:02:11\n$ETIM 14:09:44\n$P4N FITC-A\n$P4S CD3\n$P4E 0,0\n$P4R 262144\n$P4V 412",
  "panel": "marker,fluorochrome,detector,laser,expression\nCD3,FITC,FITC-A,488,high",
  "spillover": ",FITC-A,PE-A\nFITC-A,1.0000,0.1812\nPE-A,0.0121,1.0000",
  "intent": "gate",
  "populations": "Treg frequency within CD4+, expected around 5% of CD4.",
  "context": "Fresh PBMC, monthly panel.",
  "prescan": { "...": "see below" }
}

Prescan, honestly

The web page computes a prescan object in the browser before it calls the model, and passes it in as facts. It carries the parsed channel table, the acquisition statistics, the inverted spillover matrix with its infinity-norm condition number, the per-detector spreading magnitudes, the resolved panel, and a flags[] array of deterministic findings each with an id.

The prompt requires one coverage_check entry per flag id, so the run has to confirm, set aside with a reason, or contradict every single one. That is the whole accountability mechanism and it is worth reproducing.

You can omit prescan entirely. The run still works and still returns the full envelope — coverage_check just comes back empty, because there is nothing to reconcile. What you lose is the grounding: no condition number for the model to reason from, no event-rate arithmetic, and no way for you to check afterwards that a quoted spillover percentage is the one in your matrix. If you are building a pipeline, the honest options are to send a real prescan you computed yourself, or to accept that the answer is ungrounded and say so downstream. Do not send a fabricated one.

The exact structure is the return value of prescanPayload() in app.js, and the parser that produces it is fcsscan.js — both served from this origin, both readable, both free of any dependency.

Step 1 — get a token

A guest token browses; it cannot run a metered app. Mint a personal token at /tokens.html and keep it out of your source — the samples below read a placeholder you should replace with a value from your own secret store.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" -d '{"slug":"cyto-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest","credits":0}}

# A guest token cannot run a metered app. For real work use a personal token from
# https://cyto-desk.skillsafe.ai/tokens.html and export it:
export SKILLSAFE_TOKEN="YOUR_TOKEN"

Step 2 — check the session with /me

/me tells you whether the token is personal or guest and what the balance is. Check it before you spend anything: a 402 after submit is a failure of the caller, not of the user.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":184320,"app":{"slug":"cyto-desk"}}}

Step 3 — price it with /estimate

/estimate is free, creates no job and bills nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Two things to know:

If your balance sits between min_credits and hold_credits the run still executes with a reduced output cap and the job comes back with "truncated": true. Render what parsed and say the answer was cut short; do not present a clipped answer as complete.

# Free. No job is created and nothing is billed.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d @input.json
# -> {"ok":true,"data":{
#      "model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#      "hold_credits":1861,"min_credits":240,"sponsor_enabled":false }}

Step 4 — run it with /run and poll

Send an Idempotency-Key, and put the lane in it. Three lanes over the same acquisition are three distinct runs and must not collide on one key. Hash (task, input, attempt). If you retry — a dropped connection, a malformed reply — reuse the same key, or a network blip bills you twice.

# Metered. This one bills.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cyto-desk:acq:$(shasum -a 256 input.json | cut -c1-16):a1" \
  -d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"]')

until [ "$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
    | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')" != "running" ]; do
  sleep 2
done

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'

Step 5 — or stream it with /run-stream

Server-sent events, three event types: delta carrying a text field, job carrying the job_id, and done carrying status, charged_credits and truncated. Concatenate every delta's text and parse the result as the JSON answer.

The web page streams for a reason worth copying: it advances a staged progress card on real signals — the section keys ("findings", "channels", "coverage_check", "artifacts") arriving in the delta stream — rather than on a character counter. And if the stream dies mid-answer it appends closing brackets and renders whatever parsed, so a billed run is never thrown away.

curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Accept: text/event-stream" \
  -H "Idempotency-Key: cyto-desk:acq:$(shasum -a 256 input.json | cut -c1-16):a1" \
  -d @input.json
# event: delta   data: {"text":"{\"task\":\"acq\""}
# event: job     data: {"job_id":"job_..."}
# event: done    data: {"status":"succeeded","charged_credits":1204}

The output contract, per lane

Taken from the parsing code in app.js (parseResult and normalize), not from intent. The reply is one JSON object; the app strips code fences and takes the outermost braces, so a stray fence will not break you, but the model is instructed not to emit one.

Every lane: the envelope

{
  "task": "acq" | "panel" | "report",
  "title": "string",
  "posture": one of this lane's three values,
  "confidence": "high" | "medium" | "low",
  "verdict": "one actionable sentence",
  "exec_summary": "2-4 sentences",
  "assumptions": ["string", ...],
  "open_questions": ["string", ...],
  "coverage_check": [ { "id": "FCS-PAR-MISMATCH",
                        "status": "confirmed" | "set-aside" | "contradicted",
                        "note": "why" }, ... ],
  "artifacts": [ { "name": "acquisition-audit.md",
                   "language": "markdown" | "csv" | "text",
                   "content": "the whole file" }, ... ],
  "next_steps": ["string", ...],
  "summary": "one paragraph of prose"
}

task: "acq"

"findings": [
  {
    "id": "A-001",
    "severity": "critical" | "high" | "medium" | "low",
    "area": "structure" | "scale" | "acquisition" | "power" | "reproducibility" | "reporting",
    "target": "$PAR=9",                the keyword or detector, verbatim from the input
    "title": "under ten words",
    "evidence": "the exact values that establish it",
    "impact": "what a reader of the figure would get wrong",
    "remedy": "re-acquire, re-export, document, or accept with a stated limit",
    "blocks_analysis": true,           true only if no gating strategy works around it
    "keywords_cited": ["$PAR", "$P6N"]
  }
],
"channels": [
  {
    "index": 4,
    "name": "FITC-A",                  the $PnN short name, or ""
    "marker": "CD3",                   the $PnS value, or ""
    "role": "scatter" | "fluorescence" | "viability" | "time" | "index",
    "grade": "ok" | "watch" | "unusable",
    "issue": "",                       empty when the grade is ok
    "note": "what this channel can and cannot be used for"
  }
]

channels covers every parameter in the file, in index order, including scatter and time. A channel with nothing wrong is graded ok with an empty issue — an omitted channel would be indistinguishable from one the answer never read. Artifacts: acquisition-audit.md and channels.csv.

task: "panel"

"pairs": [
  {
    "id": "S-001",
    "from_channel": "APC-A",           the donor detector, exactly as named in the matrix
    "to_channel": "APC-Cy7-A",
    "spillover_pct": 42.16,            a PERCENT NUMBER, not a fraction
    "severity": "critical" | "high" | "medium" | "low",
    "consequence": "which marker loses resolution, and roughly how much",
    "mitigation": "the control or reassignment that recovers it"
  }
],
"markers": [
  {
    "marker": "CD127",
    "fluorochrome": "APC-Cy7",         "" when only the detector is known
    "detector": "APC-Cy7-A",           the resolved $PnN name, "" if it did not resolve
    "verdict": "well-placed" | "acceptable" | "move" | "unresolvable",
    "note": "the brightness-versus-density reasoning"
  }
],
"controls": [
  {
    "name": "single-stain APC-Cy7",
    "required_for": "APC-A into APC-Cy7-A",
    "status": "required" | "present" | "missing" | "unknown",
    "note": "what it settles and what happens without it"
  }
]

spillover_pct is checked by the page against your matrix, to the larger of 0.06 points and 5% relative. A number that does not match is contradicted on screen rather than displayed as fact — worth reproducing in your own consumer. Artifacts: panel-review.md and spillover-actions.csv.

task: "report"

"sections": [
  {
    "id": "R-001",
    "heading": "Instrument and configuration",
    "body": "Prose, full sentences, publication register. Anything the file does not
             state appears as [not recorded in the file: what is needed] rather than
             as a plausible invention."
  }
],
"checklist": [
  {
    "item": "Cytometer make and model",
    "status": "stated" | "partial" | "missing" | "not-applicable",
    "note": "where it came from, or what has to be supplied and from where"
  }
]

Headings arrive in a fixed order: Instrument and configuration, Sample and staining, Data acquisition, Compensation, Data processing and analysis, Limitations. An item marked stated whose keyword is absent from the file is contradicted by the page's grounding audit. Artifacts: methods.md and miflowcyt-checklist.csv.

Worked example per lane

The same acquisition, three task values, three genuinely different answers. These are abridged — the real replies carry the full coverage_check and both artifacts.

acq on a competent 7-colour panel

{
  "task": "acq",
  "posture": "analysable",
  "confidence": "high",
  "verdict": "The file is structurally sound and 250,000 events comfortably supports a 0.3% subset;
              nothing in the acquisition blocks gating.",
  "findings": [
    { "id": "A-001", "severity": "low", "area": "reproducibility",
      "target": "$TOT / $VOL",
      "title": "Concentration is a derived fact, not a defect",
      "evidence": "$TOT 250,000 over $VOL 210,000 nL implies about 1,190 recorded events per uL.",
      "impact": "Nothing, on its own - but it is the number to compare against the intended
                 staining concentration, because antibody titre is per cell rather than per tube.",
      "remedy": "Record the cell count at stain, then this becomes checkable.",
      "blocks_analysis": false, "keywords_cited": ["$TOT", "$VOL"] }
  ],
  "channels": [
    { "index": 1, "name": "FSC-A", "marker": "", "role": "scatter", "grade": "ok",
      "issue": "", "note": "Forward-scatter area; with FSC-H present a doublet gate is available." }
  ]
}

panel on the same file

{
  "task": "panel",
  "posture": "panel-workable",
  "confidence": "high",
  "verdict": "The matrix inverts cleanly, but CD127 sits on the one detector that receives 42.16%
              from APC-A and 24.47% from PE-Cy7-A, which is why the CD127-low boundary cannot be
              defended.",
  "pairs": [
    { "id": "S-001", "from_channel": "APC-A", "to_channel": "APC-Cy7-A",
      "spillover_pct": 42.16, "severity": "high",
      "consequence": "CD127 is on APC-Cy7-A. After subtracting 42.16% of a bright CD25 signal, the
                      residual negative population widens enough that CD127-low and CD127-negative
                      are not separable by an unstained reference.",
      "mitigation": "A single-stain APC control on cells rather than beads, and a CD127 gate set
                     from a stained internal negative." }
  ],
  "markers": [
    { "marker": "CD127", "fluorochrome": "APC-Cy7", "detector": "APC-Cy7-A", "verdict": "move",
      "note": "A low-density antigen on a dim tandem, on the detector with the heaviest spillover
               column in the panel (combined RSS 0.487) - all three terms of the stain index work
               against it." }
  ]
}

report on the same file

{
  "task": "report",
  "posture": "report-gaps",
  "confidence": "high",
  "verdict": "The instrument and acquisition can be reported in full from the file; the staining
              protocol and the compensation control set cannot and must come from the notebook.",
  "sections": [
    { "id": "R-001", "heading": "Instrument and configuration",
      "body": "Samples were acquired on a BD LSRFortessa X-20 (serial H647832) configured with
               488 nm, 640 nm and 405 nm excitation lines. Detector voltages were recorded per
               parameter and ranged from 388 to 590 V. Data were collected with BD FACSDiva
               9.0.1 in list mode as 32-bit floating-point values on a linear scale." }
  ],
  "checklist": [
    { "item": "Cytometer make and model", "status": "stated",
      "note": "$CYT reads LSRFortessa X-20." },
    { "item": "Compensation method and controls", "status": "missing",
      "note": "A 7x7 spillover matrix is embedded, but nothing in the file records whether the
               single-stain controls were beads or cells, or when they were acquired." }
  ]
}

Rate limits, cost and good manners