Drive FBA Desk from your own code
A reading of the model, not of the organism. The model reads the flux balance analysis your browser (or your script) computed; it never recomputes a number, and it is never sent your model file. FBA says what this model allows at the optimum under these bounds, not what the cell does.
Everything the web page does is available over HTTP. Analyse your model with the page's own
fbakit.js (cobrapy 0.32 semantics, checked against cobrapy 0.32.1 with GLPK: FBA with
slim_optimize, pFBA, flux variability at a fraction of the optimum, blocked reactions,
single reaction and gene deletions with the 1% essentiality rule, dead-end metabolites, element and
charge balance, the optimum with every default bound raised tenfold, and the gain from opening each
limited uptake by one unit), send the facts, and get back a verdict (sound,
caveated, unreliable) and either a reading of every metric and the
predicted phenotype or a cobrapy script that reproduces every number and applies the fixes. The
natural loop: analyse, read, script, fix the bounds, re-check.
Two lanes: the task field
| task | what you get | extra input |
|---|---|---|
interpret | A reading of each metric (M1.., in order: objective value, pFBA total flux, active, variable and blocked reactions, dead ends, essential reactions and genes, the optimum at 10x the default bound, and one gain_ metric per limited uptake), the predicted phenotype (what the model takes up and secretes, the largest fluxes, the deletions it cannot survive), your claims judged against the facts, and what the analysis cannot show. | none |
script | The fixes (tightening or closing a named uptake, relaxing a bound that forces flux, stating the objective, loopless FVA, FVA at a lower fraction, removing blocked reactions, listing unbalanced reactions) and one complete Python script: MODEL_PATH = "model.json" loaded with cobra.io.load_json_model, an EXPECTED dict of every browser value checked with math.isclose, then the fixes, all inside main(). | decision: the text of an earlier interpret run (optional) |
Both lanes return the same envelope: lane, verdict, headline, tldr, the lane body, next_steps and prescan_responses. Worked requests: interpret, script, the other examples. The reply shape: output contract.
Input fields
Every field is a string.
| field | required | meaning |
|---|---|---|
task | yes | interpret or script. |
facts | yes | A JSON-encoded string holding the browser's analysis - see below. The page builds it with FbaKit.buildInput; an API caller builds it too. |
title | no | A label for the analysis, up to 160 characters. |
context | no | Your notes: the organism, the condition, what the objective stands for, what you want to conclude. Up to 3,000 characters. |
decision | script only | Plain text of an earlier interpret run (the page builds it with Recon.decisionText: "Verdict: ...", the headline, one line per metric reading and phenotype, then the next steps). Up to 5,000 characters. |
question | no | Answered in tldr as a bullet starting "Answer:". Up to 800 characters. |
retry_note | no | Only on a retry after a malformed reply. |
The facts string
facts is a JSON string, not an object: the browser solves your model,
serialises the result with JSON.stringify and sends that text. The model file itself is
never sent, so over the API you build the facts yourself. It holds:
settings (objective as a list of {id, coef},
direction, fraction_of_optimum, default_bound 1000,
tolerance, the essentiality rule, format, model_id, the
solver); model (counts of reactions, metabolites, genes, boundary and internal reactions,
compartments, formulas and rules given, lines_not_read); fba
(status optimal / infeasible / unbounded, objective_value);
pfba (total_flux); medium (the uptakes the bounds allow:
id, metabolite, bound, unlimited,
flux); exchanges (boundary reactions carrying flux in the pFBA state, with
direction uptake or secretion); top_fluxes (the largest internal pFBA
fluxes); fva (fraction_of_optimum, rows of id,
min, max, boundary, and rows_total);
blocked (count, ids); dead_ends;
knockouts (threshold, and for reactions and
genes: tested, essential,
reduced_not_essential, each {id, growth, status}, or not_run
with the reason); mass_balance (checked, imbalanced,
objective_reaction_imbalanced, not_checked_missing_formula);
metrics (M1.., each metric, value, sometimes
fraction or reaction and side, and basis);
flags (F1.. with severity high / medium / low,
category, message and refs); browser_verdict;
expected (the exact values a cobrapy reproduction must match) and
expected_count; and clipped.
An abbreviated but real facts object for the page's "E. coli core, aerobic glucose" example
(e_coli_core as cobrapy bundles it, glucose uptake capped at 10). Entries shown as
"..." are cut here for length; the page computes and sends the full object:
{
"settings": {
"objective": [{"id": "Biomass_Ecoli_core", "coef": 1}],
"direction": "max",
"fraction_of_optimum": 1,
"default_bound": 1000,
"tolerance": 1e-07,
"essential_threshold_rule": "growth below 1% of the optimum, or infeasible",
"format": "text",
"model_id": "e_coli_core",
"solver": "browser bounded simplex, checked against cobrapy 0.32.1 with GLPK"
},
"model": {
"reactions": 95,
"metabolites": 72,
"genes": 137,
"boundary_reactions": 20,
"internal_reactions": 75,
"compartments": ["c", "e"],
"metabolites_with_formula": 72,
"reactions_with_gpr": 69,
"lines_not_read": 0
},
"fba": {"status": "optimal", "objective_value": 0.873922},
"pfba": {"total_flux": 518.422},
"medium": [
{
"id": "EX_glc__D_e",
"metabolite": "glc__D_e",
"bound": -10,
"unlimited": false,
"flux": -10
},
{"id": "EX_o2_e", "metabolite": "o2_e", "bound": -1000, "unlimited": true, "flux": -21.7995},
"... 5 more"
],
"exchanges": [
{"id": "EX_glc__D_e", "metabolite": "glc__D_e", "flux": -10, "direction": "uptake"},
{"id": "EX_o2_e", "metabolite": "o2_e", "flux": -21.7995, "direction": "uptake"},
{"id": "EX_co2_e", "metabolite": "co2_e", "flux": 22.8098, "direction": "secretion"},
"... 4 more"
],
"top_fluxes": [
{"id": "ATPS4r", "name": "", "flux": 45.514},
{"id": "CYTBD", "name": "", "flux": 43.599},
{"id": "NADH16", "name": "", "flux": 38.5346},
"... 22 more"
],
"fva": {
"fraction_of_optimum": 1,
"rows_total": 9,
"rows": [
{"id": "EX_co2_e", "min": 22.8098, "max": 22.8098, "boundary": true},
{"id": "EX_glc__D_e", "min": -10, "max": -10, "boundary": true},
"..."
]
},
"blocked": {
"count": 8,
"ids": [
"EX_fru_e",
"EX_fum_e",
"EX_gln__L_e",
"EX_mal__L_e",
"FRUpts2",
"FUMt2_2",
"GLNabc",
"MALt2_2"
]
},
"dead_ends": {"count": 4, "ids": ["fru_e (only consumed)", "fum_e (only consumed)", "..."]},
"knockouts": {
"threshold": 0.00873922,
"reactions": {
"tested": 95,
"essential": [
{"id": "ACONTa", "growth": 0, "status": "optimal"},
{"id": "ACONTb", "growth": 0, "status": "optimal"},
"... 16 more"
],
"reduced_not_essential": ["..."]
},
"genes": {
"tested": 137,
"essential": [
{"id": "b0720", "growth": 0, "status": "optimal"},
{"id": "b2779", "growth": 0, "status": "optimal"},
"... 5 more"
],
"reduced_not_essential": ["..."]
}
},
"mass_balance": {
"checked": 75,
"imbalanced": [],
"objective_reaction_imbalanced": ["Biomass_Ecoli_core"],
"not_checked_missing_formula": 0
},
"metrics": [
{
"id": "M1",
"metric": "objective_value",
"value": 0.873922,
"basis": "maximise Biomass_Ecoli_core at steady state under the pasted bounds (model.slim_optimize)"
},
{
"id": "M2",
"metric": "pfba_total_flux",
"value": 518.422,
"basis": "sum of absolute fluxes, minimised with the objective held at its optimum (cobra.flux_analysis.pfba)"
},
{
"id": "M9",
"metric": "objective_at_10x_default_bound",
"value": 0.873922,
"basis": "the optimum again with every bound of magnitude 1000 raised tenfold; equal to M1 means the default bound does not limit the objective"
},
{
"id": "M10",
"metric": "gain_EX_glc__D_e",
"value": 0.0916647,
"basis": "change in the objective when the uptake bound of EX_glc__D_e (lower_bound -10) is opened by 1 unit; above 0 means this uptake limits the objective",
"reaction": "EX_glc__D_e",
"side": "lower_bound"
},
"... M3-M8"
],
"flags": [
{
"id": "F1",
"severity": "medium",
"category": "range_at_cap",
"message": "1 internal reaction(s) reach the default bound in FVA at 100% of the optimum (SUCDi): a thermodynamically infeasible cycle, or an uptake limited only by the default bound, lets them carry flux up to the cap. Those FVA ranges are set by the cap; loopless FVA removes the cycle case, a finite uptake bound the other.",
"refs": ["SUCDi"]
},
"... F2-F5 (low)"
],
"browser_verdict": "caveated",
"expected": {
"objective_value": 0.873921507,
"pfba_total_flux": 518.4220855,
"n_blocked": 8,
"n_essential_reactions": 18,
"n_essential_genes": 7,
"objective_10x_default_bound": 0.873921507,
"fva_min_EX_h2o_e": 29.17582714,
"fva_max_EX_h2o_e": 29.17582714,
"fva_min_EX_co2_e": 22.80983331,
"fva_max_EX_co2_e": 22.80983331,
"fva_min_EX_o2_e": -21.79949266,
"fva_max_EX_o2_e": -21.79949266,
"gain_EX_glc__D_e": 0.09166474638
},
"expected_count": 13,
"clipped": []
}
The free browser page computes this full object for any model you paste. To copy it without writing
code, open a result on the page and press Download .json: the file carries the
exact facts object under browser (the page's saved examples replay for free, so this
works before any spend). Send it back as a string: json.dumps(facts),
JSON.stringify(facts) or your language's equivalent. Keep the keys and values the
browser produced: the reply is reconciled against them, and the script lane copies
expected into its reproduction check.
Building the body
The simplest way to get a body that matches the page byte for byte is to run the page's own module
in Node. fbakit.js needs only lp.js (the page's simplex solver) next to it,
and both export themselves with module.exports. Your model can be cobrapy reaction
strings in the page's text form (PGI: g6p_c <=> f6p_c [-1000, 1000] gpr: b4025)
or a cobrapy JSON model written by cobra.io.save_json_model; the page solves models of
up to 800 reactions and 800 metabolites.
// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://fba-desk.skillsafe.ai/fbakit.js and https://fba-desk.skillsafe.ai/lp.js next to this file, then:
// node make-body.js model.txt interpret "E. coli core on aerobic glucose" "notes" "question" 1 > body.json
// model.txt is cobrapy reaction strings in the page's text form, or a cobrapy JSON model (save_json_model).
const fs = require("fs");
const K = require("./fbakit.js");
const [file, lane = "interpret", title = "", context = "", question = "", fraction = "1", decision = ""] = process.argv.slice(2);
const set = { lane, title, context, question, decision, fraction, model: fs.readFileSync(file, "utf8") };
const X = K.analyze(set);
if (X.empty) throw new Error(X.errors.join("; ") || "no model");
const body = K.mustBeObject(K.buildInput(X, set));
console.error("browser verdict:", X.hint, "| objective:", X.fba.status, X.fba.z, "| flags:", X.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: fba-desk:" + body.task + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
# Or build the body in any language from a facts object you already hold, for example the
# "browser" key of the page's "Download .json" export. facts must go in as a STRING.
import json
export = json.load(open("e-coli-core-interpret.json")) # the page's .json download
facts = export["browser"]
body = {
"task": "interpret",
"title": "E. coli core on aerobic glucose",
"context": "The E. coli core model (e_coli_core) exactly as cobrapy bundles it: ...",
"question": "Which uptake limits growth, and does any single gene deletion stop it?",
"facts": json.dumps(facts, separators=(",", ":")),
}
json.dump(body, open("body.json", "w"))
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": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"fba-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….
The input object IS the request body. There is no {"input": …}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# 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="fba-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://fba-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 = "fba-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://fba-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"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "fba-desk";
// Paste the token from https://fba-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
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 = "fba-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://fba-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 FbaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "fba-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 = "fba-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://fba-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 = "fba-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 FbaDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "fba-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");
}
}
2. 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. A guest token, minted with
POST /guest and {"slug":"fba-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://fba-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; a run 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":"fba-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://fba-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 start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "fba-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://fba-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 start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "fba-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://fba-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 start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"fba-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://fba-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 start a metered run.
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\":\"fba-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://fba-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 start a metered run.
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: "fba-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://fba-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 start a metered run.
$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" => "fba-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://fba-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 start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"fba-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());
3. Check the session and the balance
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 FbaDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra and
markup_bps 1000 (a 10% markup). hold_credits is a
reservation, not the price: it is held against your balance while the run executes
and released afterwards. min_credits is the least balance that can start a run. What
you actually pay is charged_credits, reported on the finished job and in the
done event, and it is usually far lower than the hold. The body is the input object
itself, with no {"input": …} wrapper. /estimate does not validate the
body, so check the shape yourself: an object whose every value is a string, task equal
to interpret or script,
facts non-empty, and facts a JSON string that parses to an object (this is
what the page's own guard, FbaKit.mustBeObject, refuses to spend without).
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("interpret","script") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("facts",)) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.
INPUT = json.load(open("body.json")) # built by make-body.js above, or by hand
assert isinstance(INPUT, dict) and INPUT.get("task") in ("interpret", "script")
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("facts",))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || !["interpret", "script"].includes(INPUT.task)) throw new Error("task must be interpret or script");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["facts"]) if (!(INPUT[k] || "").trim()) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "interpret" && input["task"] != "script" {
panic("task must be interpret or script")
}
for _, k := range []string{"facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(interpret|script)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task interpret or script");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(interpret|script)\".*", "$1");
for (String k : new String[] {"facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be interpret or script" unless %w[interpret script].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || !in_array($input["task"] ?? "", ["interpret", "script"], true)) { throw new Exception("task must be interpret or script"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "interpret" && lane != "script") throw new Exception("task must be interpret or script");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await FbaDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, fba-desk:<lane>:<hash>:a<attempt> (for example
fba-desk:interpret:f2j0v11dgeebz:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: a changed model, bounds, fraction of the optimum or notes (so changed
facts) or a changed reading are a new hash, the same model in the other lane are a new key, and replaying
an old key with a different body is a 409. The page uses
FbaKit.hashInput(body) for the hash (it covers task, title,
context, facts, decision and question;
make-body.js prints the key); any stable digest of the body works from other
languages. Leave retry_note out of the hash and bump the attempt instead.
# 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.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # interpret or script
KEY="fba-desk:$LANE:$(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\":\"caveated\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"fba-desk:{INPUT['task']}:{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"))
text = job["output"]["output"] # the reply, as a string
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 = `fba-desk:${INPUT.task}:${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());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
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 text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("fba-desk:%s:%x:a1", input["task"], 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()
var jobOutput string
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"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "fba-desk:" + lane + ":" + 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.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "fba-desk:#{INPUT['task']}:#{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"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "fba-desk:" . $input["task"] . ":" . 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"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"fba-desk:{lane}:" + 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 FbaDesk.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 output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
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\":\"caveated\",\"headline\":\"The"}
# 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:])
text = (done.get("output") or {}).get("output") or raw
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));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.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
The reply is a JSON object serialised as a string. Parse it, then check the lane.
# The reply is a JSON string inside data.output.output. Pull it out and parse it:
printf '%s' "$JOB" | python3 -c 'import sys,json;r=json.loads(json.load(sys.stdin)["output"]["output"]);print(r["verdict"],r["headline"])'
reply = json.loads(job["output"]["output"])
assert reply["lane"] == INPUT["task"], "the model answered as another lane"
print(reply["verdict"], reply["headline"])
for m in reply.get("metrics", []): # interpret lane
print(m["id"], m["reading"])
open("fba_fix.py", "w").write(reply.get("script", "")) # script lane
const reply = JSON.parse(job.output.output);
if (reply.lane !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.verdict, reply.headline);
for (const m of reply.metrics || []) console.log(m.id, m.reading); // interpret lane
if (reply.script) require("fs").writeFileSync("fba_fix.py", reply.script); // script lane
var reply struct {
Lane, Verdict, Headline, Script string
Metrics []struct{ Id, Reading string }
}
if err := json.Unmarshal([]byte(job.Output.Output), &reply); err != nil { panic(err) }
fmt.Println(reply.Verdict, reply.Headline)
// With any JSON library (Jackson shown): the reply is a string that holds a JSON object.
JsonNode reply = new ObjectMapper().readTree(outputString);
System.out.println(reply.get("verdict").asText() + " " + reply.get("headline").asText());
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["lane"] == INPUT["task"]
puts [reply["verdict"], reply["headline"]].join(" ")
$reply = json_decode($job["output"]["output"], true);
if ($reply["lane"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["verdict"], " ", $reply["headline"], "\n";
var reply = JsonSerializer.Deserialize<JsonElement>(outputString);
Console.WriteLine($"{reply.GetProperty("verdict")} {reply.GetProperty("headline")}");
Invariants worth asserting
- The verdict is never looser than
facts.browser_verdict(unreliable < caveated < sound) unless the medium or high flags that set it were dismissed. - Every flag id appears exactly once in
prescan_responses, and no answer names a flag that was never raised. - In the interpret lane there is one reading per metric id (
M1.. in the order offacts.metrics), andphenotypeis[]whenfba.statusis not optimal. - Every number in the prose exists in
factsor your notes (after rounding to 3 significant figures; fractions may be written as percentages). Reaction, metabolite and gene ids are names, not numbers. - The prose speaks of the model ("the model predicts"), not the organism ("grows in vivo").
- In the script lane
MODEL_PATHis"model.json"loaded withcobra.io.load_json_model,EXPECTEDcarries every key offacts.expectedwith its value, imports come only fromcobra,cobra.io,cobra.flux_analysis,math,jsonandpathlib,cobra.Configuration().processes = 1is set, the work sits under a__main__guard, and the objective direction and FVA fraction matchfacts.settings. - The page's
recon.jschecks all of this; you can run it in Node the same way asfbakit.js. The page's model.json (cobrapy) button writes the exact model the browser solved, which is the file the script expects.
The output contract
Every key of the lane's contract is present; empty sections are [].
{
"lane": "interpret" | "script",
"verdict": "sound" | "caveated" | "unreliable",
"headline": "one sentence",
"tldr": ["2-5 bullets; one starts \"Answer:\" when a question was asked"],
// interpret:
"metrics": [{"id": "M1", "reading": "..."}], // one per facts.metrics item, same order
"phenotype": ["1-4 strings: uptake and secretion, largest fluxes, lethal deletions"],
"claims": [{"claim": "...", "support": "supported|partly|not_supported", "why": "..."}],
"cautions": ["1-4 strings on what the analysis cannot show"],
// script:
"fixes": [{"fix": "...", "why": "...", "refs": "F1"}], // 1-6; refs "" for a plain reproduction step
"script": "import math\nimport cobra\n...", // one complete Python script, under 9000 characters
"assumptions": ["1-4 strings"],
"checks": ["1-4 strings"],
// both:
"next_steps": ["1-5 concrete actions"],
"prescan_responses": [{"ref": "F1", "verdict": "confirmed|dismissed", "note": "..."}]
}
The script lane's script follows a fixed order: import cobra and set
cobra.Configuration().processes = 1; set MODEL_PATH = "model.json" and
check it exists; load it (and set model.objective_direction = "min" when
settings.direction is min); define EXPECTED; compute each key with cobrapy
(objective_value from slim_optimize(), pfba_total_flux from
pfba(model).objective_value, n_blocked, n_essential_reactions,
n_essential_genes, objective_10x_default_bound,
fva_min_<ID> / fva_max_<ID> and gain_<ID>);
compare each with math.isclose(got, want, rel_tol=1e-6, abs_tol=1e-6); then apply the
fixes. A bound the user did not give is a named constant set to None with an
AUTHOR_INPUT_NEEDED comment, and that fix is skipped while it is None.
Worked example: interpret
The page's "E. coli core, aerobic glucose" example. The browser finds an optimum of 0.873922, glucose
as the only limiting uptake (gain_EX_glc__D_e 0.0916647, M10), 18 essential reactions
and 7 essential genes, and five flags: SUCDi reaching the default bound in FVA (F1,
medium) plus four low flags (alternate optima, open uptakes, 8 blocked reactions, 4 dead ends), so
its read is caveated. The body, with facts abbreviated (send the full string from
make-body.js or the page):
{
"task": "interpret",
"title": "E. coli core on aerobic glucose",
"context": "The E. coli core model (e_coli_core) exactly as cobrapy bundles it: aerobic, glucose uptake capped at 10, the biomass reaction as the objective. I want to say that growth is limited by glucose rather than oxygen, and that no single gene deletion stops growth.",
"question": "Which uptake limits growth, and does any single gene deletion stop it?",
"facts": "{\"settings\":{\"objective\":[{\"id\":\"Biomass_Ecoli_core\",\"coef\":1}],\"direction\":\"max\",\"fraction_of_optimum\":1,\"default_bound\":1000,\"model_id\":\"e_coli_core\",\"...\":\"more\"},\"fba\":{\"status\":\"optimal\",\"objective_value\":0.873922},\"pfba\":{\"total_flux\":518.422},\"exchanges\":[{\"id\":\"EX_glc__D_e\",\"metabolite\":\"glc__D_e\",\"flux\":-10,\"direction\":\"uptake\"},{\"id\":\"EX_o2_e\",\"metabolite\":\"o2_e\",\"flux\":-21.7995,\"direction\":\"uptake\"},{\"id\":\"EX_co2_e\",\"metabolite\":\"co2_e\",\"flux\":22.8098,\"direction\":\"secretion\"},\"... 4 more\"],\"...\":\"medium, top_fluxes, fva, blocked, dead_ends, knockouts, mass_balance, metrics, flags as above\",\"browser_verdict\":\"caveated\",\"expected\":{\"objective_value\":0.873921507,\"pfba_total_flux\":518.4220855,\"...\":\"11 more\"},\"expected_count\":13,\"clipped\":[]}"
}
A reply must answer F1 to F5 once each in prescan_responses, read M1 to M10 in order,
judge both claims in context (growth limited by glucose rather than oxygen; no single
gene deletion stops growth) against knockouts and the gain_ metric, and
stay at caveated or tighter unless it dismisses F1.
Worked example: script
The same model with the interpret reading handed over as decision (the page fills it
with Recon.decisionText of the earlier reply; it may be empty). No question is sent in
this lane. The body, with facts and decision abbreviated:
{
"task": "script",
"title": "E. coli core on aerobic glucose",
"context": "The E. coli core model (e_coli_core) exactly as cobrapy bundles it: aerobic, glucose uptake capped at 10, the biomass reaction as the objective. I want to say that growth is limited by glucose rather than oxygen, and that no single gene deletion stops growth.",
"question": "",
"decision": "Verdict: caveated.\n<headline of the interpret reply>\n- M1: <reading>\n- M2: <reading>\n...\nNext steps:\n- ...",
"facts": "{\"settings\":{\"objective\":[{\"id\":\"Biomass_Ecoli_core\",\"coef\":1}],\"direction\":\"max\",\"fraction_of_optimum\":1,\"default_bound\":1000,\"model_id\":\"e_coli_core\",\"...\":\"more\"},\"fba\":{\"status\":\"optimal\",\"objective_value\":0.873922},\"pfba\":{\"total_flux\":518.422},\"exchanges\":[{\"id\":\"EX_glc__D_e\",\"metabolite\":\"glc__D_e\",\"flux\":-10,\"direction\":\"uptake\"},{\"id\":\"EX_o2_e\",\"metabolite\":\"o2_e\",\"flux\":-21.7995,\"direction\":\"uptake\"},{\"id\":\"EX_co2_e\",\"metabolite\":\"co2_e\",\"flux\":22.8098,\"direction\":\"secretion\"},\"... 4 more\"],\"...\":\"medium, top_fluxes, fva, blocked, dead_ends, knockouts, mass_balance, metrics, flags as above\",\"browser_verdict\":\"caveated\",\"expected\":{\"objective_value\":0.873921507,\"pfba_total_flux\":518.4220855,\"...\":\"11 more\"},\"expected_count\":13,\"clipped\":[]}"
}
The reply's script must put all 13 keys of facts.expected into
EXPECTED with these values (objective_value 0.873921507,
pfba_total_flux 518.4220855, n_blocked 8,
n_essential_reactions 18, n_essential_genes 7,
objective_10x_default_bound 0.873921507, the FVA minimum and maximum of
EX_h2o_e, EX_co2_e and EX_o2_e, and
gain_EX_glc__D_e 0.09166474638), check each with math.isclose, and answer
F1 with a fix from the allowed list, typically re-running FVA with loopless=True. Save
the page's model.json next to the script and run it with python fba_fix.py.
The other examples
The page carries two more interpret examples; build their bodies the same way (the numbers below are the browser's own):
| example | what the browser finds | browser verdict |
|---|---|---|
E. coli core without oxygen (EX_o2_e lower bound 0) | Optimum 0.211663; the pFBA state secretes EX_ac_e 8.50359, EX_etoh_e 8.27946 and EX_for_e 17.8047; 23 essential reactions and 10 essential genes; gain_EX_glc__D_e 0.0304591. Same five flag categories as aerobic, F1 (range_at_cap) medium. | caveated |
A teaching network with open uptakes (toy_fermenter) | Optimum 666.667, but 6666.67 with every default bound raised tenfold (M9), so F1 is a high default_bound flag: the optimum reflects cobrapy's arbitrary cap of 1000, not a nutrient limit. Also range_at_cap (medium), alternate optima, open uptakes, a blocked reaction, a dead end and no formulas (low). | unreliable |
Truncation and partial results
If your balance sits between min_credits and hold_credits, the run still
executes with a smaller output cap and the job carries "truncated": true. The JSON may
then stop mid-object: close it (the page's Recon.closeJson does this) and show the
sections that arrived, saying how many of the lane's sections were recovered, rather than treating
a clipped reply as complete. A clipped script is not runnable; re-run instead.