← Enrich Desk / API
Tokens

Drive Enrich Desk from your own code

Everything the web page does is available over HTTP. Send the facts the browser computes from one enrichment results table and get the same reading back: themes named in plain biology, what to set aside, one pitfall per flag, next steps, and a results and a methods paragraph. The natural use is the last step of an analysis pipeline. After gseapy or clusterProfiler writes its table, a script asks for the reading, files the methods paragraph with the run, and fails the job when the verdict is rerun.

One thing to be clear about before the first call: the model never runs statistics. The table is read, checked and grouped into themes by enrich.js, the same file the web page loads, and the result is sent as facts, a JSON string. The model's job is judgement over those facts. See building the facts 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": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"enrich-desk"} in its body), so no slug header is needed afterwards. Send your token as Authorization: Bearer … on every call.

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body returns a 200 with an unknown field 'input' warning, and the model never sees your facts.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. A reading is metered, so it needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://enrich-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; reading a table needs a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"enrich-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="enrich-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://enrich-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the reading (free)

The input object is exactly what the app's form submits. The first field is task. This app has one lane, so it is always interpret. A missing or unknown task is still answered as interpret, and the reply's lane field says so.

taskwhat it doesthe shape you get back
interpretReads the table facts: names each theme, sets aside what should not be reported, answers every flag, writes results and methods text.verdict (clear, qualified or rerun), headline, method_read, themes, set_aside, pitfalls, next_steps, results_text, methods_text, summary.
fieldtypewhat goes in it
taskstring, required"interpret"
factsstring, requiredThe JSON-encoded output of Enrich.buildFacts: format, method, cutoff, libraries, themes, significant terms, hub genes, flags, gene-list profile and what was clipped.
comparisonstringWhich samples, which contrast, and which list or ranking went in. It decides what "up" means, so send it.
organismstringhuman, mouse … or empty.
backgroundstringThe universe you tested against, in words.
questionstringWhat you want to know, in one or two sentences.
retry_notestringOnly when resending after an unparseable reply, or to ask for a shorter one.

The app declares an input schema with task and facts required and every field a string. So a correct call to /estimate or /run returns input_checked: true and an empty warnings array. Any warning means the body is wrong. Warnings never stop a run, so check them before you pay.

Building the facts

enrich.js is plain JavaScript with no dependencies and exports itself to node. Download it from this app and build the body with the same code the page runs:

// make-body.js - build the request body exactly as the web page does.
// Download https://enrich-desk.skillsafe.ai/enrich.js next to this file first.
const fs = require("fs");
const E = require("./enrich.js");

const table = E.readTable(fs.readFileSync(process.argv[2], "utf8"));   // results.tsv / .csv
if (!table.ok) throw new Error(table.notes.join(" "));
const profile = E.analyze(table, {
  cutoff: 0.05,
  organism: "human",
  background: "the 11,982 genes with non-zero counts",
  gene_list: fs.existsSync("genes.txt") ? fs.readFileSync("genes.txt", "utf8") : "",
});
const body = E.buildInput({
  profile, table,
  comparison: "Fibroblasts, IFN-beta 6 h vs mock, 212 genes up (padj < 0.05, log2FC > 1)",
  organism: "human",
  background: "the 11,982 genes with non-zero counts",
  question: "Is antigen presentation a separate signal from the antiviral response?",
});
process.stdout.write(JSON.stringify(body));   // {task, question, comparison, organism, background, facts}

The worked example, the interferon GO table the page ships as its first example, is this body (the facts string shown decoded and shortened):

{
  "task": "interpret",
  "question": "Which biological processes are induced, and is antigen presentation a separate signal from the antiviral response?",
  "comparison": "Primary human dermal fibroblasts, interferon-beta 6 h vs mock, 212 genes up (DESeq2 padj < 0.05, log2FC > 1)",
  "organism": "human",
  "background": "the 11,982 genes with non-zero counts in the DESeq2 run",
  "facts": "<the object below, JSON-encoded as ONE string>"
}
{
  "format": "clusterProfiler (enrichGO / enrichKEGG / enricher)",
  "format_id": "clusterprofiler-ora",
  "method": "ora",
  "cutoff": 0.05,
  "cutoff_basis": "adjusted",
  "padj_column": "p.adjust",
  "p_column": "pvalue",
  "rows_read": 14,
  "rows_skipped": 0,
  "sig_count": 13,
  "libraries": [
    {
      "name": "GO",
      "rows": 14,
      "sig": 13
    }
  ],
  "query_size": 212,
  "universe": 11982,
  "direction_counts": {
    "up": 0,
    "down": 0
  },
  "themes": [
    {
      "id": "T1",
      "lead": "R1",
      "lead_term": "defense response to virus",
      "direction": "n/a",
      "best_padj": 1.04e-22,
      "n_terms": 5,
      "member_ids": [
        "R1",
        "R2",
        "R6",
        "R7",
        "R12"
      ],
      "gene_count": 43,
      "top_genes": [
        "ISG15",
        "OASL",
        "ZC3HAV1",
        "APOBEC3G",
        "BST2",
        "EIF2AK2",
        "IFIT1",
        "IFITM1",
        "IFITM3",
        "MX1",
        "OAS1",
        "OAS3"
      ],
      "libraries": [
        "GO"
      ],
      "members": [
        {
          "id": "R1",
          "term": "defense response to virus",
          "library": "GO"
        },
        {
          "id": "R2",
          "term": "response to virus",
          "library": "GO"
        },
        {
          "id": "R6",
          "term": "negative regulation of viral genome replication",
          "library": "GO"
        },
        {
          "id": "R7",
          "term": "regulation of viral genome replication",
          "library": "GO"
        },
        {
          "id": "R12",
          "term": "regulation of type I interferon production",
          "library": "GO"
        }
      ]
    },
    {
      "id": "T2",
      "lead": "R3",
      "lead_term": "type I interferon-mediated signaling pathway",
      "direction": "n/a",
      "best_padj": 4.06e-20,
      "n_terms": 3,
      "member_ids": [
        "R3",
        "R4",
        "R5"
      ],
      "gene_count": 23,
      "top_genes": [
        "BST2",
        "IFI27",
        "IFI6",
        "IFIT1",
        "IFIT2",
        "IFIT3",
        "IFITM1",
        "IFITM2",
        "IFITM3",
        "IRF7",
        "IRF9",
        "ISG15"
      ],
      "libraries": [
        "GO"
      ],
      "members": [
        {
          "id": "R3",
          "term": "type I interferon-mediated signaling pathway",
          "library": "GO"
        },
        {
          "id": "R4",
          "term": "cellular response to type I interferon",
          "library": "GO"
        },
        {
          "id": "R5",
          "term": "response to type I interferon",
          "library": "GO"
        }
      ]
    },
    {
      "...": "3 more themes"
    }
  ],
  "themes_total": 5,
  "terms": [
    {
      "id": "R1",
      "term": "defense response to virus",
      "library": "GO",
      "theme": "T1",
      "term_id": "GO:0051607",
      "k": 38,
      "K": 231,
      "n": 212,
      "N": 11982,
      "p": 2.52e-26,
      "padj": 1.04e-22,
      "fold": 9.3,
      "genes": [
        "ISG15",
        "IFIT1",
        "IFIT3",
        "MX1",
        "OAS1",
        "OAS2",
        "OAS3",
        "OASL",
        "RSAD2",
        "IFI44L",
        "IFI6",
        "IFITM1",
        "IFITM3",
        "STAT1",
        "IRF7",
        "BST2",
        "HERC5",
        "CMPK2",
        "RIGI",
        "IFIH1"
      ],
      "genes_total": 38
    },
    {
      "id": "R2",
      "term": "response to virus",
      "library": "GO",
      "theme": "T1",
      "term_id": "GO:0009615",
      "k": 41,
      "K": 312,
      "n": 212,
      "N": 11982,
      "p": 1.82e-24,
      "padj": 3.75e-21,
      "fold": 7.43,
      "genes": [
        "ISG15",
        "IFIT1",
        "IFIT3",
        "MX1",
        "OAS1",
        "OAS2",
        "OAS3",
        "OASL",
        "RSAD2",
        "IFI44L",
        "IFI6",
        "IFITM1",
        "IFITM3",
        "STAT1",
        "IRF7",
        "BST2",
        "HERC5",
        "CMPK2",
        "RIGI",
        "IFIH1"
      ],
      "genes_total": 41
    },
    {
      "...": "11 more terms"
    }
  ],
  "near_misses": [],
  "hub_genes": [
    {
      "gene": "BST2",
      "terms": 8
    },
    {
      "gene": "IFITM1",
      "terms": 8
    },
    {
      "gene": "IFITM3",
      "terms": 8
    },
    {
      "gene": "ISG15",
      "terms": 8
    },
    {
      "gene": "MX1",
      "terms": 8
    },
    {
      "gene": "OASL",
      "terms": 8
    },
    {
      "gene": "IFIT1",
      "terms": 7
    },
    {
      "gene": "OAS1",
      "terms": 7
    },
    {
      "gene": "OAS3",
      "terms": 7
    },
    {
      "gene": "STAT1",
      "terms": 7
    },
    {
      "gene": "IFITM2",
      "terms": 6
    },
    {
      "gene": "IRF7",
      "terms": 6
    },
    {
      "gene": "IFI6",
      "terms": 5
    },
    {
      "gene": "IFIT2",
      "terms": 5
    },
    {
      "gene": "IFIT3",
      "terms": 5
    }
  ],
  "flags": [
    {
      "code": "redundancy",
      "severity": "low",
      "detail": "13 significant terms collapse into 5 themes by shared genes. Report one representative per theme.",
      "rows": []
    }
  ],
  "gene_list": {
    "size": 0,
    "namespace": "none",
    "case_style": "none",
    "ranked": false,
    "duplicates": 0
  },
  "checks": {
    "hyper": {
      "checked": 14,
      "mismatches": 0,
      "test": "one-sided hypergeometric"
    },
    "bh": {
      "checked": 14,
      "violations": 0,
      "kind": "bh"
    }
  },
  "clipped": {
    "sig_total": 13,
    "terms_sent": 13,
    "themes_sent": 5,
    "themes_total": 5,
    "rule": ""
  }
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# the node snippet above, or take the worked example from this page.
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The reply is the string at data.output.output. The terminal job also carries charged_credits (the real price) and the truncated flag.

Always send an Idempotency-Key. Derive it from the input as the web app does, with the lane and an attempt counter: enrich-desk:interpret:<hash>:a1. A retried request with the same key returns the same job instead of billing a second run. Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a changed body.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="enrich-desk:interpret:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"interpret\",\"verdict\":\"clear\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reading.json

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the reply, and the final done event carries status, charged_credits and truncated. A browser client may receive progress ticks rather than text deltas; the finished job from step 5 always has the whole reply.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"interpret\",\"verdict\":\"clear\","}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

data.output.output is a string holding one JSON object. The web app strips any code fence, takes everything from the first { to the last }, parses it and normalizes it: unknown enum values fall back (verdict to qualified, confidence to medium), theme ids are upper-cased, and a theme with no ids is dropped. Then it checks the reply against the facts it sent. You should do the same.

# The reply is a string inside the envelope; reading.json (step 5) holds it.
python3 - <<'EOF'
import json
t = open("reading.json").read().strip()
t = t[t.index("{"): t.rindex("}") + 1]           # drop any code fence
r = json.loads(t)
print(r["verdict"], "-", r["headline"])
for th in r["themes"]:
    print(" ", "+".join(th["theme_ids"]), th["label"], th["direction"], th["confidence"])
for p in r["pitfalls"]:
    print("  pitfall", p["severity"], p["code"])
EOF

Invariants worth asserting

The output contract

{
  "lane": "interpret",
  "verdict": "clear" | "qualified" | "rerun",
  "headline": "one sentence",
  "method_read": "2-4 sentences on the method and whether it fits the question",
  "themes": [
    {"theme_ids": ["T1"], "label": "type I interferon response", "direction": "up" | "down" | "mixed" | "n/a",
     "representative": "R3", "reading": "...", "key_genes": ["ISG15", "MX1", "OAS1"],
     "confidence": "high" | "medium" | "low", "caveat": ""}
  ],
  "set_aside": [{"theme_ids": ["T6"], "reason": "..."}],
  "pitfalls": [{"code": "fixed_background", "severity": "high" | "medium" | "low", "finding": "...", "fix": "..."}],
  "next_steps": ["..."],
  "results_text": "a results paragraph",
  "methods_text": "a methods paragraph with [bracketed placeholders] for what the table does not state",
  "summary": "2-4 sentences"
}

The flag codes

codeseveritymeaning
none_significanthighNo term passes the cutoff.
no_adjustedhighNo adjusted p or FDR column; significance rests on raw p.
padj_is_rawhighEvery adjusted value equals its raw p.
padj_below_phighAn adjusted value is smaller than its raw p.
padj_too_smallhighA BH value is below the BH floor of the rows pasted.
id_namespacehighThe gene list is Ensembl, Entrez or RefSeq IDs and the libraries are keyed by symbol.
genes_not_in_listhighOverlap genes in the table are missing from the pasted list.
list_smallhighAn ORA list under 10 genes.
rank_list_shorthighA GSEA ranked list under 1000 genes, probably thresholded.
p_mismatchmediumThe hypergeometric test does not reproduce a row's p from its counts.
multi_librarymediumRows from several libraries; FDR is within a library.
tiny_setsmediumSignificant terms from sets under 10 genes.
thin_overlapmediumSignificant ORA terms on fewer than 3 genes.
list_largemediumAn ORA list over 2000 genes.
case_mismatchmediumGene case does not match the organism.
fixed_backgroundmediumEnrichr's fixed background, no custom universe stated.
background_unstatedmediumORA with no universe in the table or the notes.
gsea_lenient_cutoffmediumGSEA terms that pass only above FDR 0.05.
count_mismatch, fold_mismatch, redundancy, huge_sets, gsea_zero_plowInternal inconsistencies, grouping notes, very large sets, permutation p of 0.

8. Use it in CI

The verdict is built to gate on. rerun means one of the structural flags stands and the table should not be interpreted. Fail the job, fix the run, and read again. qualified passes with caveats you should keep with the results.

#!/bin/sh
# Gate a pipeline on the reading: fail the job when the verdict is "rerun".
set -e
node make-body.js results.tsv > body.json          # the node snippet from step 4
INPUT=$(cat body.json)
KEY="enrich-desk:interpret:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
  OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
V=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];print(json.loads(t[t.index("{"):t.rindex("}")+1])["verdict"])')
echo "verdict: $V"
[ "$V" != rerun ]

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused. It executes with a reduced output cap and comes back with truncated: true. What you hold then is a prefix of the reply: the themes may be complete while results_text, methods_text and summary are missing. The web page shows the sections that arrived and says how many of the nine it recovered. From code, check the flag before you treat a reply as complete. Then resubmit with a retry_note asking for a shorter reply, and increment the attempt suffix on the Idempotency-Key.