Drive Sequon Desk from your own code
Everything the web page does is available over HTTP: post a protein sequence in, get the same structured document back. Three lanes run over one sequence set — the glycosylation and liability review, the variant design that removes what the review found, and the characterisation panel that would confirm it — and they are selected by one task field.
The natural uses are a batch job that screens a whole variant library for sequons and liability motifs before anyone orders DNA, a construct-registration hook that refuses a sequence carrying an unpaired cysteine or an internal stop codon, and a nightly pass that re-reviews every candidate in a programme and reports which ones moved.
This is a protein-engineering tool. It is not medical, clinical, diagnostic or regulatory advice, and it is not a substitute for measurement.
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": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Where the slug goes — and where it does not
There is no X-App-Slug header. This is worth stating plainly because other apps' documentation on this platform says otherwise. The vendored SDK sends exactly two headers of its own — Content-Type and Authorization — and the slug appears in exactly one place in the whole API: the body of POST /guest, as {"slug": "sequon-desk"}. A bogus slug header is accepted and ignored, so a request built around one still returns 200 and tells you nothing.
Your token, on the other hand, is required on everything except /guest: Authorization: Bearer ….
Error codes
| code | status | what it means | what to do |
|---|---|---|---|
| UNAUTHORIZED | 401 | no token, or a token that has expired | mint a guest token, or sign in for a personal one |
| FORBIDDEN | 403 | the token belongs to a different app | tokens are scoped per app; get one for this slug |
| INSUFFICIENT_CREDITS | 402 | the balance cannot cover the hold | top up, or check /estimate first — /estimate is free |
| VALIDATION_ERROR | 400 | the body is not a usable input object | check you did not wrap it in an "input" key |
| RATE_LIMITED | 429 | too many requests | back off and retry; never tight-loop |
| NOT_FOUND | 404 | no such job id | job ids are scoped to the token that created them |
| INTERNAL | 500 | the platform failed | retry with the SAME Idempotency-Key |
The input object
The request body IS the input object. It is never wrapped in an input key. This matters more than it looks: a wrapped body returns 200 with a plausible-looking hold, and the model then never sees your task field at all — so a real, billed run executes against a payload the prompt cannot read, and there is no error to catch. Comparing holds does not reliably detect it either; the two shapes have measured identical holds on other apps. The check that works is in step 3 below.
| field | type | required | what it is |
|---|---|---|---|
| task | string | yes | the lane: glyco, design or assay. Documented first because it selects everything else. If it is missing or unrecognised the model picks the closest lane, sets lane to its choice and says so in the first sentence of summary — it does not blend two contracts. |
| fasta | string | yes | the sequence. Multi-record FASTA, or a bare single-letter paste. Headers, ; comments, embedded numbering, whitespace and case are all handled; gap characters are stripped and counted; a trailing * is the translated stop and is removed, while a * in the middle is reported as a truncated construct. Clipped at 30,000 characters from the middle, with the cut announced in band. |
| fasta_clipped | number | no | how many characters the client cut. Send 0 if you did not clip. |
| format | string | yes | antibody-igg, bispecific, fab-scfv, vhh-nanobody, fusion-protein, enzyme, peptide or other. Changes the grading: a C-terminal lysine on an IgG is expected heterogeneity, not a defect. |
| host | string | yes | cho, hek, ecoli, yeast, insect, cell-free or unknown. An N-glycosylation sequon in ecoli or cell-free is graded info, because those systems have no N-linked machinery. |
| route | string | yes | iv, sc, im, inhaled, topical or research-only. A pI inside 6–8 escalates for sc, where the dose has to be soluble at high concentration. |
| stage | string | yes | discovery, lead-opt or cmc. Deamidation, isomerisation and oxidation escalate at cmc. |
| goal | string | no | what you want out of this lane, in a sentence or two. |
| notes | string | no | target, measured data, constraints, anything you refuse to change. Measured numbers here are what the assay lane builds its acceptance criteria against. |
| upstream | string | no | the previous lane's artifact, carried across. The glyco document feeds design; the design document feeds assay. Omit it and the lane works from prescan alone and says so. |
| prescan | object | strongly recommended | the computed facts. In the browser this comes from the free in-browser scanner; from your own code you may send your own object of the same shape, or omit it entirely — the model then has only the raw sequence and will say its confidence is lower. The shape is described below. |
The prescan object
This is what makes the model accountable rather than merely fluent. Every number in it is treated as authoritative — the model is instructed not to recompute it and not to contradict it — and every entry in prescan.flags must come back with a matching coverage_check entry. The important keys:
| key | what it carries |
|---|---|
| declared_context | the four context fields, normalised. |
| chain_count / chains_sent / chains_omitted | how many chains exist and how many are in this payload. When chains are dropped they are drawn with a golden-ratio Kronecker sequence, never an every-nth stride — a stride shares a factor with any periodic column and can hand the model one phase of the data while looking like a sample. |
| chains[] | per chain: id, length, sequence (possibly clipped, with each removed stretch marked in band as ...[N residues omitted: 120-460]...), mass_average_da, mass_monoisotopic_da, theoretical_pi, net_charge_ph7, charge_per_100_residues, gravy, aliphatic_index, aromaticity, cys_count, cys_parity, met_count, trp_count, ext_coeff_reduced, ext_coeff_cystines, nglyc_sequons[], proline_blocked_near_sequons[], noncanonical_nxc[], oglyco_hotspots[], hydrophobic_patches[], positive_charge_patches[], negative_charge_patches[] and parse_notes. |
| flags[] / flag_count / flag_total / flags_omitted | flag_count describes the array immediately beside it, never the full list; flag_total and flags_omitted describe the difference. Each flag carries id, chain, kind, severity, title, detail and severity_reason — the mitigating fact already applied. |
| method_notes | how each number was computed, so the model can say what it does and does not mean: pI over the Bjellqvist pKa set, extinction by Gill & von Hippel, hydrophobic patches as a seven-residue window at mean Kyte-Doolittle 2.5 with no charged residue, and the O-glycosylation list explicitly a heuristic rather than a predictor. |
Two rules the prescan itself obeys, because they are the errors that matter most here: the canonical sequon is N-X-S/T with X not proline, so NPS and NPT are listed separately as near-sequons and are NOT glycosylation sites; and an even cysteine count is described as consistent with complete pairing rather than as evidence of it.
The output contract
One JSON object, one envelope in every lane, so a client needs a single parser. The model is instructed to return the object and nothing else — no prose, no code fence.
| field | type | what it is |
|---|---|---|
| lane | string | the lane that answered. Compare it with your task. |
| title | string | a document title naming the molecule. |
| verdict | string | one of the lane's four allowed values (per lane, below). |
| headline | string | one sentence. |
| summary | string | two to four sentences: what was found, what it means, and what was not knowable. |
| checks[] | array | 6–12 of {name, value, verdict, note} where verdict is good, weak, missing, risky or not-applicable. |
| findings[] | array | worst first: {id, severity, target, quote, why, so_what} where severity is critical, high, medium, low or info. |
| rows[] | array | the lane's table, at most 60: {key, label, a, b, c, d, note}. Every cell is a string. The column meanings are per lane, below. |
| artifact | string | the document itself, as Markdown. This is what a user exports. |
| artifact_json | object | the same document as structured data; shape is per lane. |
| coverage_check[] | array | one entry per flag in prescan.flags: {flag_id, status, note} with status confirmed, cleared or not-applicable. The web page shows any flag with no entry as unanswered — a silence does not pass for agreement. |
| questions[] | array | at most six things that would have to be measured. |
| confidence | string | high, medium or low. |
1. Get a token
A guest token can call /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in — the token page is the shortest path to either.
# The shortest path is the token page. It shows the token this browser already
# holds and hands you a ready-made shell export:
#
# https://sequon-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; running a lane is metered and needs a personal token from
# signing in. Note where the slug goes: in the BODY of this one call, and nowhere
# else in the whole API. There is no X-App-Slug header.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"sequon-desk"}'
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"gst_...","subject_type":"guest"}}
import json, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://sequon-desk.skillsafe.ai/tokens.html
def call(method, path, body=None, token=None, extra_headers=None):
"""One helper for the whole API. Every response uses the same envelope, so
unwrapping happens in exactly one place."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
for k, v in (extra_headers or {}).items():
req.add_header(k, v)
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"]
# A guest token, if you do not have a personal one. The slug goes in the body of
# this call only - there is no slug header anywhere in this API.
guest = call("POST", "/guest", {"slug": "sequon-desk"})
print(guest["token"], guest["subject_type"])
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://sequon-desk.skillsafe.ai/tokens.html
async function call(method, path, body, token, extraHeaders) {
const headers = Object.assign({ "Content-Type": "application/json" }, extraHeaders || {});
if (token) headers.Authorization = "Bearer " + token;
const res = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) throw new Error(json.error.code + ": " + json.error.message);
return json.data;
}
// A guest token. The slug goes in the body of THIS call and nowhere else -
// there is no X-App-Slug header in this API.
const guest = await call("POST", "/guest", { slug: "sequon-desk" });
console.log(guest.token, guest.subject_type);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call is the whole client: one envelope, one place that unwraps it.
func call(method, path string, body any, token string, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, API+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range extra {
req.Header.Set(k, v)
}
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
}
func main() {
token := os.Getenv("SKILLSAFE_TOKEN") // or paste it: "YOUR_TOKEN"
if token == "" {
// A guest token. The slug goes in the body of this call only.
raw, err := call("POST", "/guest", map[string]string{"slug": "sequon-desk"}, "", nil)
if err != nil {
panic(err)
}
var g struct{ Token string `json:"token"` }
json.Unmarshal(raw, &g)
token = g.Token
}
fmt.Println("token", token[:12]+"...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class SequonDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
// One helper. Every response is {"ok":..., "data"|"error":...}.
static String call(String method, String path, String body, String token,
Map<String, String> extra) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json");
if (token != null) b.header("Authorization", "Bearer " + token);
if (extra != null) extra.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"ok\":false")) throw new RuntimeException(res.body());
return res.body();
}
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_TOKEN"); // or "YOUR_TOKEN"
if (token == null) {
// A guest token. The slug goes in the body of this call and nowhere else.
String out = call("POST", "/guest", "{\"slug\":\"sequon-desk\"}", null, null);
System.out.println(out);
}
}
}
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SKILLSAFE_TOKEN"] || "YOUR_TOKEN" # from https://sequon-desk.skillsafe.ai/tokens.html
# One helper for the whole API.
def call(method, path, body = nil, token = nil, extra = {})
uri = URI(API + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
extra.each { |k, v| req[k] = v }
req.body = JSON.generate(body) if body
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
# A guest token. The slug goes in the body of this call only - there is no slug
# header anywhere in this API.
guest = call("POST", "/guest", { "slug" => "sequon-desk" })
puts guest["token"], guest["subject_type"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from https://sequon-desk.skillsafe.ai/tokens.html
// One helper for the whole API.
function call(string $method, string $path, $body = null, ?string $token = null,
array $extra = []) {
$headers = ["Content-Type: application/json"];
if ($token) $headers[] = "Authorization: Bearer " . $token;
foreach ($extra as $k => $v) $headers[] = "$k: $v";
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($out["ok"])) {
throw new RuntimeException($out["error"]["code"] . ": " . $out["error"]["message"]);
}
return $out["data"];
}
// A guest token. The slug goes in the body of this call and nowhere else.
$guest = call("POST", "/guest", ["slug" => "sequon-desk"]);
echo $guest["token"], " ", $guest["subject_type"], "\n";
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class SequonDesk {
const string API = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new HttpClient();
// One helper for the whole API.
static async Task<JsonElement> Call(string method, string path, object body = null,
string token = null,
(string, string)[] extra = null) {
var req = new HttpRequestMessage(new HttpMethod(method), API + path);
if (body != null) {
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8,
"application/json");
}
if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
if (extra != null) foreach (var (k, v) in extra) req.Headers.Add(k, v);
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean()) {
var e = doc.RootElement.GetProperty("error");
throw new Exception(e.GetProperty("code").GetString() + ": " +
e.GetProperty("message").GetString());
}
return doc.RootElement.GetProperty("data");
}
static async Task Main() {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
// A guest token. The slug goes in the body of this call only.
var guest = await Call("POST", "/guest", new { slug = "sequon-desk" });
Console.WriteLine(guest.GetProperty("token").GetString());
}
}
2. Check who you are and what you can afford
/me is free. Compare credits against the hold from step 3 before you submit, rather than discovering a 402 afterwards.
# Who am I, and can I afford a run? A guest gets a subject_type of "guest" and
# usually no credits; a signed-in user gets "user" and a balance.
curl -sS "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# {"ok":true,"data":{"subject_id":"usr_...","subject_type":"user","credits":184320}}
me = call("GET", "/me", token=TOKEN)
print(me["subject_type"], me.get("credits"))
if me["subject_type"] != "user":
raise SystemExit("running a lane is metered - sign in for a personal token")
const me = await call("GET", "/me", undefined, TOKEN);
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") {
throw new Error("running a lane is metered - sign in for a personal token");
}
raw, err := call("GET", "/me", nil, token, 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)
String me = call("GET", "/me", null, token, null);
System.out.println(me);
// subject_type "guest" can call /me and /estimate but not /run.
me = call("GET", "/me", nil, TOKEN)
puts me["subject_type"], me["credits"]
abort("running a lane is metered - sign in") unless me["subject_type"] == "user"
$me = call("GET", "/me", null, $TOKEN);
echo $me["subject_type"], " ", $me["credits"] ?? 0, "\n";
var me = await Call("GET", "/me", null, token);
Console.WriteLine(me.GetProperty("subject_type").GetString());
3. Price the run for free
/estimate creates no job and costs nothing. It is also the only reliable way to confirm your payload shape is right: send a bare {"task": "glyco"} with no facts and confirm the hold drops materially. If a payload with a full prescan prices the same as a bare one, your facts are not reaching the prompt. Comparing two candidate shapes' holds against each other does not work — identical holds have been measured for wrapped and unwrapped bodies.
# /estimate is FREE and creates no job. It is also the fastest way to confirm the
# app is wired to the model you think it is: check model, model_alias and
# markup_bps in the reply.
#
# Note the body: it IS the input object. There is no "input" wrapper, and no slug
# header. Wrapping the object returns 200 with a plausible-looking hold, and the
# model then never sees your `task` field - so a real run bills against a payload
# the prompt cannot read, with no error to catch.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"glyco","fasta":">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS","format":"antibody-igg","host":"cho","route":"iv","stage":"lead-opt","goal":"Tell me whether the CDR-adjacent sequon has to go."}'
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":3084,"min_credits":420,"sponsor_enabled":false}}
# The body IS the input object - never {"input": ...}. A wrapper returns 200 with
# a plausible hold and the model never sees `task`.
run_input = {
"task": "glyco",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go."
}
est = call("POST", "/estimate", run_input, token=TOKEN)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserves", est["hold_credits"], "minimum", est["min_credits"])
# A cheap sanity check that your payload's facts are actually being priced: send a
# bare task with no facts and confirm the hold drops materially.
bare = call("POST", "/estimate", {"task": "glyco"}, token=TOKEN)
assert bare["hold_credits"] < est["hold_credits"], "the facts are not reaching the prompt"
// The body IS the input object - never { input: ... }.
const runInput = {
"task": "glyco",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go."
};
const est = await call("POST", "/estimate", runInput, TOKEN);
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserves", est.hold_credits, "minimum", est.min_credits);
// Confirm your facts are being priced: a bare task must cost materially less.
const bare = await call("POST", "/estimate", { task: "glyco" }, TOKEN);
if (!(bare.hold_credits < est.hold_credits)) {
throw new Error("the facts are not reaching the prompt");
}
runInput := map[string]any{
"task": "glyco",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNW",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go.",
}
// The body IS the input object. No "input" wrapper, no slug header.
raw, err = call("POST", "/estimate", runInput, token, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
Hold int `json:"hold_credits"`
Min int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.ModelAlias, est.MarkupBps, est.Hold)
// The body IS the input object. No "input" wrapper, no slug header.
String runInput = """
{"task":"glyco",
"fasta":">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNW",
"format":"antibody-igg","host":"cho","route":"iv","stage":"lead-opt",
"goal":"Tell me whether the CDR-adjacent sequon has to go."}
""";
String est = call("POST", "/estimate", runInput, token, null);
System.out.println(est); // model, model_alias, markup_bps, hold_credits
# The body IS the input object - never { "input" => ... }.
run_input = {
"task" => "glyco",
"fasta" => ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNW",
"format" => "antibody-igg",
"host" => "cho",
"route" => "iv",
"stage" => "lead-opt",
"goal" => "Tell me whether the CDR-adjacent sequon has to go."
}
est = call("POST", "/estimate", run_input, TOKEN)
puts est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"]
// The body IS the input object - never ["input" => ...].
$runInput = [
"task" => "glyco",
"fasta" => ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNW",
"format" => "antibody-igg",
"host" => "cho",
"route" => "iv",
"stage" => "lead-opt",
"goal" => "Tell me whether the CDR-adjacent sequon has to go.",
];
$est = call("POST", "/estimate", $runInput, $TOKEN);
echo $est["model"], " ", $est["model_alias"], " ", $est["hold_credits"], "\n";
// The body IS the input object. No "input" wrapper, no slug header.
var runInput = new {
task = "glyco",
fasta = ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNW",
format = "antibody-igg",
host = "cho",
route = "iv",
stage = "lead-opt",
goal = "Tell me whether the CDR-adjacent sequon has to go."
};
var est = await Call("POST", "/estimate", runInput, token);
Console.WriteLine(est.GetProperty("model_alias").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
4. Run it, and poll
Idempotency-Key is a header and must be derived from the input and the lane: two lanes over the same sequence are two distinct runs and must not collide on one key, while a retry of the same run must reuse its key or it double-bills. If the reply comes back with truncated: true the balance could not cover the full output cap — the sections that arrived are real, and topping up lifts the cap on the next run.
# A metered run. Idempotency-Key is a HEADER, and it must be derived from the
# input so a retry after a network blip cannot double-bill. Include the lane in
# it: two lanes over the same sequence are two distinct runs.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sequon-desk:glyco:$(printf %s '{"task":"glyco","fasta":">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS","format":"antibody-igg","host":"cho","route":"iv","stage":"lead-opt","goal":"Tell me whether the CDR-adjacent sequon has to go."}' | shasum -a 256 | cut -c1-16):1" \
-d '{"task":"glyco","fasta":">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS","format":"antibody-igg","host":"cho","route":"iv","stage":"lead-opt","goal":"Tell me whether the CDR-adjacent sequon has to go."}'
# {"ok":true,"data":{"job_id":"job_...","status":"queued"}}
# Then poll to a terminal state.
curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/job_..." -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# {"ok":true,"data":{"status":"succeeded","charged_credits":1902,"truncated":false,
# "output":"{\"lane\":\"glyco\", ...}"}}
import hashlib, time
# The key must be a function of the input AND the lane, so a retry reuses it.
key = "sequon-desk:{}:{}:1".format(
run_input["task"],
hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:16],
)
job = call("POST", "/run", run_input, token=TOKEN, extra_headers={"Idempotency-Key": key})
while True:
j = call("GET", "/jobs/" + job["job_id"], token=TOKEN)
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if j["status"] != "succeeded":
raise SystemExit("run " + j["status"])
if j.get("truncated"):
print("WARNING: the reply was cut short - top up to lift the output cap")
result = json.loads(j["output"])
print(result["lane"], result["verdict"], len(result["rows"]), "rows")
import { createHash } from "node:crypto";
// The key must be a function of the input AND the lane.
const key = `sequon-desk:${runInput.task}:` +
createHash("sha256").update(JSON.stringify(runInput)).digest("hex").slice(0, 16) + ":1";
const job = await call("POST", "/run", runInput, TOKEN, { "Idempotency-Key": key });
let j;
for (;;) {
j = await call("GET", "/jobs/" + job.job_id, undefined, TOKEN);
if (["succeeded", "failed", "cancelled"].includes(j.status)) break;
await new Promise(r => setTimeout(r, 2000));
}
if (j.status !== "succeeded") throw new Error("run " + j.status);
if (j.truncated) console.warn("the reply was cut short - top up to lift the output cap");
const result = JSON.parse(j.output);
console.log(result.lane, result.verdict, result.rows.length, "rows");
// Idempotency-Key is a header, derived from the input and the lane.
b, _ := json.Marshal(runInput)
sum := sha256.Sum256(b)
key := fmt.Sprintf("sequon-desk:glyco:%x:1", sum[:8])
raw, err = call("POST", "/run", runInput, token,
map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var job struct{ JobID string `json:"job_id"` }
json.Unmarshal(raw, &job)
for {
raw, err = call("GET", "/jobs/"+job.JobID, nil, token, nil)
if err != nil {
panic(err)
}
var st struct {
Status string `json:"status"`
Output string `json:"output"`
Truncated bool `json:"truncated"`
}
json.Unmarshal(raw, &st)
if st.Status == "succeeded" {
fmt.Println(st.Output)
break
}
if st.Status == "failed" || st.Status == "cancelled" {
panic("run " + st.Status)
}
time.Sleep(2 * time.Second)
}
// Idempotency-Key is a header. Derive it from the input and the lane so a
// retry after a blip reuses the same key and cannot double-bill.
var md = java.security.MessageDigest.getInstance("SHA-256");
String hex = java.util.HexFormat.of().formatHex(md.digest(runInput.getBytes()));
String key = "sequon-desk:glyco:" + hex.substring(0, 16) + ":1";
String job = call("POST", "/run", runInput, token, Map.of("Idempotency-Key", key));
System.out.println(job); // {"ok":true,"data":{"job_id":"job_...", ...}}
// then GET /jobs/{job_id} until status is succeeded, failed or cancelled.
require "digest"
# Idempotency-Key is a header, derived from the input and the lane.
key = "sequon-desk:#{run_input['task']}:" \
"#{Digest::SHA256.hexdigest(JSON.generate(run_input))[0, 16]}:1"
job = call("POST", "/run", run_input, TOKEN, { "Idempotency-Key" => key })
loop do
j = call("GET", "/jobs/#{job['job_id']}", nil, TOKEN)
if %w[succeeded failed cancelled].include?(j["status"])
abort("run #{j['status']}") unless j["status"] == "succeeded"
warn("the reply was cut short - top up") if j["truncated"]
result = JSON.parse(j["output"])
puts result["lane"], result["verdict"], result["rows"].length
break
end
sleep 2
end
// Idempotency-Key is a header, derived from the input and the lane.
$key = "sequon-desk:" . $runInput["task"] . ":" .
substr(hash("sha256", json_encode($runInput)), 0, 16) . ":1";
$job = call("POST", "/run", $runInput, $TOKEN, ["Idempotency-Key" => $key]);
while (true) {
$j = call("GET", "/jobs/" . $job["job_id"], null, $TOKEN);
if (in_array($j["status"], ["succeeded", "failed", "cancelled"], true)) {
if ($j["status"] !== "succeeded") throw new RuntimeException("run " . $j["status"]);
if (!empty($j["truncated"])) fwrite(STDERR, "the reply was cut short - top up\n");
$result = json_decode($j["output"], true);
echo $result["lane"], " ", $result["verdict"], " ", count($result["rows"]), "\n";
break;
}
sleep(2);
}
// Idempotency-Key is a header, derived from the input and the lane.
var payload = JsonSerializer.Serialize(runInput);
var hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
var key = $"sequon-desk:glyco:{hash[..16]}:1";
var job = await Call("POST", "/run", runInput, token,
new[] { ("Idempotency-Key", key) });
var jobId = job.GetProperty("job_id").GetString();
while (true) {
var st = await Call("GET", "/jobs/" + jobId, null, token);
var status = st.GetProperty("status").GetString();
if (status == "succeeded") {
var result = JsonDocument.Parse(st.GetProperty("output").GetString());
Console.WriteLine(result.RootElement.GetProperty("verdict").GetString());
break;
}
if (status is "failed" or "cancelled") throw new Exception("run " + status);
await Task.Delay(2000);
}
5. Or stream it
Same body, same key rule. delta frames carry the output as it generates; the done frame carries the authoritative output and charged_credits. The web page uses this one, and advances its progress card on section headings appearing in the stream.
# The same run as an SSE stream. Same body, same Idempotency-Key rule; the reply
# is text/event-stream with `delta` events and a final `done`.
curl -sSN -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: sequon-desk:glyco:abc123def456:1" \
-d '{"task":"glyco","fasta":">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS","format":"antibody-igg","host":"cho","route":"iv","stage":"lead-opt","goal":"Tell me whether the CDR-adjacent sequon has to go."}'
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"glyco\",\"title\":\"..."}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":1902,"output":"{...}"}
# Streaming with the standard library: read the SSE frames as they arrive.
req = urllib.request.Request(API + "/run-stream",
data=json.dumps(run_input).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
buf, event = "", None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
buf += payload.get("text", "")
elif event == "done":
# `output` on the done frame is authoritative; buf is what you
# rendered as it arrived.
result = json.loads(payload["output"])
print(result["verdict"], payload.get("charged_credits"))
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key,
},
body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "", text = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
const frames = buffer.split("\n");
buffer = frames.pop();
for (const line of frames) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) {
const p = JSON.parse(line.slice(6));
if (event === "delta") text += p.text || "";
if (event === "done") console.log(JSON.parse(p.output).verdict, p.charged_credits);
}
}
}
// Streaming: POST /run-stream with Accept: text/event-stream and scan frames.
b2, _ := json.Marshal(runInput)
sreq, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(b2))
sreq.Header.Set("Content-Type", "application/json")
sreq.Header.Set("Accept", "text/event-stream")
sreq.Header.Set("Authorization", "Bearer "+token)
sreq.Header.Set("Idempotency-Key", key)
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
event := ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(strings.TrimPrefix(line, "data: "))
}
}
// Streaming: the same body, Accept: text/event-stream, and a line handler.
HttpRequest sreq = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(runInput))
.build();
HTTP.send(sreq, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
# Streaming: read the SSE frames as they arrive.
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req.body = JSON.generate(run_input)
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
event = line.delete_prefix("event: ") if line.start_with?("event: ")
next unless line.start_with?("data: ")
payload = JSON.parse(line.delete_prefix("data: "))
puts JSON.parse(payload["output"])["verdict"] if event == "done"
end
end
end
end
// Streaming: a write callback receives each SSE chunk as it arrives.
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Accept: text/event-stream",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: " . $key,
],
CURLOPT_POSTFIELDS => json_encode($runInput),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "done") {
$p = json_decode(substr($line, 6), true);
echo json_decode($p["output"], true)["verdict"], "\n";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
// Streaming: read the response stream line by line.
var sreq = new HttpRequestMessage(HttpMethod.Post, API + "/run-stream") {
Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8,
"application/json")
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Headers.Add("Idempotency-Key", key);
using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new System.IO.StreamReader(await sres.Content.ReadAsStreamAsync());
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
The three lanes, one worked example each
Same endpoint, same body shape, same output envelope. Only task and the meaning of rows change.
task: "glyco" — Glyco & liabilities
Source skill: @k-dense-ai/glycoengineering. Allowed verdict: clean, engineerable, liability-heavy, not-developable.
rows is one row per SITE:
- label = the site id, e.g.
HC N100 - a = the motif as residues, e.g.
NGT - b = what it is:
N-sequon,proline-blocked near-sequon,O-glyco hotspot,deamidation,oxidation,free thiol,hydrophobic patch,charge patch… - c = the position, exactly as the prescan gives it
- d = the call:
keep,remove,shield,monitor,characteriseornot-a-site - note = why that call, naming the mitigating fact
not-a-site is allowed only for a proline-blocked near-sequon or a non-canonical N-X-C. Nothing else may carry it.
# the request body, in full
{
"task": "glyco",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go."
}
task: "design" — Variant design
Source skill: @k-dense-ai/esm. Allowed verdict: ready-to-order, needs-modelling, high-risk, no-safe-variant.
rows is one row per PROPOSED MUTATION:
- label = the variant name, e.g.
V1 - a = the mutation, e.g.
N100Q - b = the liability it removes, referencing a flag id
- c =
conservative/semi-conservative/radical, plus the substitution logic - d = expected effect on binding, stability and expression
- note = what to check before ordering, and how
Every mutation must remove or reduce a liability that appears in the prescan or in upstream. A mutation with no target is a contract violation.
# the request body, in full
{
"task": "design",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go.",
"upstream": "# Liability review\\n\\n(the previous lane's artifact, verbatim)"
}
task: "assay" — Assay panel
Source skill: @k-dense-ai/adaptyv. Allowed verdict: panel-ready, gaps-remain, over-specified, not-testable.
rows is one row per ASSAY, in run order:
- label = the assay, e.g.
SEC-HPLC monomer - a = the stage:
express,purify,identity,activity,binding,stability,glycan,forced-degradation.activityis a catalytic readout (turnover, kcat/Km, a substrate assay);bindingis an affinity readout against a partner — an enzyme's substrate assay is the former, not the latter - b = what it measures, in the unit it reports
- c = the material and quantity, and roughly how long
- d = the acceptance criterion, as a number or a comparison to the parent
- note = why this assay rather than a cheaper one
Order is part of the contract: nothing may depend on material an earlier row has not produced. A liability with no assay in the panel is a finding, not a silence.
# the request body, in full
{
"task": "assay",
"fasta": ">HC\nQVQLVQSGAEVKKPGASVKVSCKASGYTFTNYGMNWVRQAPGQGLEWMGWINTYTGEPTYAADFKR\nRVTMTRDTSISTAYMELSRLRSDDTAVYYCARDNGTYFDYWGQGTLVTVSS",
"format": "antibody-igg",
"host": "cho",
"route": "iv",
"stage": "lead-opt",
"goal": "Tell me whether the CDR-adjacent sequon has to go.",
"upstream": "# Liability review\\n\\n(the previous lane's artifact, verbatim)"
}
Notes that will save you a round trip
- Send
prescanif you can. Without it the model has only the raw sequence, and it is instructed to lower itsconfidenceand say so rather than to guess the numbers. - Positions in
prescanare 1-based indices into the full chain, including any stretch omitted from the clippedsequence. Do not reindex them. - The model never asserts solvent exposure, CDR boundaries, epitope overlap or any antibody numbering scheme from sequence alone. If you need those, supply them in
notes. - Nothing is executed on your behalf. NetOGlyc, GlycoShield, ESM, AlphaFold and docking are recommended with parameters; no score is ever reported that was not given to the model.
- Rate limits are shared across the platform. On a
429, back off; never tight-loop.