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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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"}}
# Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered reading.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "enrich-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered reading.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "enrich-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered reading.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"enrich-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered reading.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"enrich-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered reading.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "enrich-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered reading.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "enrich-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://enrich-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered reading.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"enrich-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "enrich-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://enrich-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "enrich-desk";
const TOKEN = "YOUR_TOKEN"; // from https://enrich-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "enrich-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://enrich-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class EnrichDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "enrich-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.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());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "enrich-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://enrich-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{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) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "enrich-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class EnrichDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "enrich-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await EnrichDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
task | what it does | the shape you get back |
|---|---|---|
interpret | Reads 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. |
| field | type | what goes in it |
|---|---|---|
task | string, required | "interpret" |
facts | string, required | The JSON-encoded output of Enrich.buildFacts: format, method, cutoff, libraries, themes, significant terms, hub genes, flags, gene-list profile and what was clipped. |
comparison | string | Which samples, which contrast, and which list or ranking went in. It decides what "up" means, so send it. |
organism | string | human, mouse … or empty. |
background | string | The universe you tested against, in words. |
question | string | What you want to know, in one or two sentences. |
retry_note | string | Only 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.
INPUT = json.load(open("body.json")) # task, question, comparison, organism, background, facts
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation against the full output
# cap, not the price of the run.
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
_ = json.Unmarshal(raw, &input)
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
System.out.println(call("estimate", input));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":...,"min_credits":...,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
var est = await EnrichDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"enrich-desk:interpret:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reading = json.loads(job["output"]["output"])
print(reading["verdict"], [t["label"] for t in reading["themes"]])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `enrich-desk:interpret:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reading = JSON.parse(job.output.output);
console.log(reading.verdict, reading.themes.map((t) => t.label), job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("enrich-desk:interpret:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output, job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "enrich-desk:interpret:" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
require "digest"
key = "enrich-desk:interpret:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
reading = JSON.parse(job["output"]["output"])
puts reading["verdict"], reading["themes"].map { |t| t["label"] }.inspect
<?php
$key = "enrich-desk:interpret:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$reading = json_decode($job["output"]["output"], true);
echo $reading["verdict"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "enrich-desk:interpret:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await EnrichDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var reading = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(reading.GetProperty("verdict"));
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
console.log(done, raw.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
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
def parse(text):
t = text.strip()
t = t[t.index("{"): t.rindex("}") + 1]
r = json.loads(t)
assert r.get("lane", "interpret") == "interpret"
for k in ("themes", "set_aside", "pitfalls", "next_steps"):
r.setdefault(k, [])
return r
reading = parse(job["output"]["output"])
facts = json.loads(INPUT["facts"])
# The two invariants the web page checks first:
seen = [i for t in reading["themes"] + reading["set_aside"] for i in t["theme_ids"]]
assert sorted(seen) == sorted(t["id"] for t in facts["themes"]), "every theme exactly once"
need = {f["code"] for f in facts["flags"] if f["severity"] in ("high", "medium")}
assert need <= {p["code"] for p in reading["pitfalls"]}, "every high/medium flag answered"
function parse(text) {
const t = text.trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
for (const k of ["themes", "set_aside", "pitfalls", "next_steps"]) r[k] = Array.isArray(r[k]) ? r[k] : [];
return r;
}
const facts = JSON.parse(INPUT.facts);
const r = parse(job.output.output);
const seen = [...r.themes, ...r.set_aside].flatMap((t) => t.theme_ids).sort();
console.assert(JSON.stringify(seen) === JSON.stringify(facts.themes.map((t) => t.id).sort()), "every theme exactly once");
text := job.Output.Output
text = text[strings.Index(text, "{") : strings.LastIndex(text, "}")+1]
var reading struct {
Verdict string `json:"verdict"`
Themes []struct {
ThemeIDs []string `json:"theme_ids"`
Label string `json:"label"`
Direction string `json:"direction"`
Confidence string `json:"confidence"`
} `json:"themes"`
Pitfalls []struct {
Code string `json:"code"`
Severity string `json:"severity"`
} `json:"pitfalls"`
}
_ = json.Unmarshal([]byte(text), &reading)
fmt.Println(reading.Verdict, len(reading.Themes), "themes")
// With Jackson: ObjectMapper m = new ObjectMapper();
// JsonNode job = m.readTree(call("jobs/" + jobId, null)).get("data");
// String text = job.get("output").get("output").asText();
// JsonNode r = m.readTree(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
// r.get("verdict").asText(); r.get("themes"); r.get("pitfalls");
text = job["output"]["output"]
r = JSON.parse(text[text.index("{")..text.rindex("}")])
r["themes"].each { |t| puts "#{t['theme_ids'].join('+')} #{t['label']} #{t['confidence']}" }
<?php
$text = $job["output"]["output"];
$r = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
foreach ($r["themes"] as $t) echo implode("+", $t["theme_ids"]), " ", $t["label"], PHP_EOL;
var text = job.GetProperty("output").GetProperty("output").GetString()!;
var r = JsonSerializer.Deserialize<JsonElement>(text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)]);
foreach (var t in r.GetProperty("themes").EnumerateArray())
Console.WriteLine($"{t.GetProperty("label")} {t.GetProperty("confidence")}");
Invariants worth asserting
- Every id in
facts.themesappears exactly once acrossthemes[].theme_idsandset_aside[].theme_ids. - Each
representativeis in themember_idsof the themes it is listed with, and eachkey_genesentry is in those themes' genes. - For GSEA,
directionequals the theme's direction infacts; for ORA it isn/a. - Every
facts.flagsentry of severityhighormediumhas a pitfall with the samecode. - The verdict is
rerunwheneverpadj_is_raw,padj_below_p,padj_too_small,no_adjusted,id_namespace,genes_not_in_listorrank_list_shortis flagged. - Every p-value, NES and overlap in the prose is a value in
facts.terms.
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
| code | severity | meaning |
|---|---|---|
none_significant | high | No term passes the cutoff. |
no_adjusted | high | No adjusted p or FDR column; significance rests on raw p. |
padj_is_raw | high | Every adjusted value equals its raw p. |
padj_below_p | high | An adjusted value is smaller than its raw p. |
padj_too_small | high | A BH value is below the BH floor of the rows pasted. |
id_namespace | high | The gene list is Ensembl, Entrez or RefSeq IDs and the libraries are keyed by symbol. |
genes_not_in_list | high | Overlap genes in the table are missing from the pasted list. |
list_small | high | An ORA list under 10 genes. |
rank_list_short | high | A GSEA ranked list under 1000 genes, probably thresholded. |
p_mismatch | medium | The hypergeometric test does not reproduce a row's p from its counts. |
multi_library | medium | Rows from several libraries; FDR is within a library. |
tiny_sets | medium | Significant terms from sets under 10 genes. |
thin_overlap | medium | Significant ORA terms on fewer than 3 genes. |
list_large | medium | An ORA list over 2000 genes. |
case_mismatch | medium | Gene case does not match the organism. |
fixed_background | medium | Enrichr's fixed background, no custom universe stated. |
background_unstated | medium | ORA with no universe in the table or the notes. |
gsea_lenient_cutoff | medium | GSEA terms that pass only above FDR 0.05. |
count_mismatch, fold_mismatch, redundancy, huge_sets, gsea_zero_p | low | Internal 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 ]
verdict = reading["verdict"]
print("verdict:", verdict)
raise SystemExit(1 if verdict == "rerun" else 0)
console.log("verdict:", r.verdict);
process.exitCode = r.verdict === "rerun" ? 1 : 0;
if reading.Verdict == "rerun" {
os.Exit(1)
}
// if ("rerun".equals(r.get("verdict").asText())) System.exit(1);
exit(r["verdict"] == "rerun" ? 1 : 0)
<?php
exit($r["verdict"] === "rerun" ? 1 : 0);
return r.GetProperty("verdict").GetString() == "rerun" ? 1 : 0;
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.