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:
| status | code | what it means, and what to do |
|---|---|---|
| 401 | unauthorized | No token, or a token from another app. Tokens are per app; mint one at /tokens.html. |
| 402 | payment_required | Balance below min_credits. Call /estimate first — it is free — and compare against /me. |
| 403 | forbidden | A guest token tried to run a metered app. Use a personal token. |
| 422 | invalid_input | The input object is not an object, or task is not a string. Note the shape gotcha below. |
| 429 | rate_limited | Back off and retry. Never tight-loop. |
| 500 | upstream_error | Retry 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.
task | the question | body keys in the reply | posture 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:
| field | type | what it is |
|---|---|---|
task | string, required | acq, panel or report. |
keywords | string | The FCS keyword block, one $KEY value per line. This is the primary input and everything else is optional. |
panel | string | The panel table as CSV: marker,fluorochrome,detector,laser,expression. Optional, and only the panel lane leans on it hard. |
spillover | string | The compensation matrix as a labelled CSV grid. Optional — if the keyword block carries $SPILLOVER you can leave this empty. |
intent | string | One of gate, publish, qc, inherit, troubleshoot. Changes what the answer prioritises. |
populations | string | What 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. |
context | string | Free text. What the sample is, what changed, what went wrong. |
prescan | object | The 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"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "cyto-desk"
def call(path, token=None, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method="POST" if data else "GET")
if body is not None:
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=180) as r:
env = json.load(r)
if not env.get("ok"):
raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
return env["data"]
# A guest token browses; it cannot run a metered app.
# The slug goes in the BODY here - it is the only call that takes it.
guest = call("/guest", body={"slug": SLUG})["token"]
# For real work, paste a personal token from /tokens.html:
TOKEN = "YOUR_TOKEN"
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "cyto-desk";
async function call(path, token, body) {
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers: {
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(token ? { Authorization: "Bearer " + token } : {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
const guest = (await call("/guest", null, { slug: SLUG })).token;
const TOKEN = "YOUR_TOKEN"; // personal token from /tokens.html
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "cyto-desk"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
var client = &http.Client{Timeout: 3 * time.Minute}
func call(path, token string, body interface{}, out interface{}) error {
var rdr io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
if !env.OK {
return errors.New(env.Error.Code + ": " + env.Error.Message)
}
if out != nil {
return json.Unmarshal(env.Data, out)
}
return nil
}
// A guest token browses; it cannot run a metered app. The slug goes in the BODY
// of /guest - it is the only call that takes it, and there is no slug header.
//
// var g struct{ Token string `json:"token"` }
// if err := call("/guest", "", map[string]string{"slug": slug}, &g); err != nil {
// panic(err)
// }
//
// For real work use a personal token from /tokens.html:
// token := "YOUR_TOKEN"
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "cyto-desk";
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30)).build();
static String call(String path, String token, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofMinutes(3));
if (token != null) b.header("Authorization", "Bearer " + token);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // parse the {ok,data,error} envelope with your JSON library
}
// A guest token browses; it cannot run a metered app. The slug goes in the BODY
// of /guest - it is the only call that takes it, and there is no slug header:
// String guest = call("/guest", null, "{\"slug\":\"" + SLUG + "\"}");
//
// For real work use a personal token from /tokens.html:
// String token = "YOUR_TOKEN";
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "cyto-desk"
def call(path, token: nil, body: nil)
uri = URI(BASE.to_s + path)
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}" if token
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 180) do |h|
h.request(req)
end
env = JSON.parse(res.body)
raise "#{env['error']['code']}: #{env['error']['message']}" unless env["ok"]
env["data"]
end
guest = call("/guest", body: { "slug" => SLUG })["token"]
TOKEN = "YOUR_TOKEN" # personal token from /tokens.html
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "cyto-desk";
function call(string $path, ?string $token = null, $body = null) {
$headers = [];
if ($token !== null) { $headers[] = "Authorization: Bearer " . $token; }
if ($body !== null) { $headers[] = "Content-Type: application/json"; }
$opts = ["http" => [
"method" => $body === null ? "GET" : "POST",
"header" => implode("\r\n", $headers),
"content" => $body === null ? null : json_encode($body),
"timeout" => 180,
"ignore_errors" => true,
]];
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$env = json_decode($raw, true);
if (empty($env["ok"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
$guest = call("/guest", null, ["slug" => SLUG])["token"];
$token = "YOUR_TOKEN"; // personal token from /tokens.html
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "cyto-desk";
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
async Task<JsonElement> CallAsync(string path, string? token, object? body)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (token is not null)
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement.Clone();
if (!root.GetProperty("ok").GetBoolean())
{
var e = root.GetProperty("error");
throw new Exception($"{e.GetProperty("code").GetString()}: {e.GetProperty("message").GetString()}");
}
return root.GetProperty("data").Clone();
}
var guest = (await CallAsync("/guest", null, new { slug = Slug })).GetProperty("token").GetString();
var token = "YOUR_TOKEN"; // personal token from /tokens.html
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"}}}
me = call("/me", TOKEN)
print(me["subject_type"], me["credits"])
if me["subject_type"] != "user":
raise SystemExit("a metered app needs a personal token, not a guest one")
const me = await call("/me", TOKEN);
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") {
throw new Error("a metered app needs a personal token, not a guest one");
}
type me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
var m me
if err := call("/me", token, nil, &m); err != nil {
panic(err)
}
if m.SubjectType != "user" {
panic("a metered app needs a personal token, not a guest one")
}
String meJson = call("/me", token, null);
System.out.println(meJson);
// subject_type must be "user"; a guest token cannot run a metered app.
me = call("/me", token: TOKEN)
puts "#{me['subject_type']} #{me['credits']}"
raise "a metered app needs a personal token" unless me["subject_type"] == "user"
$me = call("/me", $token);
printf("%s %d\n", $me["subject_type"], $me["credits"]);
if ($me["subject_type"] !== "user") {
throw new RuntimeException("a metered app needs a personal token, not a guest one");
}
var me = await CallAsync("/me", token, null);
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {me.GetProperty("credits").GetInt64()}");
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:
hold_creditsis a reservation, not a price. It prices the full output cap. What you are actually charged —charged_creditson the job — is usually far lower.- Estimate the lane you are about to run. The three lanes have different prompt
sections and different output caps, so
acq's hold is notreport's hold. Re-estimate when you changetask.
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 }}
payload = {
"task": "acq", # acq | panel | report
"keywords": open("keywords.txt").read(),
"panel": open("panel.csv").read(), # optional
"spillover": "", # optional; the $SPILLOVER keyword covers it
"intent": "gate",
"populations": "Treg frequency within CD4+, expected around 5% of CD4.",
"context": "Fresh PBMC, monthly panel.",
}
est = call("/estimate", TOKEN, payload)
print(est["model"], est["model_alias"], est["hold_credits"], "reserved")
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")
const payload = {
task: "acq", // acq | panel | report
keywords: keywordBlock,
panel: panelCsv, // optional
spillover: "", // optional
intent: "gate",
populations: "Treg frequency within CD4+, expected around 5% of CD4.",
context: "Fresh PBMC, monthly panel."
};
const est = await call("/estimate", TOKEN, payload);
console.log(est.model, est.model_alias, est.hold_credits, "reserved");
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}
payload := map[string]interface{}{
"task": "acq", // acq | panel | report
"keywords": keywordBlock,
"panel": panelCSV,
"intent": "gate",
"populations": "Treg frequency within CD4+, expected around 5% of CD4.",
"context": "Fresh PBMC, monthly panel.",
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
if err := call("/estimate", token, payload, &est); err != nil {
panic(err)
}
if m.Credits < est.MinCredits {
panic("not enough credits")
}
String payload = """
{"task":"acq","keywords":"...","panel":"...","intent":"gate",
"populations":"Treg frequency within CD4+, expected around 5% of CD4.",
"context":"Fresh PBMC, monthly panel."}
""";
String est = call("/estimate", token, payload);
// assert model_alias == "gpt-terra" and markup_bps == 1000 before you spend anything
payload = {
"task" => "acq", # acq | panel | report
"keywords" => File.read("keywords.txt"),
"panel" => File.read("panel.csv"),
"intent" => "gate",
"populations" => "Treg frequency within CD4+, expected around 5% of CD4.",
"context" => "Fresh PBMC, monthly panel.",
}
est = call("/estimate", token: TOKEN, body: payload)
puts "#{est['model']} reserves #{est['hold_credits']}"
raise "not enough credits" if me["credits"] < est["min_credits"]
$payload = [
"task" => "acq", // acq | panel | report
"keywords" => file_get_contents("keywords.txt"),
"panel" => file_get_contents("panel.csv"),
"intent" => "gate",
"populations" => "Treg frequency within CD4+, expected around 5% of CD4.",
"context" => "Fresh PBMC, monthly panel.",
];
$est = call("/estimate", $token, $payload);
printf("%s reserves %d\n", $est["model"], $est["hold_credits"]);
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("not enough credits");
}
var payload = new {
task = "acq", // acq | panel | report
keywords = keywordBlock,
panel = panelCsv,
intent = "gate",
populations = "Treg frequency within CD4+, expected around 5% of CD4.",
context = "Fresh PBMC, monthly panel."
};
var est = await CallAsync("/estimate", token, payload);
Console.WriteLine($"{est.GetProperty("model").GetString()} reserves {est.GetProperty("hold_credits").GetInt64()}");
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"])'
import hashlib, time
# The idempotency key MUST include the lane: three lanes over the same paste are
# three distinct runs. Reuse the same key on a retry so a network blip cannot
# double-bill you.
sig = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16]
key = f"cyto-desk:{payload['task']}:{sig}:a1"
req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req, timeout=180) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"/jobs/{job_id}", TOKEN)
if job["status"] != "running":
break
time.sleep(2)
result = json.loads(job["output"]["output"])
print(result["posture"], result["verdict"])
for f in result["findings"]:
print(f["id"], f["severity"], f["title"])
import { createHash } from "node:crypto";
const sig = createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16);
const key = `cyto-desk:${payload.task}:${sig}:a1`;
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + TOKEN,
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const { data } = await res.json();
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call(`/jobs/${data.job_id}`, TOKEN);
} while (job.status === "running");
const result = JSON.parse(job.output.output);
console.log(result.posture, result.verdict);
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
b, _ := json.Marshal(payload)
sum := sha256.Sum256(b)
key := fmt.Sprintf("cyto-desk:%s:%s:a1", payload["task"], hex.EncodeToString(sum[:])[:16])
// Add the Idempotency-Key header on the /run request; call() above is the same
// otherwise. Then poll /jobs/{id} until status leaves "running" and parse
// data.output.output as the JSON answer.
// Add these two headers to the /run request:
// Idempotency-Key: cyto-desk:<task>:<sha256(payload)[0:16]>:a1
// Content-Type: application/json
// The key MUST include the lane, and a retry MUST reuse it, so a dropped
// connection cannot bill you twice.
String runJson = call("/run", token, payload); // -> {"data":{"job_id":"job_..."}}
// then poll /jobs/{job_id} until status != "running"
require "digest"
sig = Digest::SHA256.hexdigest(JSON.generate(payload))[0, 16]
key = "cyto-desk:#{payload['task']}:#{sig}:a1"
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req.body = JSON.generate(payload)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("/jobs/#{job_id}", token: TOKEN)
break (@job = job) if job["status"] != "running"
sleep 2
end
result = JSON.parse(@job["output"]["output"])
puts "#{result['posture']} #{result['verdict']}"
$sig = substr(hash("sha256", json_encode($payload)), 0, 16);
$key = "cyto-desk:{$payload['task']}:{$sig}:a1";
// Add "Idempotency-Key: $key" to the headers of the /run request, then poll:
$job = call("/run", $token, $payload);
do {
sleep(2);
$state = call("/jobs/" . $job["job_id"], $token);
} while ($state["status"] === "running");
$result = json_decode($state["output"]["output"], true);
printf("%s %s\n", $result["posture"], $result["verdict"]);
using System.Security.Cryptography;
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload);
var sig = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()[..16];
var key = $"cyto-desk:acq:{sig}:a1";
// Attach the Idempotency-Key header to the /run request, poll /jobs/{id} until
// status leaves "running", then parse data.output.output.
var run = await CallAsync("/run", token, payload);
var jobId = run.GetProperty("job_id").GetString();
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 web page streams so it can advance a staged progress card on real signals -
# the section keys arriving in the delta stream. The same events are available here.
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode(), method="POST")
for h, v in [("Content-Type", "application/json"),
("Accept", "text/event-stream"), ("Authorization", "Bearer " + TOKEN),
("Idempotency-Key", key)]:
req.add_header(h, v)
full, event = "", None
with urllib.request.urlopen(req, timeout=600) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
full += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print("charged", done.get("charged_credits"), "truncated", done.get("truncated"))
result = json.loads(full)
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: "Bearer " + TOKEN,
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", full = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
full += (JSON.parse(line.slice(6)).text || "");
}
}
}
const result = JSON.parse(full);
// POST to /run-stream with Accept: text/event-stream, then read the body line by
// line with a bufio.Scanner. Accumulate the "text" field of every delta event and
// parse the concatenation as the JSON answer once the done event arrives.
//
// The done event carries charged_credits and truncated. If truncated is true the
// output cap was reduced to fit the caller's balance: render what parsed rather
// than discarding it, and say the answer was cut short.
// Use HttpResponse.BodyHandlers.ofLines() and fold the delta events:
//
// HttpResponse<Stream<String>> res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
// res.body().forEach(line -> { /* event: / data: */ });
//
// Set Accept: text/event-stream and reuse the same Idempotency-Key you would have
// used on /run.
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 600) do |h|
req = Net::HTTP::Post.new(URI(BASE.to_s + "/run-stream"))
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req.body = JSON.generate(payload)
full = ""
event = nil
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
event = line[7..] if line.start_with?("event: ")
full += (JSON.parse(line[6..])["text"] || "") if line.start_with?("data: ") && event == "delta"
end
end
end
result = JSON.parse(full)
end
$opts = ["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Content-Type: application/json",
"Accept: text/event-stream",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . $key,
]),
"content" => json_encode($payload),
"timeout" => 600,
]];
$fh = fopen(BASE . "/run-stream", "r", false, stream_context_create($opts));
$full = ""; $event = null;
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\n");
if (str_starts_with($line, "event: ")) { $event = substr($line, 7); }
elseif (str_starts_with($line, "data: ") && $event === "delta") {
$full .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
fclose($fh);
$result = json_decode($full, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var full = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) is not null)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
using var d = JsonDocument.Parse(line[6..]);
if (d.RootElement.TryGetProperty("text", out var t)) full.Append(t.GetString());
}
}
var result = JsonDocument.Parse(full.ToString());
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
/estimate,/meand/guestare free./runand/run-streamare metered against the caller's wallet.- Model
gpt-terra(currentlygpt-5.6-terra) atmarkup_bps1000.price_creditsis 0, so you pay the model's cost plus the publisher's 10% and nothing else. - On 429, back off exponentially. On 500, retry once with the same
Idempotency-Key. - Do not poll
/jobs/{id}faster than every two seconds. - If you are processing a directory of files, run the free prescan logic yourself first and only
spend a run on the files whose flags actually warrant judgement.
fcsscan.jshas no dependencies and no network access; it is a single file you can lift.