← Traj Desk / API
Tokens

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

taskwhat you getextra input
interpretA 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
scriptThe 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.

fieldrequiredmeaning
taskyesinterpret or script. These are the only two lanes.
factsyesA 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.
titlenoA label for the analysis, up to 160 characters.
contextnoYour notes: the system, force field, simulation length, how the frames were written, what you want to conclude. Up to 2,500 characters.
questionnoAnswered in tldr as a bullet starting "Answer:". Up to 1,500 characters.
decisionscript onlyPlain 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_notenoOnly 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

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe 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.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA 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
}

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"}}

3. Check the session and the balance

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

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.

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

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}

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"])'

Invariants worth asserting

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.