Drive RELSA Desk from your own code
A reading of the computed scores, not of the animals. The model reads the RELSA scores and severity zones your browser (or your script) computed; it never recomputes a number. A score is a fraction of the reference set's maximum deviation, comparable only within that reference frame. KDE zones are candidate, model-specific cut-points, not severity grades under EU Directive 2010/63/EU, and nothing here predicts an animal's course or replaces your humane endpoint criteria.
Everything the web page does is available over HTTP. The page reads a welfare cohort table (one row
per animal per time point: an id, a time column, optional labels such as treatment or condition,
and readout columns such as body weight, temperature, a clinical score or a biomarker) and computes
RELSA scores with its own relsa.js and relsakit.js, following the skill's
relsa_score.py and kde_thresholds.py: each variable as a percent of the
animal's own baseline, ordinal scores mapped onto the percent scale, a reference model from the
group assumed to carry the greatest burden, a weight per variable per time point, the
root-mean-square RELSA score, and severity zones from the minima of a kernel density estimate with
a bandwidth sweep. Send those facts and get back a verdict (sound,
caveated, unreliable) and either a reading of every metric, the animals,
the zones and the reporting checklist, or a Python script that reproduces every number with the
skill's own functions and runs follow-up checks. The metered run sees only the browser's analysis,
never your raw table. RELSA Desk is derived from the agent skill
@k-dense-ai/relsa-severity-assessment (k-dense-ai/scientific-agent-skills, K-Dense
Inc.).
Two lanes: the task field
| task | what you get | extra input |
|---|---|---|
interpret | A reading of each metric (M1..M7, in order: scored rows, the reference set, the highest score, animals above the reference maximum, the KDE thresholds, bandwidth stability, animals whose set of measured variables changes), the animals the scores single out, the severity zones in words, the eight-item reporting checklist, a methods draft, your claims judged against the facts, and what the scores cannot show. | none |
script | The fixes (re-scoring without a variable or with its direction flipped, rebuilding the reference set from a named group, scoring only variables measured at every time point, repeating the KDE across the sweep's bandwidths, excluding baseline rows from the KDE, forecasting one animal) and one complete Python script: SKILL_SCRIPTS = "scripts" on sys.path, COHORT_PATH = "cohort.csv" read with read_relsa_table, the skill's prepare, build_reference, relsa_scores and find_thresholds, 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 reply shape: output contract.
Input fields
Every field is a string.
| field | required | meaning |
|---|---|---|
task | yes | interpret (a reading of what the scores and zones support: metric readings, animals, zones, an 8-item reporting checklist, a methods draft, claims, cautions) or script (a Python script that reproduces the scores with the skill's own functions, plus fixes, assumptions and checks). These are the only two lanes. |
facts | yes | A JSON-encoded string holding the browser's analysis - see below. The page builds it with RelsaKit.facts in relsakit.js; an API caller must build the same object. The easiest path is to open the app, load your data and copy the run input (or reuse the example below). |
title | no | A label for the analysis, up to 160 characters. |
context | no | Your notes: the model or procedure, the humane endpoint criteria applied, why this reference set is assumed to carry the greatest burden, what you want to conclude. The checklist can only mark endpoint_criteria and reference_set as stated from here. |
question | no | Answered in tldr as a bullet starting "Answer:". |
decision | script only | Plain text of an earlier interpret result to act on (the page builds it with Recon.decisionText: "Verdict: ...", the headline, one line per metric reading, animal and zone string, then the next steps). |
retry_note | no | Set only when re-asking after a malformed reply. |
The facts string
facts is a JSON string, not an object: the browser scores the cohort,
serialises the result with JSON.stringify and sends that text. Its keys are
settings (rows, animals, variables_scored,
turned, normalized, score_scales,
baseline_time, baseline_rule, reference,
dropped, rounding, the kde settings,
forecasting, software); reference_model (per variable:
turned, max_reached_pct, max_delta_pct, the denominator of
every weight); animals (highest peak first: max_relsa,
time_of_max, last_relsa, weights_at_max,
zone_at_max and more); groups; kde (n,
bandwidth, thresholds, modes, zones,
sweep, or {"error"}); metrics (M1..);
flags (F1.. with severity high / medium / low,
category, message, refs); browser_verdict;
pipeline (the exact settings in the skill's terms, for the script);
expected (the values a reproduction must match: n_scored,
maxdelta__<VAR>, relsa__<ID>__<TIME>,
kde_n, kde_bandwidth, kde_threshold_count,
kde_threshold_<k>), expected_rows (which id and
time each relsa__ key names), expected_count; and
clipped (what was left out for length).
The skill's own synthetic cohort. The object below is what the page computes for
the 6-mouse example cohort that ships with the relsa-severity-assessment skill (not real animals),
with the endpoint animals as the reference set and the density taken over the treated animals.
The full string is about 8.6 KB; here the animals list and
expected_rows are cut after their first items, and the entries whose key is
"…" mark the cuts. Send the full string from the page, never this abbreviation:
{
"settings": {
"rows": 54,
"animals": 6,
"variables_scored": ["weight", "temp", "score", "il6"],
"turned": ["il6", "score"],
"normalized": ["weight", "temp", "il6"],
"score_scales": [
{"column": "score", "max_score": 8, "baseline_score": 0}
],
"baseline_time": -1,
"baseline_rule": "the listed baseline time point",
"reference": {
"label": "cohort.csv [condition=endpoint]",
"loaded_from_file": false,
"groups": ["condition=endpoint"],
"n_animals": 2,
"n_rows": 18
},
"dropped": [],
"rounding": "2 decimals on deltas, weights and scores (R package convention)",
"kde": {
"filter": ["treatment=treated"],
"excluded_times": null,
"bandwidth_rule": "bw.nrd0 (Silverman, R)",
"n_thresholds_kept": 2,
"min_zone_fraction": 0.02,
"grid": 512,
"cut": 3
},
"forecasting": "not run in the browser (ARIMA foRcast is available in the skill's forecast_relsa.py)",
"software": "relsa-severity-assessment skill scripts, version 1.1 (numpy/pandas/scipy)"
},
"reference_model": [
{"variable": "weight", "turned": false, "max_reached_pct": 82.39968216, "max_delta_pct": 17.60031784},
{"variable": "temp", "turned": false, "max_reached_pct": 92.78600269, "max_delta_pct": 7.213997308},
{"variable": "score", "turned": true, "max_reached_pct": 187.5, "max_delta_pct": 87.5},
{"variable": "il6", "turned": true, "max_reached_pct": 797.7207977, "max_delta_pct": 697.7207977}
],
"animals": [
{"id": "M01", "in_reference": true, "n_timepoints": 9, "n_scored": 7, "treatment": "treated", "condition": "endpoint", "max_relsa": 1, "time_of_max": 5, "last_relsa": 1, "last_time": 5, "at_peak_at_last_time": true, "largest_weight_at_max": {"variable": "weight", "weight": 1}, "weights_at_max": {"weight": 1, "temp": 1, "score": 1, "il6": 1}, "timepoints_above_1": 0, "zone_at_max": "danger", "zone_at_last": "danger"},
{"…": "5 more animals (M02, M03, M04, S02, S01), highest peak first - the full string comes from the page"}
],
"groups": [
{"group": "treatment=treated, condition=endpoint", "animals": 2, "scored_rows": 15, "highest_max_relsa": 1, "highest_animal": "M01", "animals_above_1": 0},
{"group": "treatment=treated, condition=survivor", "animals": 2, "scored_rows": 18, "highest_max_relsa": 0.53, "highest_animal": "M03", "animals_above_1": 0},
{"group": "treatment=sham, condition=sham", "animals": 2, "scored_rows": 18, "highest_max_relsa": 0.13, "highest_animal": "S02", "animals_above_1": 0}
],
"kde": {
"n": 33,
"bandwidth": 0.150177,
"bw_nrd0": 0.150177,
"thresholds": [0.7028],
"modes": [0.2638, 0.8664],
"zones": [
{"zone": "normal", "low": 0, "high": 0.7028, "n": 25, "fraction": 0.758},
{"zone": "danger", "low": 0.7028, "high": null, "n": 8, "fraction": 0.242}
],
"sweep": [
{"factor": 0.7, "bandwidth": 0.1051, "thresholds": [0.189, 0.671]},
{"factor": 0.8, "bandwidth": 0.1201, "thresholds": [0.195, 0.68]},
{"factor": 0.9, "bandwidth": 0.1352, "thresholds": [0.193, 0.69]},
{"factor": 1, "bandwidth": 0.1502, "thresholds": [0.703]},
{"factor": 1.1, "bandwidth": 0.1652, "thresholds": [0.728]},
{"factor": 1.25, "bandwidth": 0.1877, "thresholds": []}
]
},
"metrics": [
{"metric": "scored_rows", "value": 51, "fraction": 0.9444, "basis": "rows with a RELSA score (at least one variable measured), of 54 rows from 6 animals", "id": "M1"},
{"metric": "reference_set", "value": 2, "basis": "animals in the reference set (condition=endpoint), 18 rows", "id": "M2"},
{"metric": "highest_relsa", "value": 1, "animal": "M01", "time": "5", "basis": "the highest score of any animal and when it occurred; 1 means the reference set's maximum deviation", "id": "M3"},
{"metric": "animals_above_reference_max", "value": 0, "animals": [], "basis": "animals whose score exceeded 1 at some time point, i.e. deviated further than anything in the reference set", "id": "M4"},
{"metric": "kde_thresholds", "value": 1, "thresholds": [0.7028], "bandwidth": 0.150177, "n": 33, "basis": "density minima of the 33 scores in the KDE (Gaussian kernel, bandwidth bw.nrd0)", "id": "M5"},
{"metric": "bandwidth_stability", "value": 2, "of": 6, "basis": "bandwidths in the sweep (0.7, 0.8, 0.9, 1, 1.1, 1.25 x bw.nrd0) that give the same number of thresholds as the one used", "id": "M6"},
{"metric": "composition_changes", "value": 0, "animals": [], "basis": "animals whose set of measured variables changes along the trajectory, which moves the score by itself", "id": "M7"}
],
"flags": [
{"id": "F1", "severity": "low", "category": "small_reference", "message": "The reference set has 2 animal(s). Every weight divides by that set's single most extreme value, so one animal's worst day fixes the scale.", "refs": []},
{"id": "F2", "severity": "low", "category": "score_mapping", "message": "Score mapping applied: score healthy 0.0 -> 100%, worst 8.0 -> 200%. This is a modelling choice about what a score point is worth against a percent of body weight, and it belongs in the methods.", "refs": ["score"]},
{"id": "F3", "severity": "medium", "category": "bandwidth_sensitive", "message": "The number of thresholds changes within 10% of the bandwidth used (the sweep keeps it at 2 of 6 bandwidths). Report the sweep, not a bare threshold.", "refs": []},
{"id": "F4", "severity": "low", "category": "few_scores", "message": "The density rests on 33 scores (the published sepsis analysis used 239). Minima from a sample this small are fragile.", "refs": []},
{"id": "F5", "severity": "low", "category": "baseline_scores_in_kde", "message": "5 score(s) in the density are exactly 0, usually the baseline rows, which are 0 by construction. The published analysis excluded the baseline time point; consider excluding it here.", "refs": []}
],
"browser_verdict": "caveated",
"pipeline": {
"cohort_file": "cohort.csv",
"id_col": "id",
"time_col": null,
"variables": ["weight", "temp", "score", "il6"],
"turned": ["il6", "score"],
"normalize": ["weight", "temp", "il6"],
"score_scale": [
{"column": "score", "max_score": 8, "baseline_score": 0}
],
"baseline_time": -1,
"reference": {"filter": [["condition", "endpoint"]], "n_animals": 2, "n_rows": 18},
"drop": [],
"round_digits": 2,
"kde": {
"filter": [["treatment", "treated"]],
"exclude_times": null,
"bandwidth": null,
"n_thresholds": 2,
"min_zone_fraction": 0.02,
"within_data": true
}
},
"expected": {
"n_scored": 51,
"maxdelta__weight": 17.60031784,
"maxdelta__temp": 7.213997308,
"maxdelta__score": 87.5,
"maxdelta__il6": 697.7207977,
"relsa__M01__5": 1,
"relsa__M02__5": 0.94,
"relsa__M02__6": 0.94,
"relsa__M03__3": 0.53,
"relsa__M03__7": 0.11,
"relsa__M04__4": 0.41,
"relsa__M04__7": 0.09,
"relsa__S02__3": 0.13,
"relsa__S02__7": 0.04,
"relsa__S01__1": 0.11,
"relsa__S01__7": 0.02,
"kde_n": 33,
"kde_bandwidth": 0.1501770151,
"kde_threshold_count": 1,
"kde_threshold_1": 0.7027551545
},
"expected_rows": [
{"key": "relsa__M01__5", "id": "M01", "time": 5},
{"key": "relsa__M02__5", "id": "M02", "time": 5},
{"…": "9 more rows"}
],
"expected_count": 20,
"clipped": []
}
The free browser page computes this full object for any cohort you paste (CSV or tab-separated,
up to the page's row limit). To copy it without writing code, run the page's saved example (it
replays for free) or your own analysis and press Download .json: the file carries
the exact facts object under browser. 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. relsakit.js loads relsa.js (the scoring and KDE engine) from the
same folder, and both export themselves with module.exports. The input is the page's
Save set .json file: the table under data plus the settings the form
holds (idCol, timeCol, variables, turned,
normalize, scoreScale as COL=MAX[:BASELINE],
baselineTime, referenceGroup as col=value,
referenceJson, drop, precision, kdeGroup,
kdeBaseline include / exclude, nThresholds, bandwidth,
minZone) and the title, context and question.
// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://relsa-desk.skillsafe.ai/relsakit.js and relsa.js next to this file, then:
// node make-body.js my-set.json interpret > body.json
// node make-body.js my-set.json script "Verdict: caveated. ..." > body.json
// my-set.json is the page's "Save set .json" download (table + settings + notes).
const fs = require("fs");
const K = require("./relsakit.js"); // requires ./relsa.js itself
const [setFile, lane = "interpret", decision = ""] = process.argv.slice(2);
const set = JSON.parse(fs.readFileSync(setFile, "utf8"));
set.lane = lane;
if (lane === "script") set.decision = decision;
const A = K.analyze(set);
if (A.empty) throw new Error(A.errors.join("; ") || "no rows to score");
const body = K.mustBeObject(K.buildInput(A, set));
fs.writeFileSync("cohort.csv", K.cohortCsv(A)); // the table the script lane reads
console.error("browser verdict:", A.hint, "| animals:", A.animals.length, "| scored rows:", A.scored,
"| flags:", A.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: relsa-desk:" + body.task + ":" + K.hashInput(body) + ":a1");
fs.writeSync(1, 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("relsa-desk-interpret.json")) # the page's .json download
facts = export["browser"]
body = {
"task": "interpret",
"title": "Example cohort: endpoint animals as reference",
"context": "The synthetic 6-mouse cohort that ships with the skill (not real animals). ...",
"question": "Which animals came closest to the reference maximum?",
"facts": json.dumps(facts, separators=(",", ":"), ensure_ascii=False),
}
json.dump(body, open("body.json", "w"), ensure_ascii=False)
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":"relsa-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 facts.
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="relsa-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://relsa-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 = "relsa-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://relsa-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 = "relsa-desk";
// Paste the token from https://relsa-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 = "relsa-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://relsa-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 RelsaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "relsa-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 = "relsa-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://relsa-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 = "relsa-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 RelsaDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "relsa-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":"relsa-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://relsa-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":"relsa-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://relsa-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": "relsa-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://relsa-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: "relsa-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://relsa-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":"relsa-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://relsa-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\":\"relsa-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://relsa-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: "relsa-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://relsa-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" => "relsa-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://relsa-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\":\"relsa-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 RelsaDesk.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, RelsaKit.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 RelsaDesk.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, relsa-desk:<lane>:<hash>:a<attempt> (for example
relsa-desk:interpret:1a5xi21gged1p:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: a changed table, setting, reference set or
note (so changed facts) or a changed reading is a new hash, the same cohort in the other lane is a new
key, and replaying
an old key with a different body is a 409. The page uses
RelsaKit.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="relsa-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"relsa-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 = `relsa-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("relsa-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 = "relsa-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 = "relsa-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 = "relsa-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 = $"relsa-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 RelsaDesk.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("relsa_check.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("relsa_check.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),animalsandzonesare not empty, andchecklistholds exactly the 8 items in order:directionality,baseline,score_mapping,reference_set,endpoint_criteria,forecasting,thresholds,software. A missing item shows up inmethodsas[AUTHOR_INPUT_NEEDED: ...]. - Every number in the prose exists in
factsor your notes (rounded to 2 decimals, or 3 for a threshold or bandwidth; a fraction may be written as a percentage). Animals, variables, groups and time points are the ones the facts name. - Scores are stated against the named reference set. No score or zone is mapped onto the EU Directive 2010/63/EU severity categories, no animal is called mild, moderate or severe, and no reply says an animal will die, will reach a humane endpoint or should be euthanised.
- In the script lane
SKILL_SCRIPTSis"scripts"and is put onsys.pathbefore the skill's modules are imported,COHORT_PATHis"cohort.csv"and is read withread_relsa_table,EXPECTEDcarries every key offacts.expectedwith its value, imports come only fromsys,math,json,pathlib,numpy,pandas,_common,relsa_score,kde_thresholdsandforecast_relsa, nothing touches the network, subprocess or pickle, and the work sits under a__main__guard. - The page's
recon.jschecks all of this; you can run it in Node the same way asrelsakit.js. The page's cohort.csv (for the script) button writes the exact table the browser analysed, which is the file the script expects (make-body.jsabove writes it too).
The output contract
The model returns one JSON object as the job's output text. 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
"animals": ["1-6 strings: the highest peaks, animals still at their peak, groups compared"],
"zones": ["1-4 strings: thresholds, bandwidth, number of scores, what the sweep shows"],
"checklist": [{"item": "directionality", "status": "stated|partly|missing|not_applicable", "note": "..."}],
// exactly 8, in the order listed above
"methods": "one paragraph, with [AUTHOR_INPUT_NEEDED: ...] where an item is missing",
"claims": [{"claim": "...", "support": "supported|partly|not_supported", "why": "..."}],
"cautions": ["1-4 strings on what the scores cannot show"],
// script:
"fixes": [{"fix": "...", "why": "...", "refs": "F1"}], // 1-6; refs "" for a plain step
"script": "import math\nimport sys\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: set
SKILL_SCRIPTS = "scripts", check it is a folder and put it on sys.path;
set COHORT_PATH = "cohort.csv", check it is an existing local file and read it with
read_relsa_table(COHORT_PATH, id_col=..., time_col=...) from
pipeline.id_col and pipeline.time_col; map each
pipeline.score_scale column with score_to_percent; call
prepare(frame, normalize=..., baseline_time=...); build the reference from the
pipeline.reference.filter rows with build_reference (or
ReferenceModel.from_json on reference.json when the reference was loaded from a file);
score with relsa_scores(prepared, reference, drop=..., round_digits=...); run
find_thresholds on the finite relsa values the
pipeline.kde settings select; define EXPECTED and ROWS
(each expected_rows key to its (id, time)); compare every key with
math.isclose(got, want, rel_tol=1e-6, abs_tol=1e-9), printing any mismatch; then
apply the fixes, printing what changes.
Worked example: interpret
The skill's synthetic 6-mouse cohort from the facts section above (not real animals), exactly as
the page sends it for its first example. The browser scores 51 of 54 rows, takes the two endpoint
animals (M01, M02) as the reference set, finds one density threshold at 0.7028 over 33 scores and
raises F3 (medium) because only 2 of the 6 bandwidths in the sweep keep the same number of
thresholds, so its read is caveated. The body, with facts abbreviated (send the full
string from make-body.js or the page):
{
"task": "interpret",
"title": "Example cohort: endpoint animals as reference",
"context": "The synthetic 6-mouse cohort that ships with the relsa-severity-assessment skill (not real animals). Temperature and body weight fall under burden; IL-6 rises; the 0-8 clinical score is mapped with a score scale. The reference set is the two endpoint-reaching animals, assumed to carry the greatest burden in this model.",
"question": "Which animals came closest to the reference maximum, and can we use the density threshold as a danger line?",
"facts": "{\"settings\":{\"rows\":54,\"animals\":6,\"variables_scored\":[\"weight\",\"temp\",\"score\",\"il6\"],\"turned\":[\"il6\",\"score\"]},\"…\":\"the rest of settings, reference_model, animals, groups, kde, metrics, flags, pipeline and expected - the full string comes from the page\",\"browser_verdict\":\"caveated\",\"expected_count\":20,\"clipped\":[]}"
}
An abbreviated reply (the job's output.output, parsed; "..." marks cuts):
{
"lane": "interpret",
"verdict": "caveated",
"headline": "Against the two endpoint animals as reference, M01 reached the reference maximum and M02 came close, but the single density threshold at 0.703 is bandwidth-sensitive.",
"tldr": [
"51 of 54 rows are scored; the reference set is the 2 endpoint animals (condition=endpoint).",
"No animal exceeded 1, the reference set's maximum deviation.",
"Answer: M01 (1 at time 5) and M02 (0.94 at time 5) came closest; the threshold at 0.703 holds for only 2 of 6 bandwidths, so it is a candidate cut-point to report with its sweep, not a danger line."
],
"metrics": [
{"id": "M1", "reading": "51 of 54 rows (94.44%) have a RELSA score."},
{"id": "M2", "reading": "The reference set is 2 animals with condition=endpoint, 18 rows."},
{"id": "M3", "reading": "M01 has the highest score, 1 at time 5: it defines the reference maximum."},
{"id": "M4", "reading": "0 animals went above 1, so none deviated further than the reference set."},
{"id": "M5", "reading": "The density of 33 scores has 1 threshold, 0.703, at bandwidth 0.150 (bw.nrd0)."},
{"id": "M6", "reading": "Only 2 of 6 bandwidths in the sweep give the same number of thresholds."},
{"id": "M7", "reading": "0 animals change their set of measured variables along the trajectory."}
],
"animals": [
"M01 peaked at 1 at time 5 and M02 at 0.94 at time 5; both are in the reference set and were at their peak at their last time point.",
"Among the survivors M03 peaked at 0.53 at time 3; the sham group's highest peak was 0.13 (S02).",
"..."
],
"zones": [
"One threshold at 0.703 splits the 33 treated-animal scores into normal (25) and danger (8) at bandwidth 0.150.",
"The sweep gives two thresholds at 0.7, 0.8 and 0.9 x bw.nrd0 and none at 1.25 x, so the threshold is bandwidth-sensitive."
],
"checklist": [
{"item": "directionality", "status": "stated", "note": "il6 and score are turned; the notes say temperature and weight fall and IL-6 rises."},
{"item": "baseline", "status": "stated", "note": "The listed baseline time point, -1."},
{"item": "score_mapping", "status": "stated", "note": "score 0-8 mapped onto the percent scale (max_score 8, baseline_score 0)."},
{"item": "reference_set", "status": "stated", "note": "The 2 endpoint animals, assumed in the notes to carry the greatest burden."},
{"item": "endpoint_criteria", "status": "missing", "note": "The humane endpoint criteria applied are not given."},
{"item": "forecasting", "status": "not_applicable", "note": "No forecast is reported."},
{"item": "thresholds", "status": "partly", "note": "Bandwidth bw.nrd0 and 33 scores are stated; the threshold changes across the sweep."},
{"item": "software", "status": "stated", "note": "relsa-severity-assessment skill scripts, version 1.1."}
],
"methods": "Severity was assessed with RELSA using body weight, temperature and IL-6 as a percent of each animal's baseline at time -1 and a 0-8 clinical score mapped onto the percent scale. ... Humane endpoints were [AUTHOR_INPUT_NEEDED: the humane endpoint criteria applied]. ...",
"claims": [
{"claim": "The density threshold can serve as a danger line.", "support": "not_supported", "why": "The number of thresholds changes across the sweep (M6: 2 of 6) and the density rests on 33 scores; a KDE zone is a candidate cut-point, not a decision rule."}
],
"cautions": [
"The scores are relative to these 2 endpoint animals and cannot be compared with another study's.",
"The zones are not severity categories under EU Directive 2010/63/EU."
],
"next_steps": ["State the humane endpoint criteria applied.", "Report the bandwidth sweep with the threshold.", "..."],
"prescan_responses": [
{"ref": "F1", "verdict": "confirmed", "note": "The reference set has 2 animals."},
{"ref": "F2", "verdict": "confirmed", "note": "The score mapping is a modelling choice for the methods."},
{"ref": "F3", "verdict": "confirmed", "note": "Only 2 of 6 bandwidths keep the same number of thresholds."},
{"ref": "F4", "verdict": "confirmed", "note": "The density rests on 33 scores."},
{"ref": "F5", "verdict": "confirmed", "note": "5 scores in the density are exactly 0."}
]
}
A reply must answer F1 to F5 once each in prescan_responses, read M1 to M7 in order,
give the 8 checklist items in order, judge the danger-line claim in question against
kde, and stay at caveated or tighter unless it dismisses F3.
Worked example: script
The page's second example, the 22-mouse synthetic CLP-like cohort (generated for the page, not
real animals: telemetry temperature and activity, body weight and a 0-8 clinical score, 8 endpoint
animals as the reference set, the baseline time point excluded from the density), sent to the
script lane with a short decision (the page fills it with
Recon.decisionText of the earlier interpret reply; it may be empty). The browser's read
is sound, with two low flags: F1 (score mapping) and F2 (one score within a grid step of the
threshold). No question is sent in this lane. The body, with facts abbreviated:
{
"task": "script",
"title": "Synthetic CLP-like cohort, telemetry + weight + score",
"context": "Synthetic cohort generated for this example (not real animals): a CLP-like sepsis model with telemetry temperature and activity, body weight and a 0-8 clinical score, 8 endpoint animals, 8 survivors and 6 shams. Endpoint animals were removed at the humane endpoint, so their later rows are empty. The reference set is the endpoint group, assumed to carry the greatest burden.",
"decision": "Verdict: sound.\nAgainst the 8 endpoint animals as reference, E05 peaked highest at 0.97 at time 6 and one density threshold at 0.759 holds for 4 of 6 bandwidths.\n- M1: 181 of 198 rows are scored.\n- ...\nNext steps:\n- Report the bandwidth sweep with the threshold.",
"facts": "{\"settings\":{\"rows\":198,\"animals\":22,\"variables_scored\":[\"temp\",\"act\",\"weight\",\"score\"],\"turned\":[\"score\"],\"normalized\":[\"temp\",\"act\",\"weight\"]},\"…\":\"the rest of settings, reference_model, animals, groups, kde, metrics and flags - the full string comes from the page\",\"browser_verdict\":\"sound\",\"pipeline\":{\"cohort_file\":\"cohort.csv\",\"id_col\":\"id\",\"time_col\":null,\"variables\":[\"temp\",\"act\",\"weight\",\"score\"],\"turned\":[\"score\"],\"normalize\":[\"temp\",\"act\",\"weight\"],\"score_scale\":[{\"column\":\"score\",\"max_score\":8,\"baseline_score\":0}],\"baseline_time\":-1,\"reference\":{\"filter\":[[\"condition\",\"endpoint\"]],\"n_animals\":8,\"n_rows\":72},\"drop\":[],\"round_digits\":2,\"kde\":{\"filter\":[],\"exclude_times\":[-1],\"bandwidth\":null,\"n_thresholds\":null,\"min_zone_fraction\":0.02,\"within_data\":true}},\"expected\":{\"n_scored\":181,\"maxdelta__temp\":9.887760556,\"maxdelta__act\":87.70301624,\"maxdelta__weight\":17.64950166,\"maxdelta__score\":100,\"relsa__E05__6\":0.97,\"relsa__E04__5\":0.96,\"relsa__E02__5\":0.94,\"relsa__E07__5\":0.94,\"relsa__E01__6\":0.93,\"relsa__E03__5\":0.93,\"kde_n\":159,\"kde_bandwidth\":0.09298578192,\"kde_threshold_count\":1,\"kde_threshold_1\":0.7585894213},\"expected_rows\":[{\"key\":\"relsa__E05__6\",\"id\":\"E05\",\"time\":6},{\"key\":\"relsa__E04__5\",\"id\":\"E04\",\"time\":5},{\"…\":\"4 more rows\"}],\"expected_count\":15,\"clipped\":[]}"
}
An abbreviated reply; the script string is shown decoded below it:
{
"lane": "script",
"verdict": "sound",
"headline": "The script rebuilds the endpoint reference from cohort.csv with the skill's functions, checks all 15 expected values, then repeats the density across the sweep and re-scores without score.",
"tldr": ["The reproduction check covers n_scored, 4 maxdelta values, 6 peak scores and 4 KDE values.", "..."],
"fixes": [
{"fix": "Repeat the KDE across the bandwidth factors of facts.kde.sweep and print the thresholds.", "why": "One score lies within a grid step of the threshold at 0.759, so its zone depends on the estimate.", "refs": "F2"},
{"fix": "Re-score without score and print each animal's peak score before and after.", "why": "The score mapping is a modelling choice; this shows how much the peaks rest on it.", "refs": "F1"}
],
"script": "import math\nimport sys\nfrom pathlib import Path\n...",
"assumptions": ["cohort.csv is the file the page wrote for this cohort.", "The skill's scripts folder sits at ./scripts."],
"checks": ["The reproduction prints no MISMATCH line.", "How the peaks move without score, and whether the threshold near 0.759 persists across the sweep."],
"next_steps": ["Save the page's cohort.csv next to relsa_check.py and run python relsa_check.py.", "..."],
"prescan_responses": [
{"ref": "F1", "verdict": "confirmed", "note": "Answered by the second fix."},
{"ref": "F2", "verdict": "confirmed", "note": "Answered by the first fix."}
]
}
# relsa_check.py - reproduce the browser's RELSA scores and zones (abbreviated reply script)
import math
import sys
from pathlib import Path
import numpy as np
SKILL_SCRIPTS = "scripts"
if not Path(SKILL_SCRIPTS).is_dir():
raise SystemExit(f"{SKILL_SCRIPTS}/ not found: point SKILL_SCRIPTS at the skill's scripts folder")
sys.path.insert(0, SKILL_SCRIPTS)
from _common import read_relsa_table, score_to_percent # noqa: E402
from kde_thresholds import find_thresholds # noqa: E402
from relsa_score import build_reference, prepare, relsa_scores # noqa: E402
COHORT_PATH = "cohort.csv"
VARIABLES = ["temp", "act", "weight", "score"]
TURNED = ["score"]
NORMALIZE = ["temp", "act", "weight"]
BASELINE_TIME = -1
KDE_EXCLUDE_TIMES = [-1]
SWEEP = [0.7, 0.8, 0.9, 1, 1.1, 1.25]
EXPECTED = {
"n_scored": 181,
"maxdelta__temp": 9.887760556,
"maxdelta__act": 87.70301624,
"maxdelta__weight": 17.64950166,
"maxdelta__score": 100,
"relsa__E05__6": 0.97,
"relsa__E04__5": 0.96,
"relsa__E02__5": 0.94,
"relsa__E07__5": 0.94,
"relsa__E01__6": 0.93,
"relsa__E03__5": 0.93,
"kde_n": 159,
"kde_bandwidth": 0.09298578192,
"kde_threshold_count": 1,
"kde_threshold_1": 0.7585894213,
}
ROWS = {
"relsa__E05__6": ("E05", 6),
"relsa__E04__5": ("E04", 5),
"relsa__E02__5": ("E02", 5),
"relsa__E07__5": ("E07", 5),
"relsa__E01__6": ("E01", 6),
"relsa__E03__5": ("E03", 5),
}
def kde_values(scores):
rows = scores[~scores["time"].isin(KDE_EXCLUDE_TIMES)]
values = rows["relsa"].to_numpy(dtype=float)
return values[np.isfinite(values)]
def main():
if not Path(COHORT_PATH).is_file():
raise SystemExit(f"{COHORT_PATH} not found: save it from the RELSA Desk page first")
frame = read_relsa_table(COHORT_PATH, id_col="id", time_col=None)
frame["score"] = score_to_percent(frame["score"], max_score=8, baseline_score=0)
prepared = prepare(frame, normalize=NORMALIZE, baseline_time=BASELINE_TIME)
ref_rows = prepared[prepared["condition"].astype(str) == "endpoint"]
reference = build_reference(ref_rows, variables=VARIABLES, turned=TURNED, baseline_time=BASELINE_TIME)
scores = relsa_scores(prepared, reference, drop=[], round_digits=2)
values = kde_values(scores)
kde = find_thresholds(values, bandwidth=None, n_thresholds=None, min_zone_fraction=0.02)
got = {"n_scored": int(np.isfinite(scores["relsa"].to_numpy(dtype=float)).sum())}
for var in VARIABLES:
got[f"maxdelta__{var}"] = reference.maxdelta[var]
for key, (animal, time) in ROWS.items():
row = scores[(scores["id"].astype(str) == animal) & (scores["time"] == time)]
got[key] = float(row["relsa"].iloc[0])
got["kde_n"] = kde.n
got["kde_bandwidth"] = kde.bandwidth
got["kde_threshold_count"] = len(kde.thresholds)
for k, t in enumerate(kde.thresholds, start=1):
got[f"kde_threshold_{k}"] = t
bad = [k for k, want in EXPECTED.items()
if k not in got or not math.isclose(got[k], want, rel_tol=1e-6, abs_tol=1e-9)]
for k in bad:
print(f"MISMATCH {k}: got {got.get(k)!r}, expected {EXPECTED[k]!r}")
print("reproduction:", "OK" if not bad else f"{len(bad)} mismatch(es)")
# Fix 1 (F2): repeat the KDE across the sweep's bandwidth factors.
for factor in SWEEP:
r = find_thresholds(values, bandwidth=kde.bandwidth * factor, min_zone_fraction=0.02)
print(f"bandwidth x{factor}: thresholds {[round(t, 3) for t in r.thresholds]}")
# Fix 2 (F1): re-score without score and compare each animal's peak.
without = relsa_scores(prepared, reference, drop=["score"], round_digits=2)
before = scores.groupby("id")["relsa"].max()
after = without.groupby("id")["relsa"].max()
for animal in before.index:
print(f"{animal}: peak {before[animal]} -> {after[animal]} without score")
if __name__ == "__main__":
main()
The reply's script must put all 15 keys of facts.expected into
EXPECTED with the browser's values, check each with math.isclose, and
answer F1 and F2 with fixes from the allowed list. Save the page's cohort.csv next to
the script, point SKILL_SCRIPTS at the skill's scripts folder and run it
with python relsa_check.py.
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.