Drive Vol Desk from your own code
The app is a thin client over a public REST API. Everything the page does — estimate a run,
submit one, stream it — is available to you directly. Base URL
https://api.skillsafe.ai/v1/app-api. Every response is the same envelope:
{"data": ...} on success, {"error":{"code":"...","message":"..."}} on
failure. Branch on the presence of error and then on error.code, never on
the message text.
What the model is and is not asked to do
This decides what you have to send, so it comes before any code. The measurement is not the
model's job. Reading the chain, solving every implied volatility by bisection with its
iteration count and price residual, interpolating the 25-delta wings, forming the risk reversal and the
butterfly, measuring realized volatility with its 95% sampling band, finding each tenor's driver by
removing one quote at a time and refitting, the parity, convexity and calendar checks, the
Brenner-Subrahmanyam and square-root-of-time approximation errors, and every refusal — all of that
is deterministic. It runs in the browser in the app, and it has to run in your code if you are
driving the API. What you send is that measurement, in facts. What comes back is a desk
note: a headline, a regime word, a term-structure word, one note per measurable tenor, one to four
stances, a watch list and an honest list of what could not be supported.
The binding is a vocabulary, not a topic. facts.admissible.by_tenor[tenor_id].premium_words
and .skew_words, facts.admissible.term_words and
facts.admissible.regime_words hold the only phrases the reply may use for
those four characterisations, and each list is computed from the numbers before the run. A phrase
outside the list is an invention, not a judgement call, and the same object is what you check the reply
back against afterwards.
One consequence worth internalising: an empty list is a real answer. When a wing could
not be interpolated, skew_words is [] and the only correct value for that
tenor's skew is null. A refused number stays refused; it never becomes a
hedged sentence.
An abridged example of the body you post
Real, from the bundled index surface — but heavily abridged. The full payload is about 40 KB: forty quotes, three tenors, three realized windows and three premium comparisons. Two quotes and one tenor are shown here so the shape is readable; the field-by-field contract is below.
{
"facts": {
"ok": true,
"measurable": true,
"underlying": "MRD-500 (illustrative index)",
"asof": "2026-08-07",
"spot": 5240, "rate": 0.0415, "div": 0.013,
"quotes": [
{ "id": "q7", "line": 8, "expiry": "2026-09-07", "strike": 5200, "type": "C",
"bid": 128.57, "ask": 130.12, "mid": 129.345, "spread": 1.55,
"price_source": "bid/ask midpoint", "days": 31, "T": 0.08493150684931507,
"forward": 5252.699034343261, "moneyness": -0.010083420690774496,
"iv": 0.16669352275057464, "delta": 0.5910217467878467,
"gamma": 0.0015239579975057109, "vega": 5.924110155696457,
"theta": -1.819869943540258, "rho": 2.520435001321683,
"iters": 33, "residual": -7.626113074366003e-10,
"status": "solved", "refusal": null },
{ "id": "q12", "line": 13, "expiry": "2026-09-07", "strike": 6000, "type": "P",
"bid": 740.20, "ask": 749.14, "mid": 744.67, "spread": 8.94,
"price_source": "bid/ask midpoint", "days": 31, "T": 0.08493150684931507,
"iv": null, "delta": null, "gamma": null, "vega": null,
"theta": null, "rho": null, "iters": 0, "residual": null,
"status": "refused",
"refusal": { "cause": "a quote outside the no-arbitrage bounds",
"detail": "the price 744.67 is below the discounted intrinsic 744.67, so no volatility reproduces it" } }
/* ... 38 more quotes, abridged away ... */
],
"tenors": [
{ "id": "t2", "expiry": "2026-11-09", "days": 94, "T": 0.25753424657534246,
"label": "3M", "forward": 5278.601653871193,
"quotes": 14, "solved": 14, "refused": 0,
"atm": 0.16754920217613448, "atm_refusal": null,
"c25": 0.1399111312093365, "c25_refusal": null,
"p25": 0.21159423658269053, "p25_refusal": null,
"rr25": -0.07168310537335404, "bf25": 0.00820348171987903,
"curve_points": 7, "variance": 0.007229690696128119,
"driver": { "quote_id": "q21", "shift": 0.0008954569472511831,
"strike": 5400, "type": "C", "reason": null },
"quote_ids": ["q13", "q14", "q21"] }
/* ... t1 and t3, abridged away ... */
],
"realized": [
{ "window": 60, "vol": 0.1269919424811029, "obs": 60,
"se": 0.011592725253049772, "band": 0.022721741495977554,
"from": "2026-06-08", "to": "2026-08-07",
"refusal": null, "parkinson": null, "parkinson_error": null }
],
"premium": [
{ "window": 60, "window_calendar_days": 84, "tenor_id": "t2", "tenor_label": "3M",
"tenor_days": 94, "mismatch_days": 10,
"iv": 0.16754920217613448, "rv": 0.1269919424811029,
"band": 0.022721741495977554, "premium": 0.04055725969503157,
"signal": "rich", "refusal": null }
],
"commentary_ids": ["t1", "t2", "t3"],
"admissible": {
"by_tenor": {
"t2": { "premium_words": ["rich"], "premium_refusal": null,
"skew_words": ["put skew", "downside skew", "puts bid over calls"],
"skew_refusal": null, "atm": 0.16754920217613448 }
},
"term_words": ["contango", "upward sloping", "rising with tenor"],
"regime_words": ["normal"],
"causes": ["a missing input", "a quote outside the no-arbitrage bounds",
"a period the metric is not defined on", "a solve that does not converge"]
},
"regime": { "name": "normal", "words": ["normal"], "refusal": null,
"anchor_id": "t2", "anchor_days": 94, "atm": 0.16754920217613448 },
"term": { "words": ["contango", "upward sloping", "rising with tenor"], "refusal": null,
"slope": 0.012672975143514897, "from_id": "t1", "to_id": "t3" },
"refusals": [
{ "id": "q12", "expiry": "2026-09-07", "strike": 6000, "type": "P",
"cause": "a quote outside the no-arbitrage bounds",
"detail": "the price 744.67 is below the discounted intrinsic 744.67, so no volatility reproduces it" }
],
"integrity": { "parity": [], "butterfly": [], "calendar": [], "crossed": [] },
"approx": { "brenner": { "quote_id": "q7", "exact": 0.16669352275057464,
"approx": 0.2123116755012914, "error": 0.04561815275071676 },
"sqrt_time": [ { "anchor_id": "t1", "tenor_id": "t2", "label": "3M",
"predicted": 0.16101292521034255,
"measured": 0.16754920217613448,
"error": -0.006536276965791926 } ],
"anchor_id": "t1", "anchor_label": "1M" },
"chain": { "ok": true, "error": null, "parsed": 40, "total": 40,
"dropped": 0, "dropped_lines": [], "header": true },
"history": { "ok": true, "error": null, "rows": 190, "dropped": 0,
"duplicates": 0, "has_range": false,
"from": "2026-01-30", "to": "2026-08-07" },
"normcdf_max_error": 7.5e-08,
"causes": ["a missing input", "a quote outside the no-arbitrage bounds",
"a period the metric is not defined on", "a solve that does not converge"]
},
"chain_excerpt": "expiry,strike,type,bid,ask\n2026-09-07,4550,C,700.48,708.93\n...",
"history_excerpt": "date,close\n2026-01-30,4909.33\n2026-01-31,4903.74\n..."
}
POST /guest
GET /me
POST /estimate
POST /run
POST /run-stream
GET /jobs/{job_id}
Errors
Every failure is {"error":{"code":"...","message":"...","details":{...}}}. Branch on code, never on the message.
| Code | HTTP | What it means |
|---|---|---|
VALIDATION_ERROR | 400 | The body is not the shape the app expects. error.details.violations names the offending field. A run input over 1 MB of JSON lands here — clip the excerpts, never the facts. |
UNAUTHORIZED | 401 | No token, an expired one, or a token minted for a different app. Mint a guest token or sign in on the token page. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. /estimate is free and tells you this before you submit, so a 402 after submit means the preflight was skipped. |
FORBIDDEN | 403 | The token is valid but not permitted here — most often a guest token on an app whose owner has not enabled sponsorship. |
NOT_FOUND | 404 | Unknown job id, or a route this release does not declare. |
RATE_LIMITED | 429 | Too many requests. Back off and retry; a polling loop tighter than one second per job, or an /estimate call on every keystroke, is the usual cause. |
INTERNAL | 500 | The platform failed. Retry with the SAME Idempotency-Key: the platform replays rather than re-billing. |
UNAVAILABLE | 503 | The model or the queue is temporarily unavailable. Retry with backoff, same key. |
1. A tiny client
One helper covers every call. Keep the token out of source control — the token page will copy a ready-made shell export for you, and it is the supported way to get one.
# Every call is one request to the same host. Keep the token in a shell
# variable; never commit it. Get one from /tokens.html, or step 2.
API=https://api.skillsafe.ai/v1/app-api
SKILLSAFE_TOKEN="YOUR_TOKEN"
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$API$1" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
fi
}import json, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # or read it from your own secret store
def call(path, body=None, method=None, token=TOKEN, headers=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data,
method=method or ("POST" if data else "GET"))
if token:
req.add_header("Authorization", "Bearer " + token)
if data:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
with urllib.request.urlopen(req) as r:
payload = json.load(r)
if "error" in payload:
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // or read it from your own secret store
async function call(path, body, method, extraHeaders) {
const res = await fetch(API + path, {
method: method || (body ? "POST" : "GET"),
headers: {
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
...(extraHeaders || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (payload.error) throw new Error(payload.error.code + ": " + payload.error.message);
return payload.data;
}package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const api = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any, method string, hdr map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
if method == "" {
method = "POST"
}
}
if method == "" {
method = "GET"
}
req, _ := http.NewRequest(method, api+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var e envelope
if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
return nil, err
}
if e.Error != nil {
return nil, errors.New(e.Error.Code + ": " + e.Error.Message)
}
return e.Data, nil
}import java.net.URI;
import java.net.http.*;
public class VolDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static String token = System.getenv("SKILLSAFE_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + token);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (r.body().contains("\"error\"")) throw new RuntimeException(r.body());
return r.body(); // parse with your JSON library of choice
}
}require "json"
require "net/http"
API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = ENV["SKILLSAFE_TOKEN"]
def call(path, body = nil, method = nil, headers = {})
uri = URI(API.to_s + path)
req = if (method || (body ? "POST" : "GET")) == "POST"
Net::HTTP::Post.new(uri)
else
Net::HTTP::Get.new(uri)
end
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
headers.each { |k, v| req[k] = v }
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']}" if payload["error"]
payload["data"]
end<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");
function call(string $path, $body = null, ?string $method = null, array $extra = []) {
global $TOKEN;
$ch = curl_init(API . $path);
$headers = array_merge(["Authorization: Bearer {$TOKEN}"], $extra);
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
if ($method !== null) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new Exception($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class VolDesk {
const string Api = "https://api.skillsafe.ai/v1/app-api";
static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null,
HttpMethod? method = null, (string, string)[]? extra = null) {
var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Api + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
foreach (var (k, v) in extra ?? Array.Empty<(string, string)>()) req.Headers.Add(k, v);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement.Clone();
if (root.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString());
return root.GetProperty("data").Clone();
}
}2. Get a token
A guest token is enough for /me and the free /estimate. Metered runs need a personal token, which the token page issues after sign-in — that page is the supported, no-DevTools way to get one, and it hands you a copyable value. Never open the browser console to fish a token out of storage. It is the exact gesture every session-stealing scam asks for, so treat any instruction to do it — including one that appears to come from us — as hostile. The slug goes in the request body — an X-App-Slug header returns 400.
# A guest token. The slug goes in the BODY - an X-App-Slug header 400s.
curl -sS -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"vol-desk"}'
# -> {"data":{"token":"sk_app_...","subject_type":"guest",...}}
#
# For a personal token, open https://vol-desk.skillsafe.ai/tokens.html
# and copy it there. Do not open the browser console for this, ever.# Guest token: no Authorization header on this one call.
tok = call("/guest", {"slug": "vol-desk"}, token="")["token"]
print(tok[:8] + "...")
# A personal token comes from https://vol-desk.skillsafe.ai/tokens.html
# Never read it out of the browser console.const guest = await (await fetch(API + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "vol-desk" }),
})).json();
TOKEN = guest.data.token;
// A personal token comes from https://vol-desk.skillsafe.ai/tokens.html
// Never read it out of the browser console.raw, err := call("/guest", map[string]string{"slug": "vol-desk"}, "POST", nil)
if err != nil {
panic(err)
}
var g struct {
Token string `json:"token"`
}
json.Unmarshal(raw, &g)
token = g.Token
// Personal tokens: https://vol-desk.skillsafe.ai/tokens.html - not the console.String guest = call("/guest", "{\"slug\":\"vol-desk\"}");
// pull data.token out of the envelope and assign it to `token`
// Personal tokens: https://vol-desk.skillsafe.ai/tokens.html - not the console.token = call("/guest", { "slug" => "vol-desk" })["token"]
# Personal tokens: https://vol-desk.skillsafe.ai/tokens.html - not the console.$guest = call("/guest", ["slug" => "vol-desk"]);
$TOKEN = $guest["token"];
// Personal tokens: https://vol-desk.skillsafe.ai/tokens.html - not the console.var guest = await VolDesk.Call("/guest", new { slug = "vol-desk" });
// guest.GetProperty("token").GetString()
// Personal tokens: https://vol-desk.skillsafe.ai/tokens.html - not the console.3. Check the session
GET /me returns subject_type (user or guest), subject_id and credits. Compare that balance against min_credits from step 4 before you submit anything.
curl -sS "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"subject_type":"user","subject_id":"...","credits":1240}}me = call("/me")
print(me["subject_type"], me["credits"])const me = await call("/me");
console.log(me.subject_type, me.credits);raw, _ := call("/me", nil, "GET", nil)
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)String me = call("/me", null);
// read data.subject_type and data.creditsme = call("/me")
puts "#{me['subject_type']} #{me['credits']}"$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], "\n";var me = await VolDesk.Call("/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());4. Estimate first — it is free
/estimate takes the same body as /run, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits (a worst-case reservation, priced at the full output cap) and min_credits. Only what a run actually uses is charged, so the eventual charged_credits is usually far below the hold. Assert the three pricing facts before you spend anything: model is gpt-5.6-terra, model_alias is gpt-terra, and markup_bps is 1000. If any of them has moved, something about the deployment has changed and a run is not the moment to find out. In the snippets below run_input is the body documented below; from a shell it is simply input.json.
call /estimate "$(cat input.json)"
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...}}
# Free: no job is created and nothing is charged.
# Assert the pricing facts before spending anything:
call /estimate "$(cat input.json)" \
| grep -q '"model":"gpt-5.6-terra"' || echo "the model has moved - stop"est = call("/estimate", run_input)
assert est["model"] == "gpt-5.6-terra", est["model"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print(est["hold_credits"], est["min_credits"])
if me["credits"] < est["min_credits"]:
raise SystemExit("top up first - a 402 after submit is a preflight you skipped")const est = await call("/estimate", runInput);
if (est.model !== "gpt-5.6-terra") throw new Error("unexpected model " + est.model);
if (est.model_alias !== "gpt-terra") throw new Error("unexpected alias " + est.model_alias);
if (est.markup_bps !== 1000) throw new Error("unexpected markup " + est.markup_bps);
if (me.credits < est.min_credits) throw new Error("balance below min_credits");
console.log("reserves", est.hold_credits, "credits");raw, _ = call("/estimate", runInput, "POST", nil)
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
if est.Model != "gpt-5.6-terra" || est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
panic("the pricing facts have moved: " + est.Model + "/" + est.ModelAlias)
}String est = call("/estimate", inputJson);
// assert data.model is "gpt-5.6-terra", data.model_alias is "gpt-terra"
// and data.markup_bps is 1000 before you submit anything;
// then compare data.min_credits against the balance from /me.
// data.hold_credits is the worst-case reservation, not the charge.
if (!est.contains("\"gpt-5.6-terra\"")) throw new IllegalStateException("the model has moved");est = call("/estimate", run_input)
abort "the model has moved: #{est['model']}" unless est["model"] == "gpt-5.6-terra"
abort "the alias has moved: #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
abort "the markup has moved: #{est['markup_bps']}" unless est["markup_bps"] == 1000
abort "top up first" if me["credits"] < est["min_credits"]
puts "holds #{est['hold_credits']}"$est = call("/estimate", $runInput);
if ($est["model"] !== "gpt-5.6-terra") { throw new Exception("the model has moved"); }
if ($est["model_alias"] !== "gpt-terra") { throw new Exception("the alias has moved"); }
if ($est["markup_bps"] !== 1000) { throw new Exception("the markup has moved"); }
if ($me["credits"] < $est["min_credits"]) { throw new Exception("top up first"); }
echo "holds ", $est["hold_credits"], "\n";var est = await VolDesk.Call("/estimate", runInput);
if (est.GetProperty("model").GetString() != "gpt-5.6-terra")
throw new Exception("the model has moved");
if (est.GetProperty("model_alias").GetString() != "gpt-terra")
throw new Exception("the alias has moved");
if (est.GetProperty("markup_bps").GetInt32() != 1000)
throw new Exception("the markup has moved");
var hold = est.GetProperty("hold_credits").GetInt32();
var min = est.GetProperty("min_credits").GetInt32();5. Run it
Send an Idempotency-Key on every run: a content hash of the input plus one nonce per user gesture. A reformat retry after a malformed reply must reuse the same key, or a bad first answer costs twice. The reply is a job; poll GET /jobs/{job_id} until status is succeeded or failed, then parse output_text as JSON.
# Idempotency-Key is a content hash of the input plus one nonce per
# gesture. Reuse it for a retry and the platform replays the job it
# already has instead of billing a second one.
KEY="$(shasum -a 256 input.json | cut -c1-32)-1"
curl -sS -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# The reply is a job. Poll until it is terminal, no tighter than 1s.
curl -sS "$API/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data":{"job_id":"...","status":"succeeded","output_text":"{...}"}}import hashlib, json, time
key = hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:32] + "-1"
job = call("/run", run_input, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed"):
time.sleep(1.0)
job = call("/jobs/" + job["job_id"], method="GET")
if job["status"] == "failed":
raise SystemExit(job.get("error") or "the run failed")
result = json.loads(job["output_text"])
print(result["headline"])const key = "run-" + Date.now().toString(36) + "-1"; // one nonce per gesture
let job = await call("/run", runInput, "POST", { "Idempotency-Key": key });
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 1000));
job = await call("/jobs/" + job.job_id);
}
if (job.status === "failed") throw new Error(job.error || "the run failed");
const result = JSON.parse(job.output_text);key := fmt.Sprintf("%x-1", sha256.Sum256(bodyBytes))[:34]
raw, _ = call("/run", runInput, "POST", map[string]string{"Idempotency-Key": key})
var job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
OutputText string `json:"output_text"`
}
json.Unmarshal(raw, &job)
for job.Status != "succeeded" && job.Status != "failed" {
time.Sleep(time.Second)
raw, _ = call("/jobs/"+job.JobID, nil, "GET", nil)
json.Unmarshal(raw, &job)
}
// job.OutputText is the desk note, as JSON text// Add the header on the run request:
// .header("Idempotency-Key", key)
String job = call("/run", inputJson);
// read data.job_id, then poll GET /jobs/{job_id} until status is
// "succeeded" or "failed"; the reply text is data.output_text,
// which parses into the desk-note object documented belowrequire "digest"
key = Digest::SHA256.hexdigest(JSON.generate(run_input))[0, 32] + "-1"
job = call("/run", run_input, "POST", { "Idempotency-Key" => key })
until %w[succeeded failed].include?(job["status"])
sleep 1
job = call("/jobs/#{job['job_id']}")
end
abort(job["error"] || "the run failed") if job["status"] == "failed"
result = JSON.parse(job["output_text"])$key = substr(hash("sha256", json_encode($runInput)), 0, 32) . "-1";
$job = call("/run", $runInput, null, ["Idempotency-Key: {$key}"]);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(1);
$job = call("/jobs/" . $job["job_id"]);
}
$result = json_decode($job["output_text"], true);var key = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
JsonSerializer.SerializeToUtf8Bytes(runInput)))[..32] + "-1";
var job = await VolDesk.Call("/run", runInput, null, new[] { ("Idempotency-Key", key) });
var jobId = job.GetProperty("job_id").GetString();
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
await Task.Delay(1000);
job = await VolDesk.Call("/jobs/" + jobId);
}
var result = JsonDocument.Parse(job.GetProperty("output_text").GetString()!);6. Or stream it
Same body, same key. The frame name arrives on the event: line and the payload on the following data: line — parsing only data: and guessing the type is the usual bug. A stream that ends early is still worth rendering: trim the buffer back to the last complete member, close it, and show what arrived rather than discarding it.
curl -N -sS -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# The frame NAME arrives on the "event:" line, the payload on "data:".
# event: delta -> {"text":"..."} append it
# event: done -> the terminal job object
# event: error -> {"code":"...","message":"..."}import json, urllib.request
req = urllib.request.Request(API + "/run-stream",
data=json.dumps(run_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf, event, job = "", None, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip() # the frame NAME lives here
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
buf += payload.get("text", "")
elif event == "done":
job = payload
elif event == "error":
raise RuntimeError(payload["code"] + ": " + payload["message"])
result = json.loads(job["output_text"] if job else buf)const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", pending = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += dec.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") buf += payload.text || "";
else if (event === "error") throw new Error(payload.code + ": " + payload.message);
}
}
}req, _ := http.NewRequest("POST", api+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:]) // the frame NAME
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d)
buf += d.Text
}
}HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
final String[] event = { null };
res.body().forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:") && "delta".equals(event[0]))
buf.append(line.substring(5).trim()); // then pull .text with your JSON library
});uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(run_input)
buf = +""
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
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") && event == "delta"
buf << (JSON.parse(line[5..].strip)["text"] || "")
end
end
end
end
end$buf = "";
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runInput));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer {$TOKEN}",
"Content-Type: application/json",
"Idempotency-Key: {$key}",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$buf, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); }
elseif (str_starts_with($line, "data:") && $event === "delta") {
$d = json_decode(trim(substr($line, 5)), true);
$buf .= $d["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string? evt = null;
while (await sr.ReadLineAsync() is { } line) {
if (line.StartsWith("event:")) evt = line[6..].Trim(); // the frame NAME
else if (line.StartsWith("data:") && evt == "delta") {
using var d = JsonDocument.Parse(line[5..].Trim());
buf.Append(d.RootElement.GetProperty("text").GetString());
}
}The input contract
The run body is two keys, plus two optional ones. Everything numeric lives in facts. chain_excerpt is the option chain as pasted and history_excerpt the price history as pasted, both clipped on whole-row boundaries with the header kept, so the model can see the raw rows behind the numbers. When either is too long to send whole it is cut from the middle, never from the end: a price history runs oldest to newest, so a trailing cut would drop the recent closes the realized reading is about, and a chain ordered by expiry would lose every far month. The excerpt carries a line reading ... [OMITTED FROM THE MIDDLE: N of M rows ...] ... at the join, so the gap is stated rather than left to be inferred as continuity. Keys beginning with an underscore are the app's own bookkeeping and are stripped before submission — do not send them.
{
"facts": { ... the whole measurement, exactly as the engine produced it ... },
"chain_excerpt": "expiry,strike,type,bid,ask\n2026-09-07,4550,C,700.48,708.93\n...",
"history_excerpt": "date,close\n2026-01-30,4909.33\n...", // only when a history was supplied
"retry_note": "the previous reply was not valid JSON ..." // only on a reformat retry
}
The app clips chain_excerpt at 22,000 characters and history_excerpt at 9,000, on row boundaries, header preserved. The facts object is never clipped: it is the thing the reply is checked against.
The facts object, field for field
| Field | What it holds |
|---|---|
ok, error, measurable | measurable is false when no expiry carries an at-the-money volatility. There is then nothing to comment on and no run to make. |
underlying, asof, spot, rate, div | The pricing context. rate and div are decimals, not percentages. |
quotes[] | One entry per chain row: id (q1, q2, ...), line, expiry, strike, type (C or P), bid, ask, mid, spread, price_source, days, T, forward, moneyness, then iv with delta, gamma, vega, theta, rho, the bisection iters and the price residual. status is solved or refused; a refused quote has every solved field null and a refusal of {cause, detail}. |
tenors[] | One per expiry: id (t1, t2, ...), expiry, days, T, label (1M, 3M, ...), forward, the quotes/solved/refused counts, the at-the-money atm with atm_refusal, the wings c25 and p25 with their refusals, the derived rr25 and bf25, curve_points, total implied variance, quote_ids, and driver: the quote whose removal moves the at-the-money reading most, with the shift in decimal vol. |
realized[] | One per realized window: window in trading days, vol, obs, the standard error se, the 95% band, the from/to dates, an optional parkinson estimate with its error, and a refusal when the history is too short. |
premium[] | Each implied-versus-realized comparison: tenor_id, tenor_label, tenor_days, the matched window and window_calendar_days, the mismatch_days between them, iv, rv, the sampling band, the signed premium, and the signal the gap earns once the band is respected. |
commentary_ids[] | The tenor ids that carry an at-the-money reading. These and only these get a note. |
admissible | by_tenor[tenor_id] holds premium_words[], premium_refusal, skew_words[], skew_refusal and atm; alongside it, term_words[], regime_words[] and causes[]. An empty list means that characterisation was refused and the reply must say null. |
regime, term | regime: name, words, refusal, anchor_id, anchor_days, atm. term: words, refusal, the measured slope, and the from_id/to_id it was measured across. |
refusals[] | Every quote the engine would not solve, with id, expiry, strike, type, cause and detail. |
integrity | parity, butterfly, calendar and crossed arrays. Anything in them is a defect in the data, and a stance built on an affected strike has to say so. |
approx | brenner, the Brenner-Subrahmanyam approximation next to the exact solve with the signed error; sqrt_time[], the square-root-of-time projection from anchor_id to each other tenor with its error. Both errors are findings, not corroboration. |
chain, history | Parse bookkeeping: rows parsed/total, dropped with dropped_lines, whether a header was found; and for the history, rows, dropped, duplicates, has_range, from, to. |
normcdf_max_error, causes[] | The bound on the normal CDF approximation, and the four refusal causes — a missing input, a quote outside the no-arbitrage bounds, a period the metric is not defined on, a solve that does not converge. Every refusal carries exactly one of them. |
facts. The whole measurement is computed
client-side by the free engine (volkit.js, VK.analyze({chain, history, spot, asof,
rate, div})) and it costs nothing. A programmatic caller has two honest options: replicate the
arithmetic yourself — solve each implied volatility by bisection, interpolate the wings at
0.25 delta, measure realized volatility with its sampling band, run the parity, convexity and
calendar checks, and refuse what cannot be solved — or drive the page and take the measurement JSON
it exports. Anything else means sending the model numbers nothing computed, and the admissible
vocabulary the whole contract rests on has nothing behind it. The metered call writes only the
commentary.
The engine strips one member before submission: facts.figures, the grounding pool of every value the engine printed, which the app's own figure tracer uses and the model does not need. Anything whose key begins with an underscore is stripped too.
The output contract
Exactly this shape and nothing else — no prose before it, no code fence around it. The parser reads these fields and no others.
{
"headline": "one sentence, at most 220 characters, naming the regime and the single most
important thing about this surface",
"regime": "exactly one word from facts.admissible.regime_words, or null when that list is empty",
"term_structure": "exactly one word or phrase from facts.admissible.term_words, or null",
"tenor_notes": [
{ "tenor_id": "t2",
"premium": "verbatim from facts.admissible.by_tenor.t2.premium_words, or null",
"skew": "verbatim from facts.admissible.by_tenor.t2.skew_words, or null",
"note": "two or three sentences on what this tenor tells the desk; cite the driver
quote by its id when it matters" }
],
"strategies": [
{ "name": "short name for the trade or the stance",
"primary_greek": "one of delta, gamma, vega, theta, rho",
"rationale": "why this measurement supports it, in one or two sentences" }
],
"watch_items": ["short strings - what would change this reading"],
"unverifiable": ["anything worth saying that facts could not support"]
}
The rules the app enforces on the reply
| Rule | Why |
|---|---|
Exactly one entry in tenor_notes per id in facts.commentary_ids, in that order, and no entry for any other tenor. | Those are the tenors with an at-the-money reading. The rest were refused and are not the model's to comment on. A note that is out of order is a warning; a missing, duplicated or invented tenor is a failure. |
premium appears verbatim in that tenor's premium_words, or is null when the list is empty. | The engine compared the at-the-money implied volatility to the matched realized window and to that window's own 95% sampling band. If the gap does not clear the band, the only admissible word is fairly priced. Calling it rich because it looks rich is a false statement about a measured quantity. |
skew appears verbatim in that tenor's skew_words, or is null. | Skew direction is derived from the measured 25-delta risk reversal. When a wing could not be interpolated there is no risk reversal, so there is no direction to name. |
Leaving premium or skew null when the list is not empty is reported as a warning. | It is not a lie, but the measurement did support a word and the note declined to use it. |
term_structure is in facts.admissible.term_words or null; regime is in facts.admissible.regime_words or null. | Both are single measured shapes with a threshold under them. A slope inside the threshold admits no word at all. |
One to four strategies, each primary_greek one of delta, gamma, vega, theta, rho. | A stance that is not hung on a Greek cannot be traced to anything on the surface. A stance whose rationale depends on a refused number belongs in unverifiable instead. |
One to five watch_items. When the surface is clean, the correct answer is ["nothing on this surface changes the reading"]. | Inventing a risk to fill the list is the same failure as inventing a number. |
| Every figure in the reply traces to a value the engine measured or printed, at the precision it was printed to. | The model does not compute. It does not annualise, interpolate, average or round anything into existence. A figure that is not in facts is flagged as ungrounded; a value written to fewer decimals than the page prints is accepted as a rounding of a real number, not an invention. |
The risk reversal and the butterfly are one fitted smile, and the term structure and approx.sqrt_time are one observation restated. | Neither pair may be cited as two pieces of corroborating evidence. |
An abridged reply
Real output for the surface above, cut down to one tenor note and one stance.
{
"headline": "Normal regime, contango term structure (16.1%/16.8%/17.4% ATM); 3M and 6M carry rich premium and put skew, 1M fairly priced with its call wing unreadable.",
"regime": "normal",
"term_structure": "contango",
"tenor_notes": [
{ "tenor_id": "t2",
"premium": "rich",
"skew": "put skew",
"note": "ATM at 16.8% on 94 days to 2026-11-09, rich against the 60-day realized window (12.7%, a 4.1% gap against a 2.3% band). The 25-delta risk reversal is -7.2. Driver is q21 (5400 strike call), moving the ATM reading by 0.1 vol points." }
/* ... t1 and t3, abridged away ... */
],
"strategies": [
{ "name": "Sell 3M vol",
"primary_greek": "vega",
"rationale": "3M implied (16.8%) clears its realized band against both the 60-day and 90-day windows, the measured signal for rich premium worth selling." }
],
"watch_items": ["q12 (6000 strike, 1M put) re-quoting inside no-arbitrage bounds"],
"unverifiable": ["1M skew direction: refused, the call wing spans 0.26 to 0.97 delta and does not straddle 0.25"]
}
retry_note
explaining what was wrong — reusing the same Idempotency-Key is what stops a
bad first answer costing twice. A reply that fails the contract checks is a different
matter: do not paper over it, name the failing tenor ids and let a human look.
What is free, and what is metered
Almost all of Vol Desk costs nothing and never touches this API. The whole measurement — parsing the chain and the history, solving every implied volatility with its Greeks, iteration count and price residual, the 25-delta wings, the risk reversal and the butterfly, realized volatility with its sampling band, the implied-versus-realized premium, the term structure, the regime, each tenor's driver by removal and refit, the parity, convexity and calendar checks, the two approximation errors and every refusal — is computed in the browser at no cost and with no account. So are the outputs built from it: the desk-note markdown, the quotes CSV, the tenor CSV and the full measurement JSON. No model call, nothing charged.
Only the commentary is metered. That is the pass that gives each measurable tenor a
premium word, a skew word and a note, and writes the headline, the regime and term-structure words, the
stances, the watch list and the unverifiable list. It is what /run and
/run-stream buy you, and it is the only part of the app that costs a credit. An API caller
must supply facts themselves, because nothing on the server computes it. If all you want is
the numbers, you never need a token at all.