Drive Traj Desk from your own code
A reading of the frames, not of the molecule. The model reads the analysis your browser (or your script) computed; it never recomputes a number. RMSD, RMSF, radius of gyration and contacts describe the frames you analysed - they do not establish convergence, stability or binding affinity.
Everything the web page does is available over HTTP. Analyse the trajectory with the page's own
trajkit.js (MDAnalysis 2.9.0 semantics, checked against MDAnalysis itself: the PDB
reader and parser, the selection language, align.AlignTraj in memory,
rms.RMSD, rms.RMSF, the mass-weighted radius of gyration and
distance-based ligand contacts), send the facts, and get back a verdict (sound,
caveated, unreliable) and either a reading of every metric and of what
the frames show or an MDAnalysis script that reproduces every number and runs follow-up checks.
The natural loop: paste the trajectory, read, script, change the settings (a later start frame,
another selection), re-check. Traj Desk is derived from the agent skill
@k-dense-ai/molecular-dynamics (k-dense-ai/scientific-agent-skills), section
"Trajectory Analysis with MDAnalysis".
Two lanes: the task field
| task | what you get | extra input |
|---|---|---|
interpret | A reading of each metric (M1..M7, in order: frames and selection, RMSD, RMSD plateau over the second half, largest RMSD step, RMSF, radius of gyration, ligand contacts), what the frames show in words (how RMSD and radius of gyration move, which residues fluctuate most, where the ligand sits and when it loses contact), your claims judged against the facts, and what the analysis cannot show. | none |
script | The fixes (a later start frame, an alternative selection, block averages, a periodic-wrap check on C-alpha distances, CSV output, per-residue contact occupancy) and one complete Python script: TRAJ_PATH = "trajectory.pdb", the page's settings as constants, Universe(TRAJ_PATH, dt=DT_PS), align.AlignTraj(..., in_memory=True), rms.RMSD, rms.RMSF with per-residue means indexed by position in the selection, radius_of_gyration(), distance_array contacts, 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 or script. These are the only two lanes. |
facts | yes | A JSON-encoded string holding the browser's analysis - see below. The page builds it with TrajKit.buildInput; an API caller normally runs the free page or reproduces it. |
title | no | A label for the analysis, up to 160 characters. |
context | no | Your notes: the system, force field, simulation length, how the frames were written, what you want to conclude. Up to 2,500 characters. |
question | no | Answered in tldr as a bullet starting "Answer:". Up to 1,500 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 dynamics point, then the next steps). Up to 5,000 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 analyses the
trajectory, serialises the result with JSON.stringify and sends that text. It holds:
settings (selection, ref_frame, start_frame,
dt_ps, align_rmsf, protein_selection,
ligand_selection, radius, n_frames, n_atoms,
n_residues, segments, has_element_column,
trajectory_file and the MDAnalysis version); rmsd_series and
rg_series (up to 17 sampled frames: first, last, the RMSD maximum and evenly spaced
frames, each frame, time_ps, value in Angstrom);
rmsf_top and rmsf_lowest (the 10 most and 3 least mobile residues:
resid, resname, segid, rmsf);
contacts (frames, frames_without_contact,
ligand, occupancy per resid) or null; metrics
(M1..M7, each metric, value, further fields and
basis); flags (F1.. with severity high / medium
/ low, category, message and refs);
skill_code_notes (what the skill's own snippets would do on this input);
browser_verdict; expected (the exact values an MDAnalysis reproduction
must match: n_frames, n_selected_atoms, rmsd_mean,
rmsd__frame_<k>, rmsf_mean, rmsf_residue__<resid>,
rg__frame_0, rg_mean and, with a ligand,
contact_bound_fraction and contact_fraction__<resid>) and
expected_count; and clipped (what was left out for length).
The object below is what the page computes for its first example, the 38 NMR models of the Trp-cage miniprotein (wwPDB entry 1L2Y), with the backbone selection. Long lists and strings are shortened with "...":
{
"settings": {"selection": "backbone", "ref_frame": 0, "start_frame": 0, "dt_ps": 1, "align_rmsf": true, "protein_selection": "protein", "ligand_selection": "resname LIG", "radius": 4.5, "n_frames": 38, "n_atoms": 304, "n_residues": 20, "segments": ["A"], "has_element_column": true, "trajectory_file": "trajectory.pdb", "mdanalysis": "2.9.0"},
"rmsd_series": [
{"frame": 0, "time_ps": 0, "value": 0},
{"frame": 2, "time_ps": 2, "value": 1.20711},
{"frame": 5, "time_ps": 5, "value": 1.15082},
{"...": "13 more sampled frames"}
],
"rg_series": [
{"frame": 0, "time_ps": 0, "value": 6.91094},
{"frame": 2, "time_ps": 2, "value": 6.88267},
{"...": "14 more"}
],
"rmsf_top": [
{"resid": 1, "resname": "ASN", "segid": "A", "rmsf": 1.58348},
{"resid": 20, "resname": "SER", "segid": "A", "rmsf": 1.54611},
{"resid": 19, "resname": "PRO", "segid": "A", "rmsf": 0.70605},
{"...": "7 more"}
],
"rmsf_lowest": [
{"resid": 6, "resname": "TRP", "segid": "A", "rmsf": 0.209008},
{"resid": 7, "resname": "LEU", "segid": "A", "rmsf": 0.211785},
{"resid": 10, "resname": "GLY", "segid": "A", "rmsf": 0.257599}
],
"contacts": null,
"metrics": [
{"metric": "trajectory", "value": 38, "atoms": 304, "selected_atoms": 80, "selected_residues": 20, "duration_ps": 37, "basis": "frames (MODEL records), atoms per frame, atoms and residues ...", "id": "M1"},
{"metric": "rmsd", "value": 0.945648, "sd": 0.288532, "max": 1.55246, "max_frame": 11, "final": 0.77418, "basis": "Angstrom; mean, standard deviation, maximum and final-frame ...", "id": "M2"},
{"...": "M3-M7"}
],
"flags": [
{"id": "F1", "severity": "low", "category": "no_equilibration_discarded", "message": "RMSF uses every frame from frame 0. The skill's own practice is to analyse only the equili...", "refs": ["M5"]}
],
"skill_code_notes": [
"compute_rmsf: the skill indexes R.results.rmsf (one value per SELECTED atom) with res_atoms.indices (UNIVERSE ..."
],
"browser_verdict": "sound",
"expected": {"n_frames": 38, "n_selected_atoms": 80, "rmsd_mean": 0.9456483337, "rmsd__frame_37": 0.7741796112, "rmsd__frame_11": 1.55245563, "rmsd__frame_19": 1.202749288, "rmsf_mean": 0.4871234851, "rmsf_residue__1": 1.583478611, "rmsf_residue__20": 1.546110999, "rmsf_residue__19": 0.7060498899, "rg__frame_0": 6.910938862, "rg_mean": 6.881494309},
"clipped": [
"RMSD and radius-of-gyration series sampled at 17 of 38 frames (first, last, the RMSD maximum and evenly spaced frames)",
"per-residue RMSF: the 10 most and 3 least mobile of 20 residues"
],
"expected_count": 12
}
To copy the full object 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 modules
in Node. trajkit.js needs pdbtraj.js (the PDB reader and selection
language) and mdcore.js (alignment, RMSD, RMSF, radius of gyration, contacts) next to
it, and all three export themselves with module.exports.
// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://traj-desk.skillsafe.ai/trajkit.js, pdbtraj.js and mdcore.js next to this file, then:
// node make-body.js trajectory.pdb interpret "backbone" 0 0 "resname LIG" "title" "notes" "question" > body.json
const fs = require("fs");
const K = require("./trajkit.js");
const [file, lane = "interpret", selection = "backbone", ref_frame = "0", start_frame = "0", ligand = "resname LIG",
title = "", context = "", question = "", decision = ""] = process.argv.slice(2);
const set = { lane, selection, ref_frame, start_frame, ligand, title, context, question, decision,
dt: "1", align: "aligned", protein: "protein", radius: "4.5", data: fs.readFileSync(file, "utf8") };
const X = K.analyze(set);
if (X.empty) throw new Error(X.errors.join("; ") || "no trajectory");
const body = K.mustBeObject(K.buildInput(X, set));
console.error("browser verdict:", X.hint, "| frames:", X.T.n_frames, "| flags:", X.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: traj-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("traj-desk-trp-cage-interpret.json")) # the page's .json download
facts = export["browser"]
body = {
"task": "interpret",
"title": "Trp-cage TC5b, 38 NMR models",
"context": "These are the 38 NMR models of PDB 1L2Y, not an MD run. ...",
"question": "Which residues move most across the models?",
"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":"traj-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="traj-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://traj-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 = "traj-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://traj-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 = "traj-desk";
// Paste the token from https://traj-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 = "traj-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://traj-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 TrajDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "traj-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 = "traj-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://traj-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 = "traj-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 TrajDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "traj-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":"traj-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://traj-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":"traj-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://traj-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": "traj-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://traj-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: "traj-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://traj-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":"traj-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://traj-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\":\"traj-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://traj-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: "traj-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://traj-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" => "traj-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://traj-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\":\"traj-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 TrajDesk.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, TrajKit.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 TrajDesk.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, traj-desk:<lane>:<hash>:a<attempt> (for example
traj-desk:interpret:mt6z7s12xnl3e:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: changed frames, settings or notes (so changed
facts) or a changed reading are a new hash, the same trajectory in the other lane is a new key, and replaying
an old key with a different body is a 409. The page uses
TrajKit.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="traj-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"traj-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 = `traj-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("traj-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 = "traj-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 = "traj-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 = "traj-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 = $"traj-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 TrajDesk.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("traj_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("traj_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..M7in the order offacts.metrics) anddynamicsis not empty. - Every number in the prose exists in
factsor your notes (after rounding to 3 significant figures; a fraction may be written as a percentage and a time in ps in ns). Residue labels such as VAL47 are names, not numbers, and every residue named exists in the trajectory. - The prose describes the frames, not the molecule: no "stable", "unfolded", "converged" or "binds tightly" as a fact, and nothing about affinity or free energy.
- In the script lane
TRAJ_PATHis"trajectory.pdb";SELECTION,REF_FRAME,START_FRAME,DT_PS,ALIGN_RMSF,PROTEIN_SELECTION,LIGAND_SELECTIONandRADIUSequalfacts.settings; the Universe is loaded withdt=DT_PS;AlignTraj(..., in_memory=True)runs beforerms.RMSD(..., ref_frame=REF_FRAME);rms.RMSF(...).run(start=START_FRAME); per-residue RMSF selects byatoms.resindices == res.resindexand never indexes by.indices; contacts usedistance_arrayand nevercontact_matrix;EXPECTEDcarries every key offacts.expectedwith its value; imports come only fromMDAnalysis,MDAnalysis.analysis.rms,MDAnalysis.analysis.align,MDAnalysis.lib.distances,numpy,math,json,pathlibandwarnings; nothing touches pickle, a subprocess or the network; the work sits under a__main__guard. - The page's
recon.jschecks all of this; you can run it in Node the same way astrajkit.js. The page's trajectory.pdb (for the script) button writes exactly the text the browser analysed, which is the file the script expects.
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
"dynamics": ["1-5 strings: RMSD and radius of gyration over time, the most mobile residues, the ligand's contacts"],
"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 step
"script": "import math\nimport MDAnalysis as mda\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 TRAJ_PATH = "trajectory.pdb"
and check it is an existing local file; set the settings constants to the literal values in
facts.settings; load MDAnalysis.Universe(TRAJ_PATH, dt=DT_PS); run
align.AlignTraj(u, u, select=SELECTION, in_memory=True) as the skill's
compute_rmsd does; compute rms.RMSD(u, select=SELECTION, ref_frame=REF_FRAME);
compute rms.RMSF(atoms).run(start=START_FRAME) (on a fresh, unaligned Universe when
ALIGN_RMSF is False) and per-residue means indexed by position in the selection;
compute atoms.radius_of_gyration() per frame; compute contacts from
START_FRAME with distances.distance_array(protein.positions, ligand.positions) < RADIUS;
define EXPECTED; check each key with math.isclose(got, want, rel_tol=1e-5, abs_tol=1e-4);
then apply the fixes. A value the facts do 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 third example: a synthetic 30-frame trajectory built from wwPDB entry
1STP (streptavidin with biotin, residue name BTN) by adding random motion, with the biotin moving
out of the pocket from frame 18 - not a simulation. With the backbone selection, frames from 6 on,
100 ps per frame and resname BTN as the ligand, the browser finds the ligand without
a protein atom within 4.5 A in 7 of the 24 analysed frames (F1 ligand_unbound (medium)), so its read is
caveated. The body, with facts abbreviated (send the full string from
make-body.js or the page):
{
"task": "interpret",
"title": "Streptavidin with biotin, 30 frames",
"context": "Synthetic demonstration trajectory built from PDB 1STP, one frame per 100 ps, first 6 frames treated as equilibration. I want to say biotin stays bound through the run.",
"question": "Which residues hold the biotin, and when does it lose contact?",
"facts": "{\"settings\":{\"selection\":\"backbone\",\"ref_frame\":0,\"start_frame\":6,\"dt_ps\":100,\"ligand_selection\":\"resname BTN\",\"radius\":4.5,\"n_frames\":30},\"...\":\"series, rmsf_top, contacts, metrics M1-M7\",\"flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"ligand_unbound\",\"...\":\"...\"}],\"browser_verdict\":\"caveated\",\"expected\":{\"n_frames\":30,\"n_selected_atoms\":484,\"rmsd_mean\":0.7114763633,\"...\":\"12 more\"},\"expected_count\":15}"
}
The reply a validation run produced for this exact input (the job's output.output, parsed; lists shortened with "..."):
{
"lane": "interpret",
"verdict": "caveated",
"headline": "Backbone RMSD rises to 1.10 Angstrom over 2900 ps while radius of gyration stays flat, and BTN loses all contact with the protein in the trajectory's final frames, 24-29.",
"tldr": [
"Answer: BTN's top contacts are VAL47 (0.7083), ASN49 (0.6667), SER45 and GLY48 (0.625 each); contact is full through frames 6-11 and 12-17, falls to 0.8333 in frames 18-23, and is 0 in frames 24-29 (M7, F1).",
"Backbone RMSD to frame 0 climbs from 0 to 1.10 Angstrom by frame 29, with block means rising each quarter from 0.409 to 0.978 Angstrom (M2, M3).",
"Radius of gyration barely moves: 14.8 Angstrom at frame 0 versus a mean of 14.8 Angstrom, a 0.248% relative change (M6).",
"GLY68, SER45 and VAL47 are the most mobile backbone residues (RMSF 1.06-0.897 Angstrom); TRP108 is least mobile (0.229 Angstrom) (M5).",
"The claim that biotin stays bound through the run is not supported: contact is 0 in frames 24-29 and the last frame's nearest protein atom is 16.4 Angstrom away."
],
"metrics": [
{
"id": "M1",
"reading": "M1 counts 30 frames of 917 atoms; the \"backbone\" selection matches 484 atoms across 121 of 122 residues, spanning 2900 ps (2.9 ns) at 100 ps between frames, MDAnalysis 2.9.0."
},
{
"id": "M2",
"reading": "M2 gives backbone RMSD to frame 0 after alignment with mean 0.711 Angstrom (sd 0.242), rising to its maximum and final value of 1.10 Angstrom at frame 29."
},
{
"id": "M3",
"reading": "M3 shows RMSD still rising in the second half, slope 0.251 Angstrom per ns; quarter means climb from 0.409 to 1.00 Angstrom, and block means rise 0.409, 0.602, 0.781, 0.978 Angstrom across frames 0-6, 7-13, 14-20, 21-29 - not a plateau."
},
{
"...": "4 more, one per metric"
}
],
"dynamics": [
"Backbone RMSD to frame 0 rises through the run, 0 at frame 0 to 1.10 Angstrom at frame 29 (M2), with block means increasing each quarter from 0.409 to 0.978 Angstrom (M3) and its largest single-frame jump, 0.354 Angstrom, at frame 1 (M4).",
"Radius of gyration stays close to its start, 14.8 Angstrom at frame 0 versus a mean of 14.8 Angstrom, a 0.248% relative change (M6).",
"..."
],
"claims": [
{
"claim": "Biotin stays bound through the run",
"support": "not_supported",
"why": "M7 and F1 show 7 of 24 frames (29.2%) with no protein atom within 4.5 Angstrom of BTN, all in frames 24-29 (bound fraction 0), and the last frame's nearest protein atom is 16.4 Angstrom away; contact is full only through frames 6-11 and 12-17 and 0.8333 in frames 18-23."
}
],
"cautions": [
"This RMSD, radius of gyration and contact analysis describes only the 30 frames pasted and cannot show convergence or statistical significance beyond this sample.",
"Contacts use a 4.5 Angstrom cutoff with no periodic images, so the frames 24-29 loss of contact could reflect BTN leaving the site or being wrapped across a periodic boundary, which this check cannot distinguish (F1).",
"..."
],
"next_steps": [
"Check whether the frames 24-29 contact loss is BTN leaving the site or a periodic-image wrap, since the 4.5 Angstrom check applies no box (F1).",
"Consider a later start_frame than 6 to see whether the RMSD rise (M3) and the frames 24-29 contact loss (M7) persist once more early frames are excluded.",
"..."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "Contacts.occupancy and M7 show 7 of 24 frames (29.2%) with no protein atom within 4.5 Angstrom of BTN, concentrated in frames 24-29 (bound fraction 0) with the last frame's minimum distance 16.4 Angstrom - consistent with the ligand leaving contact or a periodic wrap this check cannot rule out."
}
]
}
Worked example: script
The same trajectory and settings, with the reading above handed over as decision
(the page's "Write the script for this reading" button does this). The body, abbreviated:
{
"task": "script",
"title": "Streptavidin with biotin, 30 frames",
"context": "Synthetic demonstration trajectory built from PDB 1STP, one frame per 100 ps, first 6 frames treated as equilibration. I want to say biotin stays bound through the run.",
"decision": "Verdict: caveated.\nBackbone RMSD rises to 1.10 Angstrom over 2900 ps while radius of gyration stays flat, and BTN loses all contact with the protein in the traj ...",
"facts": "{\"settings\":{\"selection\":\"backbone\",\"ref_frame\":0,\"start_frame\":6,\"dt_ps\":100,\"ligand_selection\":\"resname BTN\",\"radius\":4.5,\"n_frames\":30},\"...\":\"series, rmsf_top, contacts, metrics M1-M7\",\"flags\":[{\"id\":\"F1\",\"severity\":\"medium\",\"category\":\"ligand_unbound\",\"...\":\"...\"}],\"browser_verdict\":\"caveated\",\"expected\":{\"n_frames\":30,\"n_selected_atoms\":484,\"rmsd_mean\":0.7114763633,\"...\":\"12 more\"},\"expected_count\":15}"
}
The reply a validation run produced for this exact input (the script cut after 40 lines):
{
"lane": "script",
"verdict": "caveated",
"headline": "This script reproduces the browser's backbone analysis of a caveated run: RMSD keeps rising through the 2900 ps trajectory while BTN contact with the protein drops to zero in frames 24-29.",
"tldr": [
"The script reloads trajectory.pdb, reruns AlignTraj, RMSD, RMSF, radius of gyration and BTN contacts exactly as the browser did, then checks every facts.expected value with math.isclose.",
"Backbone RMSD to frame 0 keeps rising through the run, 0 at frame 0 to 1.10 Angstrom at frame 29 (M2, M3), not a plateau.",
"BTN loses all contact with the protein in 7 of 24 analysed frames, all at the end of the run (M7, F1); this does not match the context's claim that biotin stays bound through the run.",
"Radius of gyration stays close to flat, 14.8 Angstrom at frame 0 versus a 14.8 Angstrom mean (M6)."
],
"fixes": [
{
"fix": "List frames in which consecutive C-alpha atoms of the protein selection are more than 8 Angstrom apart.",
"why": "F1 reports BTN losing all contact in frames 24-29 under a check that applies no periodic box; a break in the protein's own backbone at the same frames would point to a wrap rather than the ligand leaving.",
"refs": "F1"
},
{
"fix": "Recompute RMSF and contacts from a later start frame, LATER_START_FRAME.",
"why": "M3's RMSD is still rising through frames 21-29, the same frames where F1's contact loss occurs; a later start would show whether both hold once more early frames are excluded. No later start frame value appears in facts or the notes, so the constant is None and the step is skipped.",
"refs": "F1"
},
{
"fix": "Print per-residue BTN contact occupancy for the frames from START_FRAME.",
"why": "F1's contact loss is uneven across residues, with VAL47, ASN49, SER45 and GLY48 holding the highest occupancy in M7; printing it from the script shows where contact was held longest before it dropped.",
"refs": "F1"
},
{
"fix": "Write rmsd.csv and rmsf_per_residue.csv.",
"why": "Keeps the per-frame RMSD series and per-residue RMSF available alongside the console output for later review.",
"refs": ""
},
{
"fix": "Print block averages of RMSD over 4 blocks.",
"why": "M3's four block means, 0.409, 0.602, 0.781 and 0.978 Angstrom, show the RMSD rise is not levelling off; printing them from the script confirms the reproduction agrees.",
"refs": ""
}
],
"script": "\"\"\"Traj Desk reproduction of the browser MDAnalysis run.\"\"\"\nimport math\nimport warnings\nfrom pathlib import Path\n\nimport numpy as np\nimport MDAnalysis\nfrom MDAnalysis.analysis import align, rms\nfrom MDAnalysis.lib import distances\n\nTRAJ_PATH = \"trajectory.pdb\"\nSELECTION = \"backbone\"\nREF_FRAME = 0\nSTART_FRAME = 6\nDT_PS = 100\nALIGN_RMSF = True\nPROTEIN_SELECTION = \"protein\"\nLIGAND_SELECTION = \"resname BTN\"\nRADIUS = 4.5\n\nLATER_START_FRAME = None # AUTHOR_INPUT_NEEDED: no later start frame is named in facts or notes\nN_BLOCKS = 4 # matches facts.metrics M3's four block means\n\nEXPECTED = {\n \"n_frames\": 30,\n \"n_selected_atoms\": 484,\n \"rmsd_mean\": 0.7114763633,\n \"rmsd__frame_29\": 1.100318017,\n \"rmsd__frame_15\": 0.7120816431,\n \"rmsf_mean\": 0.4075868386,\n \"rmsf_residue__68\": 1.056025926,\n \"rmsf_residue__45\": 0.9091704102,\n \"rmsf_residue__47\": 0.8968799061,\n \"rg__frame_0\": 14.82029566,\n \"rg_mean\": 14.83416129,\n \"contact_bound_fraction\": 0.7083333333,\n \"contact_fraction__47\": 0.7083333333,\n \"contact_fraction__49\": 0.6666666667,\n \"contact_fraction__45\": 0.625,\n}\n# ... 153 more lines",
"assumptions": [
"trajectory.pdb in the working directory is the same multi-model PDB the page analysed, with 917 atoms and 30 MODEL records in the same order.",
"The installed MDAnalysis is 2.9.0, matching facts.settings.mdanalysis, so RMSF results index by position in the selection and distance_array behaves as described.",
"..."
],
"checks": [
"The EXPECTED comparison prints no MISMATCH line for any of the 15 keys, including rmsd__frame_29, rmsf_residue__68 and contact_fraction__47.",
"Whether the C-alpha jump check flags any frame in 24-29, which would point to a periodic wrap rather than BTN leaving the site (F1).",
"How the per-residue contact occupancy for VAL47, ASN49, SER45 and GLY48 changes once a later start frame is filled into LATER_START_FRAME.",
"Whether the printed block averages of RMSD keep rising across all four blocks, matching M3's 0.409, 0.602, 0.781, 0.978 Angstrom progression."
],
"next_steps": [
"Run the script and confirm the EXPECTED check prints no mismatch before drawing further conclusions from M2, M5 and M7.",
"Use the C-alpha jump check to test whether the frames 24-29 contact loss is BTN leaving the site or a periodic-image wrap, since the 4.5 Angstrom check applies no box (F1).",
"..."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "BTN has no protein atom within 4.5 Angstrom in 7 of 24 analysed frames, all at the end of the run (M7); since the check applies no periodic box this may be the ligand leaving or an un-corrected wrap, so the script adds a C-alpha jump check rather than dismissing it."
}
]
}
The full script from this reply was run with MDAnalysis 2.9.0 against the page's
trajectory.pdb for this example: every one of the 15 values in EXPECTED
matched. Save the page's trajectory.pdb next to the script and run it with
python traj_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.