Drive MSMS Desk from your own code
Annotation evidence, not identification. The model reads the scores your browser (or your script) computed; it never sees raw files, never rescores a spectrum and never computes a mass. Similarity is evidence, not proof of identity. It is not clinical, forensic or regulatory advice.
Everything the web page does is available over HTTP. Score one query MS/MS spectrum against its
candidate library spectra with the page's own mskit.js (greedy cosine and greedy
modified cosine, matchms semantics), send the scores, and get back a verdict
(probable, tentative, unassigned), a Schymanski et al. (2014)
confidence level, the best candidate, and either a judgement of every candidate or the methods text,
results text and annotation table row for the feature. The natural use is a batch annotation
pipeline: score features locally, send only the ones with a passing candidate, and hold anything
below level 2a for a standard.
Two lanes: the task field
| task | what you get | extra input |
|---|---|---|
annotate | One call per candidate sent (supported, analog, ambiguous, rejected) with evidence for and against and the query peaks that carry it; up to 8 fragment readings; caveats. | none |
report | A methods paragraph for the spectral matching, a results sentence, an annotation table row and limitations, with AUTHOR_INPUT_NEEDED where a fact is missing. | annotation: the text of an earlier annotate run (optional) |
Both lanes return the same envelope: lane, verdict, level, best_candidate, headline, tldr, the lane body, next_evidence and prescan_responses. Worked examples: annotate, report.
Input fields
Every field is a string.
| field | required | meaning |
|---|---|---|
task | yes | annotate or report. |
facts | yes | A JSON-encoded string with the browser's scores - see below. Build it with MSKit.buildInput. |
title | no | A label for the feature, up to 160 characters. |
context | no | Your notes: sample, LC, instrument, collision energy, library source, and any standard, isotope-pattern or retention-time evidence. Up to 3,000 characters. Level 1 is only possible when this says an authentic standard matched. |
annotation | report only | Plain text of an earlier annotation (the page builds it with Recon.annotationText). Up to 8,000 characters. |
question | no | Answered in tldr as a bullet starting "Answer:". Up to 1,200 characters. |
retry_note | no | Only on a retry after a malformed reply. |
The facts string
settings (tolerance in Da, minimum score, minimum matched peaks, relative-intensity
floor, precursor tolerance in ppm, and plain-language descriptions of the processing and the
scores); query (precursor m/z, ion mode, adduct, retention time, up to 60 peaks as
[m/z, relative intensity]); library (spectra read, scored and passing);
candidates - the top candidates by cosine plus up to two analogs only the modified
cosine finds, each with id R1.., name, precursor, adduct, ion mode, formula,
InChIKey, cosine / cosine_matches, modified_cosine /
modified_matches, precursor difference in Da and ppm, explained intensity,
browser_status, the matched pairs that contribute most and up to 30 peaks;
flags (F1.. with severity, category, message and the candidates they
concern); browser_verdict; and clipped.
Building the body
The simplest way to get a body that matches the page byte for byte is to run the page's own module
in Node. mskit.js has no dependencies and exports itself with
module.exports:
// make-body.js - build the exact body the page sends, with the page's own code.
// Save https://msms-desk.skillsafe.ai/mskit.js next to this file, then:
// node make-body.js query.mgf library.msp annotate "Feature 195.0878" "notes" > body.json
const fs = require("fs");
const K = require("./mskit.js");
const [q, lib, lane = "annotate", title = "", context = "", annotation = ""] = process.argv.slice(2);
const A = K.analyze({ lane, title, context, query: fs.readFileSync(q, "utf8"), library: fs.readFileSync(lib, "utf8"),
settings: { tolerance: 0.02, min_score: 0.6, min_matches: 5, intensity_from: 0.01, precursor_ppm: 10, top_n: 8 } });
if (A.empty) throw new Error("need one query spectrum with peaks and at least one reference");
const body = K.mustBeObject(K.buildInput(A, { question: "", annotation }));
console.error("browser verdict:", A.hint, "| passing:", A.passing, "| flags:", A.flags.map(f => f.id + " " + f.category).join(", "));
console.error("idempotency key: msms-desk:" + lane + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
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":"msms-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="msms-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://msms-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 = "msms-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://msms-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 = "msms-desk";
// Paste the token from https://msms-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 = "msms-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://msms-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 MsmsDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "msms-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 = "msms-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://msms-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 = "msms-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 MsmsDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "msms-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":"msms-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://msms-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":"msms-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://msms-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": "msms-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://msms-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: "msms-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://msms-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":"msms-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://msms-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\":\"msms-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://msms-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: "msms-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://msms-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" => "msms-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://msms-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\":\"msms-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 MsmsDesk.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 annotate or report,
facts non-empty, and facts a JSON string that parses to an object (this is
what the page's own guard, MSKit.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 ("annotate","report") 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 ("annotate", "report")
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" || !["annotate", "report"].includes(INPUT.task)) throw new Error("task must be annotate or report");
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"] != "annotate" && input["task"] != "report" {
panic("task must be annotate or report")
}
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*\"(annotate|report)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task annotate or report");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(annotate|report)\".*", "$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 annotate or report" unless %w[annotate report].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"] ?? "", ["annotate", "report"], true)) { throw new Exception("task must be annotate or report"); }
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 != "annotate" && lane != "report") throw new Exception("task must be annotate or report");
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 MsmsDesk.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, msms-desk:<lane>:<hash>:a<attempt> (for example
msms-desk:annotate:1gliaxow6ay8q:a1), so a retried request returns the same job instead
of billing a second run. Use one key per distinct input: changed spectra, settings or notes (so changed
facts) or a changed annotation are a new hash, the same spectra in the other lane are a new key, and replaying
an old key with a different body is a 409. The page uses
MSKit.hashInput(body) for the hash (it covers task, title,
context, facts, annotation 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"])') # annotate or report
KEY="msms-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\":\"annotate\",\"verdict\":\"probable\",\"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"msms-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 = `msms-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("msms-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 = "msms-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 = "msms-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 = "msms-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 = $"msms-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 MsmsDesk.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\":\"annotate\",\"verdict\":\"probable\",\"level\":\"2a"}
# 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["level"],r["best_candidate"])'
reply = json.loads(job["output"]["output"])
assert reply["lane"] == INPUT["task"], "the model answered as another lane"
print(reply["verdict"], reply["level"], reply["best_candidate"]["name"])
for c in reply.get("candidates", []): # annotate lane
print(c["ref"], c["call"], c["key_peaks"])
print(reply.get("methods_text", "")) # report 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.level, reply.best_candidate.name);
for (const c of reply.candidates || []) console.log(c.ref, c.call, c.key_peaks); // annotate lane
console.log(reply.methods_text || ""); // report lane
var reply struct {
Lane, Verdict, Level string
Best struct{ Ref, Name string } `json:"best_candidate"`
Candidates []struct{ Ref, Call string } `json:"candidates"`
MethodsText string `json:"methods_text"`
}
if err := json.Unmarshal([]byte(job.Output.Output), &reply); err != nil { panic(err) }
fmt.Println(reply.Verdict, reply.Level, reply.Best.Name)
// 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("level").asText()
+ " " + reply.get("best_candidate").get("name").asText());
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["lane"] == INPUT["task"]
puts [reply["verdict"], reply["level"], reply["best_candidate"]["name"]].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["level"], " ", $reply["best_candidate"]["name"], "\n";
var reply = JsonSerializer.Deserialize<JsonElement>(outputString);
Console.WriteLine($"{reply.GetProperty("verdict")} {reply.GetProperty("level")} {reply.GetProperty("best_candidate").GetProperty("name")}");
Invariants worth asserting
levelandverdictagree: 1 or 2a isprobable, 2b or 3tentative, 4 or 5unassigned.- The verdict is never looser than
facts.browser_verdictunless the flags that set it were dismissed. best_candidate.refis one of the candidate ids you sent, with its exact name; empty forunassigned.- Every flag id appears once in
prescan_responses; in the annotate lane every candidate id appears once incandidates. - Every score, matched-peak count and m/z quoted exists in
facts. The page'srecon.jschecks all of this; you can run it in Node the same way asmskit.js.
The output contract
{
"lane": "annotate" | "report",
"verdict": "probable" | "tentative" | "unassigned",
"level": "1" | "2a" | "2b" | "3" | "4" | "5",
"best_candidate": {"ref": "R1", "name": "..."},
"headline": "...",
"tldr": ["..."],
// annotate:
"candidates": [{"ref", "call": "supported|analog|ambiguous|rejected", "evidence_for", "evidence_against", "key_peaks": [m/z, ...]}],
"fragments": [{"mz", "reading", "certainty": "tentative|supported"}],
"caveats": ["..."],
// report:
"methods_text": "...", "results_text": "...",
"table_row": {"feature", "precursor_mz", "adduct", "annotation", "level", "score", "matched_peaks", "reference", "evidence"},
"limitations": ["..."],
// both:
"next_evidence": ["..."],
"prescan_responses": [{"ref": "F1", "verdict": "confirmed|dismissed", "note": "..."}]
}
Worked example: annotate
The caffeine example from the page (illustrative spectra). The body, with the peak lists shortened here:
{
"task": "annotate",
"title": "Feature 195.0878 @ 5.20 min",
"context": "Human plasma, protein-precipitated extract. C18 reversed-phase LC, ESI positive. The reference spectra were pasted from our in-house list. No authentic standard has been run yet.",
"facts": "{\"settings\": {\"tolerance_da\": 0.02, \"min_score\": 0.6, \"min_matched_peaks\": 5, \"relative_intensity_floor\": 0.01, \"precursor_tolerance_ppm\": 10, \"processing\": \"same for query and references: non-positive peaks dropped, duplicate m/z merged (max), intensities scaled to the base peak, peaks below the relative-intensity floor removed\", \"scores\": \"greedy cosine and greedy modified cosine, mz_power 0, intensity_power 1, computed in the browser with matchms CosineGreedy / ModifiedCosineGreedy semantics\"}, \"query\": {\"title\": \"Feature 195.0878 @ 5.20 min\", \"precursor_mz\": 195.0878, \"ionmode\": \"positive\", \"adduct\": \"[M+H]+\", \"rt\": \"312\", \"instrument\": \"\", \"collision_energy\": \"\", \"peaks_raw\": 10, \"peaks_after_processing\": 10, \"peaks_sent\": 10, \"peaks\": [[42.0339, 0.1], [56.0496, 0.04], [69.0448, 0.15], [\"...\"]]}, \"library\": {\"spectra_read\": 8, \"spectra_scored\": 8, \"passing\": 1, \"candidates_sent\": 8, \"format\": \"msp\"}, \"candidates\": [{\"ref\": \"R1\", \"rank\": 1, \"name\": \"Caffeine\", \"id\": \"\", \"precursor_mz\": 195.0877, \"adduct\": \"[M+H]+\", \"ionmode\": \"positive\", \"formula\": \"C8H10N4O2\", \"inchikey\": \"RYYVLZVUVIJVGH-UHFFFAOYSA-N\", \"cosine\": 0.9937, \"cosine_matches\": 8, \"modified_cosine\": 0.9937, \"modified_matches\": 8, \"precursor_delta_da\": 0.0001, \"precursor_delta_ppm\": 0.5, \"explained_intensity\": 0.957, \"browser_status\": \"match\", \"top_pairs\": [{\"query_mz\": 138.0663, \"reference_mz\": 138.0662, \"shifted\": false, \"contribution\": 0.8213}, \"...\"], \"peaks\": [[42.0338, 0.08], [56.0495, 0.05], [\"...\"]]}, {\"ref\": \"R2\", \"rank\": 2, \"name\": \"Theobromine\", \"id\": \"\", \"precursor_mz\": 181.072, \"adduct\": \"[M+H]+\", \"ionmode\": \"positive\", \"formula\": \"C7H8N4O2\", \"inchikey\": \"YAPQBXQYLJRXSA-UHFFFAOYSA-N\", \"cosine\": 0.8663, \"cosine_matches\": 2, \"modified_cosine\": 0.9649, \"modified_matches\": 3, \"precursor_delta_da\": 14.0158, \"precursor_delta_ppm\": 77404.6, \"explained_intensity\": 0.616, \"browser_status\": \"weak\", \"top_pairs\": [{\"query_mz\": 138.0663, \"reference_mz\": 138.0662, \"shifted\": false, \"contribution\": 0.8219}, \"...\"], \"peaks\": [[67.0291, 0.1], [110.0713, 0.18], [\"...\"]]}, \"... 6 more\"], \"flags\": [{\"id\": \"F1\", \"severity\": \"medium\", \"category\": \"ion_mode_conflict\", \"message\": \"2 candidate(s) were recorded in the other ion mode from the query (positive); their spectra are not comparable as an identity match.\", \"refs\": [\"R6\", \"R7\"]}, {\"id\": \"F2\", \"severity\": \"medium\", \"category\": \"high_score_few_matches\", \"message\": \"1 reference(s) score at or above 0.6 on fewer than 5 matched peaks (Theobromine: 0.866 on 2); keep score and matched peaks together.\", \"refs\": [\"R2\"]}, {\"id\": \"F3\", \"severity\": \"low\", \"category\": \"analog_candidates\", \"message\": \"3 candidate(s) pass only on the modified cosine (1-Methylxanthine 0.965, shift +28.0314 Da; Theophylline 0.986, shift +14.0158 Da; Paraxanthine 0.967, shift +14.0158 Da): structural relatives, not identities.\", \"refs\": [\"R3\", \"R4\", \"R5\"]}], \"browser_verdict\": \"probable\", \"clipped\": {\"query_peaks_not_sent\": 0, \"library_spectra_not_scored\": 0}}",
"question": ""
}
The reply (a saved model run):
{
"lane": "annotate",
"verdict": "probable",
"level": "2a",
"best_candidate": {
"ref": "R1",
"name": "Caffeine"
},
"headline": "The query spectrum matches the Caffeine reference with cosine 0.994 on 8 matched peaks and a precursor difference of 0.5 ppm, supporting a probable structure at level 2a pending an authentic standard.",
"tldr": [
"Caffeine (R1) is the only candidate with browser status match: cosine 0.9937 on 8 matched peaks, 95.7% of query intensity explained, precursor 195.0877 vs 195.0878 (0.5 ppm).",
"Theophylline, Paraxanthine and 1-Methylxanthine pass only on the modified cosine with precursor shifts of 14.0158 or 28.0314 Da and are structural relatives, not identities.",
"Theobromine scores 0.866 on only 2 matched peaks with a 14.0158 Da precursor difference and is below the matched-peak threshold.",
"No authentic standard has been run, so level 1 is not supported; retention time confirmation is the next check."
],
"candidates": [
{
"ref": "R1",
"call": "supported",
"evidence_for": "Cosine 0.9937 on 8 matched peaks (modified cosine identical), explained intensity 0.957, same positive ion mode and [M+H]+ adduct, precursor 195.0877 within 0.0001 Da (0.5 ppm). Unshifted matches at 138.07 (base peak), 110.07, 195.09, 69.04, 83.06, 42.03, 123.04 and 56.05.",
"evidence_against": "Query peaks at 91.05 and 150.07 have no counterpart in the reference spectrum. No retention time or authentic standard evidence is supplied.",
"key_peaks": [
138.0663,
110.0714,
195.0879,
69.0448,
83.0605,
42.0339
]
},
{
"ref": "R2",
"call": "rejected",
"evidence_for": "Shares unshifted peaks at 138.07 and 110.07; modified cosine 0.9649 on 3 matched peaks.",
"evidence_against": "Browser status weak: cosine 0.8663 on only 2 matched peaks, below the 5-peak threshold. Precursor 181.072 differs by 14.0158 Da (77404.6 ppm), so it cannot be the query compound.",
"key_peaks": [
138.0663,
110.0714
]
},
{
"ref": "R3",
"call": "analog",
"evidence_for": "Modified cosine 0.9652 on 5 matched peaks; query 138.07, 195.09 and 110.07 align with reference 110.03, 167.06 and 82.04 after the precursor shift.",
"evidence_against": "Plain cosine 0.0315 on 2 matched peaks, explained intensity 0.118; precursor 167.0564 differs by 28.0314 Da. Passes only on the modified cosine.",
"key_peaks": [
138.0663,
195.0879,
110.0714
]
},
{
"ref": "R4",
"call": "analog",
"evidence_for": "Modified cosine 0.9857 on 5 matched peaks; query 138.07, 195.09 and 110.07 align with reference 124.05, 181.07 and 96.06 after the shift.",
"evidence_against": "Plain cosine 0.0286 on 2 matched peaks, explained intensity 0.118; precursor 181.072 differs by 14.0158 Da (77404.6 ppm). A shifted-fragment relative, not an identity match.",
"key_peaks": [
138.0663,
195.0879,
110.0714
]
},
{
"ref": "R5",
"call": "analog",
"evidence_for": "Modified cosine 0.9671 on 5 matched peaks; query 138.07, 195.09 and 110.07 align with reference 124.05, 181.07 and 96.06 after the shift.",
"evidence_against": "Plain cosine 0.0278 on 2 matched peaks, explained intensity 0.118; precursor 181.072 differs by 14.0158 Da (77404.6 ppm). A shifted-fragment relative, not an identity match.",
"key_peaks": [
138.0663,
195.0879,
110.0714
]
},
{
"ref": "R6",
"call": "rejected",
"evidence_for": "None beyond a single shifted precursor pairing (modified cosine 0.0965 on 1 peak).",
"evidence_against": "Recorded in negative ion mode as [M-H]- (ion mode conflict); cosine 0 on 0 matched peaks; precursor 179.035 differs by 16.0528 Da.",
"key_peaks": []
},
{
"ref": "R7",
"call": "rejected",
"evidence_for": "None beyond a single shifted precursor pairing (modified cosine 0.0607 on 1 peak).",
"evidence_against": "Recorded in negative ion mode as [M-H]- (ion mode conflict); cosine 0 on 0 matched peaks; precursor 191.0197 differs by 4.0681 Da.",
"key_peaks": []
},
{
"ref": "R8",
"call": "rejected",
"evidence_for": "Same positive ion mode and [M+H]+ adduct.",
"evidence_against": "Cosine 0 on 0 matched peaks, modified cosine 0.0508 on 1 peak, explained intensity 0; precursor 163.123 differs by 31.9648 Da.",
"key_peaks": []
}
],
"fragments": [
{
"mz": 138.0663,
"reading": "Base peak, matched unshifted to Caffeine at 138.0662 and carrying most of the cosine; appears shifted in Theophylline and Paraxanthine, consistent with a fragment retaining the extra methyl group (tentative).",
"certainty": "supported"
},
{
"mz": 195.0879,
"reading": "Protonated precursor, matched unshifted to the Caffeine precursor at 195.0877.",
"certainty": "supported"
},
{
"mz": 110.0714,
"reading": "Matched unshifted to Caffeine at 110.0713; shifted in the dimethylxanthine and 1-Methylxanthine references.",
"certainty": "supported"
},
{
"mz": 69.0448,
"reading": "Matched unshifted to Caffeine and to the analog references, suggesting a fragment common to the xanthine core (tentative).",
"certainty": "tentative"
},
{
"mz": 42.0339,
"reading": "Low-mass fragment shared unshifted with Caffeine and the analog references.",
"certainty": "tentative"
},
{
"mz": 83.0605,
"reading": "Matched unshifted to Caffeine at 83.0604; not present in the analog references.",
"certainty": "supported"
},
{
"mz": 91.0542,
"reading": "Unexplained by any candidate reference; low intensity, possibly co-isolated background or a collision-energy difference.",
"certainty": "tentative"
},
{
"mz": 150.066,
"reading": "Unexplained by any candidate reference; low intensity, origin not supported by the supplied spectra.",
"certainty": "tentative"
}
],
"caveats": [
"Positional isomers or other compounds absent from the in-house list cannot be excluded by library matching alone.",
"Two low-intensity query peaks are unexplained, which may reflect co-isolation in the plasma matrix or collision-energy differences between query and reference.",
"The instrument and collision energy of the query are not stated, so comparability with the reference spectra is not documented."
],
"next_evidence": [
"Measure an authentic standard of Caffeine under the same C18 LC method and compare retention time and MS/MS with the feature at 5.20 min.",
"Record the query and reference spectra at documented, matching collision energies and instrument settings.",
"Check the isotope pattern and any other adducts of the 195.0878 feature to support the molecular formula.",
"Compare against an independent public reference spectrum of Caffeine to confirm the in-house entry."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "Caffeic acid and Citric acid were recorded as [M-H]- in negative mode while the query is positive; both are rejected and play no part in the annotation."
},
{
"ref": "F2",
"verdict": "confirmed",
"note": "Theobromine scores 0.866 on only 2 matched peaks, below the 5-peak threshold, with a 14.0158 Da precursor difference; it is rejected and does not compete with Caffeine."
},
{
"ref": "F3",
"verdict": "confirmed",
"note": "1-Methylxanthine, Theophylline and Paraxanthine pass only on the modified cosine with precursor shifts of 28.0314 or 14.0158 Da; they are called analogs, and only Caffeine matches the query precursor."
}
]
}
Worked example: report
The same feature, with the annotation above handed over as annotation:
{
"task": "report",
"title": "Feature 195.0878 @ 5.20 min",
"context": "Human plasma, protein-precipitated extract. C18 reversed-phase LC, ESI positive. The reference spectra were pasted from our in-house list. No authentic standard has been run yet.",
"facts": "{\"settings\": {\"tolerance_da\": 0.02, \"min_score\": 0.6, \"min_matched_peaks\": 5, \"relative_intensity_floor\": 0.01, \"precursor_tolerance_ppm\": 10, \"processing\": \"same for query and references: non-positive peaks dropped, duplicate m/z merged (max), intensities scaled to the base peak, peaks below the relative-intensity floor removed\", \"scores\": \"greedy cosine and greedy modified cosine, mz_power 0, intensity_power 1, computed in the browser with matchms CosineGreedy / ModifiedCosineGreedy semantics\"}, \"query\": {\"title\": \"Feature 195.0878 @ 5.20 min\", \"precursor_mz\": 195.0878, \"ionmode\": \"positive\", \"adduct\": \"[M+H]+\", \"rt\": \"312\", \"instrument\": \"\", \"collision_energy\": \"\", \"peaks_raw\": 10, \"peaks_after_processing\": 10, \"peaks_sent\": 10, \"peaks\": [[42.0339, 0.1], [56.0496, 0.04], [69.0448, 0.15], [\"...\"]]}, \"library\": {\"spectra_read\": 8, \"spectra_scored\": 8, \"passing\": 1, \"candidates_sent\": 8, \"format\": \"msp\"}, \"candidates\": [{\"ref\": \"R1\", \"rank\": 1, \"name\": \"Caffeine\", \"id\": \"\", \"precursor_mz\": 195.0877, \"adduct\": \"[M+H]+\", \"ionmode\": \"positive\", \"formula\": \"C8H10N4O2\", \"inchikey\": \"RYYVLZVUVIJVGH-UHFFFAOYSA-N\", \"cosine\": 0.9937, \"cosine_matches\": 8, \"modified_cosine\": 0.9937, \"modified_matches\": 8, \"precursor_delta_da\": 0.0001, \"precursor_delta_ppm\": 0.5, \"explained_intensity\": 0.957, \"browser_status\": \"match\", \"top_pairs\": [{\"query_mz\": 138.0663, \"reference_mz\": 138.0662, \"shifted\": false, \"contribution\": 0.8213}, \"...\"], \"peaks\": [[42.0338, 0.08], [56.0495, 0.05], [\"...\"]]}, {\"ref\": \"R2\", \"rank\": 2, \"name\": \"Theobromine\", \"id\": \"\", \"precursor_mz\": 181.072, \"adduct\": \"[M+H]+\", \"ionmode\": \"positive\", \"formula\": \"C7H8N4O2\", \"inchikey\": \"YAPQBXQYLJRXSA-UHFFFAOYSA-N\", \"cosine\": 0.8663, \"cosine_matches\": 2, \"modified_cosine\": 0.9649, \"modified_matches\": 3, \"precursor_delta_da\": 14.0158, \"precursor_delta_ppm\": 77404.6, \"explained_intensity\": 0.616, \"browser_status\": \"weak\", \"top_pairs\": [{\"query_mz\": 138.0663, \"reference_mz\": 138.0662, \"shifted\": false, \"contribution\": 0.8219}, \"...\"], \"peaks\": [[67.0291, 0.1], [110.0713, 0.18], [\"...\"]]}, \"... 6 more\"], \"flags\": [{\"id\": \"F1\", \"severity\": \"medium\", \"category\": \"ion_mode_conflict\", \"message\": \"2 candidate(s) were recorded in the other ion mode from the query (positive); their spectra are not comparable as an identity match.\", \"refs\": [\"R6\", \"R7\"]}, {\"id\": \"F2\", \"severity\": \"medium\", \"category\": \"high_score_few_matches\", \"message\": \"1 reference(s) score at or above 0.6 on fewer than 5 matched peaks (Theobromine: 0.866 on 2); keep score and matched peaks together.\", \"refs\": [\"R2\"]}, {\"id\": \"F3\", \"severity\": \"low\", \"category\": \"analog_candidates\", \"message\": \"3 candidate(s) pass only on the modified cosine (1-Methylxanthine 0.965, shift +28.0314 Da; Theophylline 0.986, shift +14.0158 Da; Paraxanthine 0.967, shift +14.0158 Da): structural relatives, not identities.\", \"refs\": [\"R3\", \"R4\", \"R5\"]}], \"browser_verdict\": \"probable\", \"clipped\": {\"query_peaks_not_sent\": 0, \"library_spectra_not_scored\": 0}}",
"question": "",
"annotation": "Verdict: probable, level 2a, best candidate R1 Caffeine.\nHeadline: The query spectrum matches the Caffeine reference with cosine 0.994 on 8 matched peaks and a precursor difference of 0.5 ppm, supporting a probable structure at level 2a pen ..."
}
The reply:
{
"lane": "report",
"verdict": "probable",
"level": "2a",
"best_candidate": {
"ref": "R1",
"name": "Caffeine"
},
"headline": "The feature at precursor m/z 195.0878 is reported as Caffeine at level 2a (probable structure by library spectrum match), with cosine 0.994 on 8 matched peaks and a precursor difference of 0.5 ppm, pending an authentic standard.",
"tldr": [
"R1 Caffeine is the only candidate with browser status match: cosine 0.9937 on 8 matched peaks, explained intensity 0.957, same positive ion mode and [M+H]+ adduct, precursor 195.0877 within 0.0001 Da (0.5 ppm).",
"No other distinct candidate comes close on the cosine: Theobromine scores 0.8663 on only 2 matched peaks, and Theophylline, Paraxanthine and 1-Methylxanthine pass only on the modified cosine with precursors differing by 14.0158 or 28.0314 Da.",
"Level 1 is not supported because no authentic reference standard has been run; positional isomers absent from the in-house list cannot be excluded.",
"Instrument, collision energy and library version are not stated and are marked AUTHOR_INPUT_NEEDED in the methods text."
],
"methods_text": "Tandem mass spectra were acquired from a protein-precipitated human plasma extract by C18 reversed-phase LC with positive-mode electrospray ionisation on AUTHOR_INPUT_NEEDED (instrument) at AUTHOR_INPUT_NEEDED (collision energy). The query spectrum was compared with reference spectra from an in-house list (source and version: AUTHOR_INPUT_NEEDED). Query and reference spectra were processed identically: non-positive peaks were dropped, duplicate m/z values were merged by taking the maximum intensity, intensities were scaled to the base peak, and peaks below a relative intensity of 0.01 were removed. Spectral similarity was computed as greedy cosine and greedy modified cosine scores with a fragment m/z tolerance of 0.02 Da, m/z power 0 and intensity power 1. A reference was considered a match when it reached a score of at least 0.6 on at least 5 matched peaks, with the precursor m/z within 10 ppm and the same ion mode; references passing only on the modified cosine were treated as structural analogs. Eight library spectra were scored. Confidence levels were assigned following Schymanski et al. (2014): level 2a required a library spectrum match with precursor agreement and no competing distinct candidate of similar score, and level 1 was reserved for confirmation against an authentic standard measured under the same conditions.",
"results_text": "The feature at precursor m/z 195.0878 ([M+H]+, 5.20 min) was annotated as Caffeine at confidence level 2a, with a cosine score of 0.9937 on 8 matched peaks and a precursor difference of 0.5 ppm against the in-house reference spectrum. The annotation is based on library spectral matching only; no authentic standard has been measured, so retention time confirmation and exclusion of isomers not in the library remain outstanding.",
"table_row": {
"feature": "Feature 195.0878 @ 5.20 min",
"precursor_mz": "195.0878",
"adduct": "[M+H]+",
"annotation": "Caffeine",
"level": "2a",
"score": "0.9937",
"matched_peaks": "8",
"reference": "RYYVLZVUVIJVGH-UHFFFAOYSA-N",
"evidence": "Library MS/MS match, cosine 0.994 on 8 peaks, precursor within 0.5 ppm; no authentic standard"
},
"limitations": [
"The annotation rests on library spectral similarity alone; no authentic Caffeine standard was measured under the same LC method, so retention time and MS/MS were not confirmed.",
"Positional isomers or other compounds absent from the in-house reference list cannot be excluded, and MS/MS does not resolve stereochemistry.",
"Query peaks at 91.05 and 150.07 have no counterpart in the Caffeine reference spectrum, which may reflect co-isolation in the plasma matrix or collision-energy differences.",
"The instrument and collision energy of the query and the provenance of the in-house reference spectra are not documented, so spectral comparability cannot be fully assessed."
],
"next_evidence": [
"Measure an authentic standard of Caffeine under the same C18 LC method and compare retention time and MS/MS with the feature at 5.20 min.",
"Document the instrument and collision energy for the query and the in-house reference spectra, and re-acquire at matching settings if they differ.",
"Compare the query against an independent public reference spectrum of Caffeine to corroborate the in-house entry.",
"Check the isotope pattern and other adducts of the 195.0878 feature to support the molecular formula."
],
"prescan_responses": [
{
"ref": "F1",
"verdict": "confirmed",
"note": "Caffeic acid (R6) and Citric acid (R7) are recorded as [M-H]- in negative ion mode against a positive-mode query; both are rejected and the flag does not affect the reported Caffeine annotation."
},
{
"ref": "F2",
"verdict": "confirmed",
"note": "Theobromine (R2) scores 0.8663 on only 2 matched peaks, below the 5-peak threshold, and its precursor 181.072 differs by 14.0158 Da; it is not a competing identity for the feature."
},
{
"ref": "F3",
"verdict": "confirmed",
"note": "1-Methylxanthine, Theophylline and Paraxanthine pass only on the modified cosine with plain cosine below 0.04 and precursors differing by 28.0314 or 14.0158 Da; they are structural relatives, not identities, and do not compete with Caffeine."
}
]
}
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.