Drive Basis Desk from your own code
Everything the web page does is available over HTTP. Send the basis sheet the browser computes for one US Treasury futures contract and its deliverable basket, and get the same review back: whether the future is rich, fair or cheap to cash, a stance (long basis, short basis or no trade), why the cheapest-to-deliver is cheapest, what the delivery option is worth in words, how the basis trade would be built, the risks, one response per flag and the checks to make before acting. The natural use is a basis monitor: a script rebuilds the sheet from end-of-day prices for each contract, asks for the review, and files it next to the sheet.
One thing to be clear about before the first call: the model never does the arithmetic.
Conversion factors, accrued interest, invoice prices, gross basis, carry, net basis, implied repo,
DV01s, the CTD, the scenarios and the flags are all computed by basis.js, the same file
the web page loads, and the result is sent as facts, a JSON string. The model's
job is judgement over those figures. See building the facts below.
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": { ... } } }
There is no slug header. The token is minted for this app (the guest endpoint takes
{"slug":"basis-desk"} in its body), and every later call knows the app from the token.
Send it as Authorization: Bearer … on every call.
The input object IS the request body. There is no {"input": …} wrapper.
A wrapped body returns a 200 with an unknown field 'input' warning, and the model never
sees your facts.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run on an app whose publisher does not sponsor guest runs. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. 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. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
To mint a guest token yourself, POST /guest with
{"slug":"basis-desk"} in the body and no Authorization header. It answers
201 with {token, guest_id, expires_at}. A guest token can call
/me and /estimate. A review is metered, so it needs a
personal token from signing in: a guest run is refused with 403 unless the
publisher sponsors guest runs (/estimate reports this as sponsor_enabled).
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://basis-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. No Authorization header,
# the slug goes in the body. A guest token is enough for /me and /estimate;
# running a review 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":"basis-desk"}'
# HTTP 201
# {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://basis-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 run a metered review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "basis-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r: # 201 Created
guest = json.load(r)["data"]
TOKEN = guest["token"]
print(guest["guest_id"], guest["expires_at"])
// Open https://basis-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 run a metered review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "basis-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, guest.expires_at);
// Open https://basis-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 run a metered review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"basis-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close() // guestRes.StatusCode == 201
var guest struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token, guest.Data.ExpiresAt)
// Open https://basis-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 run a metered review.
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\":\"basis-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.statusCode()); // 201
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://basis-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 run a metered review.
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: "basis-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) } # 201
guest = JSON.parse(res.body)["data"]
TOKEN = guest["token"]
puts guest["guest_id"], guest["expires_at"]
<?php
// Open https://basis-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 run a metered review.
$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" => "basis-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true); // HTTP 201
curl_close($ch);
echo $guest["data"]["token"], " ", $guest["data"]["expires_at"];
// Open https://basis-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 run a metered review.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"basis-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq); // 201 Created
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Console.WriteLine($"{guest.GetProperty("token").GetString()} {guest.GetProperty("expires_at")}");
2. A tiny client
One helper that adds the headers, unwraps data and raises on error. No other header is needed.
# 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"
TOKEN="$SKILLSAFE_TOKEN" # from https://basis-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"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://basis-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"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://basis-desk.skillsafe.ai/tokens.html
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"
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://basis-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.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class BasisDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
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();
}
static String sha256Hex(String s) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://basis-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";
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 BasisDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
public 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");
}
}
3. Check the session and the balance
GET /me answers {subject_type, subject_id, credits}. Branch on
subject_type: it is guest or user, and a guest
can price a run but, unless the publisher sponsors guest runs, cannot start one. credits
is the wallet balance. Compare it against min_credits from the next step before you run,
so a shortfall surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
me = call("me")
if me["subject_type"] != "user":
print("guest token: /estimate works, a review needs a signed-in token")
print(me["subject_type"], me["subject_id"], me.get("credits"))
const me = await call("me");
if (me.subject_type !== "user") console.warn("guest token: /estimate works, a review needs a signed-in token");
console.log(me.subject_type, me.subject_id, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
if me.SubjectType != "user" {
fmt.Println("guest token: /estimate works, a review needs a signed-in token")
}
fmt.Println(me.SubjectType, me.Credits)
String me = BasisDesk.call("me", null);
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
if (!me.contains("\"subject_type\":\"user\"")) System.out.println("guest token: a review needs a signed-in token");
me = call("me")
warn "guest token: a review needs a signed-in token" unless me["subject_type"] == "user"
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']}"
<?php
$me = call("me");
if ($me["subject_type"] !== "user") fwrite(STDERR, "guest token: a review needs a signed-in token\n");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await BasisDesk.Call("me");
var subject = me.GetProperty("subject_type").GetString();
if (subject != "user") Console.Error.WriteLine("guest token: a review needs a signed-in token");
Console.WriteLine($"{subject} {me.GetProperty("credits")}");
4. Price the review (free)
The input object is exactly what the app's form submits. The first field is
task. This app has one lane, so it is always review. A missing or
unknown task is still answered as review, and the reply's lane
says so.
| task | what it does |
|---|---|
review | Reads the basis sheet like a senior basis trader: assessment (rich, fair, cheap), stance (long_basis, short_basis, no_trade), headline, basis read, why the CTD is cheapest, the delivery option, the trade (construction, hedge ratio, carry, exit), risks, flag responses, checks and summary. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | The JSON-encoded output of Basis.buildFacts: the contract, futures price, dates, term repo, every bond's figures, the CTD, the carry model, the assessment, futures DV01, scenarios, switch points, flags and rules. Fields below. |
question | string | What you want to know, up to 2,000 characters. May be empty. Longer text is cut on a word boundary with [...] and facts.note_clipped_chars says how much was cut. |
retry_note | string | Only when resubmitting after an unparseable reply: a plain instruction about the reply's shape. The web app sends it once, as attempt 2. |
The app declares an input schema with task and facts required, so an
estimate of an empty object comes back with missing required field warnings. A warning is
not a rejection: check the warnings array yourself before you run. And
/estimate does very little body validation — a bare string or an array
prices as happily as the real input. Make sure you send a JSON object with
task and facts as strings; the page's own guard,
Basis.mustBeObject, throws on anything else before it calls the API.
Building the facts
A direct API caller builds facts itself; the server does no basis arithmetic. The
engine is basis.js, plain JavaScript with no dependencies that exports itself
to Node through module.exports. Download it next to your script, put the contract in a
JSON file with the form field ids below (the page's Save contract .json button writes
exactly this file, as {"form": {...}, "question": "..."}), and let it build the body:
// make-body.js - node make-body.js contract.json "your question" > body.json
const fs = require("fs");
const Basis = require("./basis.js"); // https://basis-desk.skillsafe.ai/basis.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = Basis.compute(file.form || file); // {ok, errors, warnings, model}
if (!res.ok) throw new Error(res.errors.join(" "));
res.warnings.forEach((w) => console.error("warning:", w)); // unreadable rows, bonds dropped
const body = Basis.mustBeObject(Basis.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));
Or build it inline. This is the page's US Dec 2026 example, the bond contract trading rich:
const Basis = require("./basis.js");
const form = {
contract_name: "USZ6",
contract: "US",
contract_month: "2026-12",
futures_price: "116-18",
settle_date: "2026-09-28",
delivery_date: "", // empty: last business day of the month, marked "assumed"
repo_rate: "3.62",
basket: [
"T 4.75 2041-02-15 101-217",
"T 4.375 2041-05-15 97-22+",
"T 3.25 2042-05-15 85-01",
"T 4 2042-11-15 93-03+",
"T 3.875 2043-02-15 91-18+",
"T 4.75 2043-11-15 101-16",
"T 4.625 2044-05-15 99-307",
"T 4.125 2044-08-15 93-28+",
].join("\n"),
};
const res = Basis.compute(form);
console.log(Basis.verdictLine(res.model));
// CTD B4 (T 4 11/15/42): implied repo 3.84% against repo 3.62% (+22 bp), net basis -1.7 ticks.
// Futures rich to cash on the carry model.
const body = Basis.buildInput(res, "The future looks expensive against the cash bonds. " +
"Is the long basis in the CTD a real opportunity, and what could take it away?");
// body = {task: "review", facts: "<JSON string, about 8.7 KB>", question: "..."}
The form fields
| field id | required | accepted input |
|---|---|---|
contract_name | no | A label for the sheet, for example USZ6. Up to 80 characters. |
contract | yes | TU (2-Year, $200,000 face), Z3N (3-Year), FV (5-Year), TY (10-Year), TN (Ultra 10-Year), US (Treasury Bond) or UB (Ultra Bond); $100,000 face except TU. It sets the conversion-factor rounding (whole months for TU, Z3N, FV; quarters for the rest) and the deliverable maturity window. |
contract_month | yes | The delivery month: 2026-12, Dec 2026 or Dec26. A bare month code like Z6 is not accepted (ambiguous decade). |
futures_price | yes | In 32nds or decimal, between 50 and 250: 116-18, 116-18+ (half a 32nd), 116-182 / 116-185 / 116-187 (a quarter, half, three quarters of a 32nd), 116'18, 116-18.5 or 116.5625. |
settle_date | yes | 2026-09-28, 09/28/2026, 28-Sep-2026 or Sep 28 2026. |
delivery_date | no | Same date formats. Empty means the last weekday of the delivery month, and facts.delivery_date is marked "(assumed: last business day of the month)". Must be after the settlement date. |
repo_rate | yes | Term repo to delivery in percent, between -2 and 25, for example 3.62. Used for every bond without its own repo. |
basket | yes | One deliverable bond per line, see below. Up to 30 bonds. |
The basket. Each line needs a coupon, a maturity date and a clean price (50 to 250,
32nds or decimal), in that order: T 4 2042-11-15 93-03+. Coupons may be written
4.125, 4 1/8, 4-1/8 or 4.125%. Any text before the
coupon is the label. Two optional extras go anywhere on the line: repo 3.20, a
bond-specific repo (it drives the repo_special flag), and cf 0.8870, the
exchange's published conversion factor, checked against the computed one. A trailing bare number with
four or more decimals between 0.3 and 1.6 is read as the CF; any other trailing number as the repo.
A header row (tab, comma, pipe or semicolon separated, with columns such as coupon,
maturity, price, repo, cf, cusip)
switches to column mode. Lines starting with # or // are skipped. Bonds get
ids B1, B2, … in the order they were read; rows that cannot be read and
bonds maturing on or before delivery come back in res.warnings and are not in the sheet.
What is in facts
Numbers in facts are pre-formatted strings with their units ("+6.3 ticks", "3.84%",
"+22 bp", "116-16 (116.4964)"), because the model is told to copy figures exactly as written and the
page re-reads every number in the reply against them.
| key | contents |
|---|---|
units | The conventions in words: prices per 100 face, basis and carry in ticks of 1/32, repo actual/360, semiannual street yields, DV01 per 100 face per bp, hedge ratios in contracts per $10m face, CFs at a 6% yield. |
contract | {id, name, label, face_per_contract, delivery_month} |
futures_price | 32nds with the decimal in brackets: "116-18 (116.5625)" |
settle_date, delivery_date | ISO dates; the delivery date carries "(assumed: …)" when it was defaulted. |
days_to_delivery | A number: calendar days from settlement to delivery. |
term_repo | "3.62%" |
bonds[] | One object per bond: id, label, coupon, maturity, clean_price, yield, repo (marked bond-specific or term repo), conversion_factor, accrued_at_settle, accrued_at_delivery, futures_x_cf, invoice_price, gross_basis, carry, net_basis, implied_repo, irr_minus_repo, irr_rank (1 = highest implied repo among deliverable bonds, or "not deliverable"), net_basis_per_cf, modified_duration, dv01_per_100, in_delivery_window (boolean), hedge_contracts_per_10m_cf, hedge_contracts_per_10m_dv01, coupon_before_delivery. |
ctd | The cheapest-to-deliver: highest implied repo among bonds inside the delivery window. {id, label, implied_repo, repo, irr_minus_repo, net_basis, gross_basis, carry, conversion_factor} |
carry_model | {ctd_by_net_basis_per_cf, fair_futures_price, futures_minus_fair, note}: the carry-only fair price, min over deliverable bonds of forward clean price / CF. It holds no delivery option value. |
assessment | "rich" when the CTD's implied repo is more than 5 bp over its repo, "cheap" when more than 30 bp under, otherwise "fair". |
futures_dv01 | {per_100_face, per_contract, basis: "CTD DV01 / CTD CF"} |
scenarios[] | Parallel yield shifts of -100, -50, -25, 0, +25, +50 and +100 bp: {shift, ctd, fair_futures_price} under each. |
switch_points | {base_ctd, up, down}; up and down are {shift, new_ctd} (searched in 5 bp steps) or the string "no switch up to +150 bp" / "no switch down to -150 bp". |
flags[] | {code, severity, detail, bonds}; see the flag codes. |
rules | The thresholds behind the assessment and the flags, as text: rich above +5 bp, cheap below -30 bp, switch near within 25 bp, far within 50 bp, special repo more than 25 bp under term, yield outlier more than 20 bp off the fit. |
note_clipped_chars | Only when the question was longer than 2,000 characters: how many were cut. |
An excerpt of the real facts for the US Dec 2026 example (8 bonds; bonds shortened to the CTD):
{
"contract": {"id": "US", "name": "Treasury Bond", "label": "USZ6", "face_per_contract": "$100,000", "delivery_month": "2026-12"},
"futures_price": "116-18 (116.5625)",
"settle_date": "2026-09-28",
"delivery_date": "2026-12-31 (assumed: last business day of the month)",
"days_to_delivery": 94,
"term_repo": "3.62%",
"bonds": [ ...,
{"id": "B4", "label": "T 4 11/15/42", "coupon": "4.000%", "maturity": "2042-11-15",
"clean_price": "93-03+ (93.1094)", "yield": "4.610%", "repo": "3.62% (term repo)",
"conversion_factor": "0.7980", "accrued_at_settle": "1.4783", "accrued_at_delivery": "0.5083",
"futures_x_cf": "93.0169", "invoice_price": "93.5252", "gross_basis": "+3.0 ticks",
"carry": "+4.6 ticks", "net_basis": "-1.7 ticks", "implied_repo": "3.84%", "irr_minus_repo": "+22 bp",
"irr_rank": 1, "net_basis_per_cf": "-2.1 ticks", "modified_duration": "11.42", "dv01_per_100": "0.1080",
"in_delivery_window": true, "hedge_contracts_per_10m_cf": "79.8", "hedge_contracts_per_10m_dv01": "79.8",
"coupon_before_delivery": "2026-11-15"},
... ],
"ctd": {"id": "B4", "label": "T 4 11/15/42", "implied_repo": "3.84%", "repo": "3.62%", "irr_minus_repo": "+22 bp",
"net_basis": "-1.7 ticks", "gross_basis": "+3.0 ticks", "carry": "+4.6 ticks", "conversion_factor": "0.7980"},
"carry_model": {"ctd_by_net_basis_per_cf": "B4", "fair_futures_price": "116-16 (116.4964)", "futures_minus_fair": "+2.1 ticks", "note": "..."},
"assessment": "rich",
"futures_dv01": {"per_100_face": "0.1353", "per_contract": "$135", "basis": "CTD DV01 / CTD CF"},
"scenarios": [ ..., {"shift": "0 bp", "ctd": "B4", "fair_futures_price": "116-16 (116.4964)"},
{"shift": "+25 bp", "ctd": "B6", "fair_futures_price": "113-04 (113.1235)"}, ... ],
"switch_points": {"base_ctd": "B4", "up": {"shift": "+10 bp", "new_ctd": "B6"}, "down": "no switch down to -150 bp"},
"flags": [
{"code": "negative_net_basis", "severity": "high", "detail": "B4 net basis -1.7 ticks; B6 net basis -1.6 ticks. ...", "bonds": ["B4", "B6"]},
{"code": "futures_rich", "severity": "medium", "detail": "The CTD's implied repo 3.84% is +22 bp against its repo 3.62%, above the +5 bp line.", "bonds": ["B4"]},
{"code": "ctd_switch_near", "severity": "high", "detail": "The CTD changes from B4 to B6 on a parallel shift of +10 bp. ...", "bonds": ["B4", "B6"]},
{"code": "outside_window", "severity": "high", "detail": "B1 has 14y 2m to maturity ...; B2 has 14y 5m ..., outside the Treasury Bond window. ...", "bonds": ["B1", "B2"]}
],
"rules": {"rich_if_irr_minus_repo_above": "+5 bp", "cheap_if_irr_minus_repo_below": "-30 bp", ...}
}
The request body wraps that object as a string (abbreviated here):
{
"task": "review",
"facts": "{\"units\":\"Prices per 100 face. Basis and carry in ticks of 1/32 of a point (32 ticks = 1 point). ...\",\"contract\":{\"id\":\"US\",\"name\":\"Treasury Bond\",\"label\":\"USZ6\",...",
"question": "The future looks expensive against the cash bonds. Is the long basis in the CTD a real opportunity, and what could take it away?"
}
/estimate is free: it creates no job and charges nothing. It answers
model, model_alias, markup_bps, hold_credits and
min_credits (plus sponsor_enabled, input_checked and
warnings). Read hold_credits as a reservation against the
full output cap, not the price; the real cost is charged_credits on the finished job,
which is normally much lower. A balance under min_credits is refused with 402.
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above.
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
# "hold_credits":…,"min_credits":…,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is the real cost.
INPUT = json.load(open("body.json")) # task, facts, question
assert isinstance(INPUT, dict) and isinstance(INPUT.get("facts"), str) # /estimate will not check this for you
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserve", est["hold_credits"], "minimum", est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation, not the price of the run.
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
if (typeof INPUT !== "object" || Array.isArray(INPUT) || typeof INPUT.facts !== "string") throw new Error("send an object with facts as a string");
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
if err := json.Unmarshal(raw, &input); err != nil { // an object, not a string or an array
panic(err)
}
if _, ok := input["facts"].(string); !ok {
panic("facts must be a JSON string")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
if (!input.trim().startsWith("{")) throw new IllegalArgumentException("the body must be a JSON object");
System.out.println(BasisDesk.call("estimate", input));
// {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
// "hold_credits":…,"min_credits":…,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
raise "facts must be a string" unless INPUT.is_a?(Hash) && INPUT["facts"].is_a?(String)
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
if (!is_array($input) || !is_string($input["facts"] ?? null)) throw new RuntimeException("facts must be a string");
$est = call("estimate", $input);
echo $est["model"], " hold ", $est["hold_credits"], " min ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
if (input.ValueKind != JsonValueKind.Object || input.GetProperty("facts").ValueKind != JsonValueKind.String)
throw new Exception("send an object with facts as a string");
var est = await BasisDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
5. Run it, then poll
POST /run needs a signed-in (user) token and returns a
job_id; poll GET jobs/{job_id} until status is
succeeded or failed. The reply is the string at
data.output.output. The terminal job also carries charged_credits (the real
price) and the truncated flag.
Always send an Idempotency-Key on /run and
/run-stream. The web app derives it from the input with the lane and an attempt counter,
basis-desk:review:<hash>:a<attempt>, where the hash is a short digest of the
JSON body (the page's own looks like basis-desk:review:80354e84-26f3:a1; any stable
digest works). A retried request with the same key returns the same job instead of billing a second
run. Replaying a key with a different body is a 409, so bump the attempt suffix when the body
changes — for example when you add retry_note after an unparseable reply, as the page
does with :a2.
# 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.
KEY="basis-desk:review:$(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\":\"review\",\"assessment\":\"rich\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"basis-desk:review:{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"]
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 = `basis-desk:review:${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;
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("basis-desk:review:%x:a1", 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("charged", job.Charged, "truncated", job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "basis-desk:review:" + BasisDesk.sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BasisDesk.BASE + "/run"))
.header("Authorization", "Bearer " + BasisDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = BasisDesk.HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = BasisDesk.call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) break;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// data.output.output is a string holding the reply JSON; read it with your JSON library
// (Jackson below), along with data.charged_credits and data.truncated.
var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(job).get("data");
String jobOutput = data.get("output").get("output").asText();
System.out.println("charged " + data.get("charged_credits") + " truncated " + data.get("truncated"));
require "digest"
key = "basis-desk:review:#{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"
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
<?php
$key = "basis-desk:review:" . 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));
echo "charged ", $job["charged_credits"], " truncated ", var_export($job["truncated"], true), PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "basis-desk:review:" + 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 {BasisDesk.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 BasisDesk.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);
}
Console.WriteLine($"charged {job.GetProperty("charged_credits")} truncated {job.GetProperty("truncated")}");
6. Or stream it
POST /run-stream is the same call over server-sent events, with the same token rules and
the same Idempotency-Key header. From a server or script, each delta event
carries {"text": "..."}, a chunk of the reply, and the final done event
carries status, charged_credits and truncated (and, when
present, the whole reply at output.output; the web app prefers it and falls back to the
concatenated deltas). In a browser, /run-stream sends progress ticks, not text
deltas, so do not build a live typing view on it there; the done event and the finished job
from step 5 always have the whole reply.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
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\":\"review\",\"assessment\":\"rich\","}
# 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 && done.output && done.output.output) || raw;
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(BasisDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + BasisDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
BasisDesk.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 {BasisDesk.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
data.output.output is a string holding one JSON object. The web app
(recon.js, also a Node module) strips any code fence, takes everything from
the first { to the last }, parses it and normalizes it: lane is
forced to review; an unknown assessment becomes empty (and is then reported
as missing); an unknown stance falls back to no_trade; an unknown risk
severity to medium; ctd.id and risk bond ids are upper-cased
and flag codes lower-cased; missing arrays become empty and risks with neither text nor watch are
dropped. A reply with no headline, basis_read or summary, or
with neither a ctd.id nor a risks array, is treated as unparseable — that is
when the page retries once with retry_note. Then it checks the reply against the facts it
sent. You should do the same.
# review.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("review.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["assessment"], r["stance"], "-", r["headline"])
print("CTD", r["ctd"]["id"], "hedge:", r["trade"]["hedge_ratio"])
for x in r["risks"]:
print(x["severity"], x["risk"])
EOF
STANCES = ("long_basis", "short_basis", "no_trade")
def parse_review(text):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
r["lane"] = "review"
if r.get("assessment") not in ("rich", "fair", "cheap"):
r["assessment"] = "" # reported as missing
if r.get("stance") not in STANCES:
r["stance"] = "no_trade" # the page's fallback
for k in ("risks", "flag_responses", "checks"):
r[k] = r.get(k) or []
r["ctd"] = r.get("ctd") or {}
r["trade"] = r.get("trade") or {}
return r
r = parse_review(text)
print(r["assessment"], r["stance"], r["ctd"].get("id"), [x["severity"] for x in r["risks"]])
// Or reuse the page's own parser: const Recon = require("./recon.js");
// const r = Recon.normalize(Recon.parseResult(text));
function parseReview(text) {
const t = String(text).trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
r.lane = "review";
if (!["rich", "fair", "cheap"].includes(r.assessment)) r.assessment = "";
if (!["long_basis", "short_basis", "no_trade"].includes(r.stance)) r.stance = "no_trade";
for (const k of ["risks", "flag_responses", "checks"]) r[k] = r[k] || [];
r.ctd = r.ctd || {}; r.trade = r.trade || {};
return r;
}
const r = parseReview(text);
console.log(r.assessment, r.stance, r.ctd.id, r.risks.map((x) => x.severity));
type Review struct {
Lane string `json:"lane"`
Assessment string `json:"assessment"`
Stance string `json:"stance"`
Headline string `json:"headline"`
BasisRead string `json:"basis_read"`
CTD struct {
ID string `json:"id"`
Why string `json:"why"`
} `json:"ctd"`
DeliveryOption string `json:"delivery_option"`
Trade struct {
Construction string `json:"construction"`
HedgeRatio string `json:"hedge_ratio"`
Carry string `json:"carry"`
Exit string `json:"exit"`
} `json:"trade"`
Risks []struct {
Risk string `json:"risk"`
Severity string `json:"severity"`
Bonds []string `json:"bonds"`
Watch string `json:"watch"`
} `json:"risks"`
FlagResponses []struct {
Code string `json:"code"`
Response string `json:"response"`
} `json:"flag_responses"`
Checks []string `json:"checks"`
Summary string `json:"summary"`
}
text := jobOutput // data.output.output from step 5
var r Review
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
if r.Stance != "long_basis" && r.Stance != "short_basis" {
r.Stance = "no_trade"
}
fmt.Println(r.Assessment, r.Stance, r.CTD.ID, len(r.Risks))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
String stance = r.path("stance").asText("no_trade");
if (!java.util.List.of("long_basis", "short_basis", "no_trade").contains(stance)) stance = "no_trade";
System.out.println(r.path("assessment").asText() + " " + stance + " " + r.path("ctd").path("id").asText());
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
r["assessment"] = "" unless %w[rich fair cheap].include?(r["assessment"])
r["stance"] = "no_trade" unless %w[long_basis short_basis no_trade].include?(r["stance"])
%w[risks flag_responses checks].each { |k| r[k] ||= [] }
puts r["assessment"], r["stance"], r.dig("ctd", "id")
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
if (!in_array($r["stance"] ?? "", ["long_basis", "short_basis", "no_trade"], true)) $r["stance"] = "no_trade";
foreach (["risks", "flag_responses", "checks"] as $k) $r[$k] = $r[$k] ?? [];
echo $r["assessment"], " ", $r["stance"], " ", $r["ctd"]["id"] ?? "", PHP_EOL;
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
var stance = r.TryGetProperty("stance", out var s) ? s.GetString() : null;
if (stance is not ("long_basis" or "short_basis" or "no_trade")) stance = "no_trade";
Console.WriteLine($"{r.GetProperty("assessment")} {stance} {r.GetProperty("ctd").GetProperty("id")}");
Invariants worth asserting
The page runs Recon.reconcile(result, facts) on every reply and shows each disagreement
next to the review. These are the checks, so a script can hold the reply to the same standard:
- Numbers. Every number written in the prose (headline, basis read, ctd.why, delivery option, the four trade fields, risks, flag responses, checks, summary) must appear somewhere in
facts, matched within half a unit of the last digit written ("3.84%" matches 3.84, "+22 bp" matches 22). Bond ids, bare integers of 10 or less and calendar years are not counted as claims. - Bond ids. Every
B1,B2, … mentioned in the prose or listed inrisks[].bondsis anidinfacts.bonds. - Assessment.
assessmentequalsfacts.assessment; the model copies it and explains it, never decides it. - CTD.
ctd.idequalsfacts.ctd.id. - Stance.
long_basisonly when the assessment isrich;short_basisonly when it ischeap.no_tradeis always consistent (it is also the right answer for afairfuture, or when acf_mismatchoryield_outlierflag names the CTD, or no pasted bond is deliverable). - Flags.
flag_responsesanswers every code infacts.flagsexactly once and invents none.
// Node: the page's own reconciliation, on your reply and the facts you sent.
const Recon = require("./recon.js"); // https://basis-desk.skillsafe.ai/recon.js
const result = Recon.normalize(Recon.parseResult(text));
const check = Recon.reconcile(result, JSON.parse(body.facts));
console.log(check.numbers_checked, "numbers checked,", check.disagreements, "disagreements");
check.items.filter((i) => !i.ok).forEach((i) => console.log(i.kind, "-", i.text));
The output contract
{
"lane": "review",
"assessment": "rich" | "fair" | "cheap",
"stance": "long_basis" | "short_basis" | "no_trade",
"headline": "one sentence: the CTD, its implied repo against repo, and whether the future is rich, fair or cheap to cash",
"basis_read": "3 to 5 sentences: what the gross basis, carry and net basis of the CTD say, how the rest of the basket compares, and what the implied repo against repo means",
"ctd": {"id": "the id in facts.ctd", "why": "why this bond is cheapest to deliver here, in terms of its CF, duration, yield or carry, quoting figures"},
"delivery_option": "2 to 4 sentences on the switch option from scenarios and switch_points, and what the net basis is paying for",
"trade": {
"construction": "what the position would be (long basis, short basis, or why none), which bond against which contract",
"hedge_ratio": "which ratio from facts to use (CF-weighted or DV01-weighted contracts per 10m face) and the figure",
"carry": "what the position earns or pays to delivery, quoting carry and repo",
"exit": "how the position is closed or delivered and what makes it pay"
},
"risks": [
{"risk": "what could go wrong", "severity": "high" | "medium" | "low", "bonds": ["ids it concerns, may be empty"], "watch": "the figure or event to watch"}
],
"flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this contract and what to do about it"}],
"checks": ["something to verify before acting on the sheet"],
"summary": "two sentences: the assessment and the stance, and why"
}
risks has 3 to 5 entries, most important first; checks has 3 to 5;
flag_responses follows the order of facts.flags. Each why,
response, risk, watch and trade field is at most 60 words, and
empty arrays are [], never omitted. When question is not empty,
basis_read or summary answers it directly. The review is analysis, not
advice: it describes what a position would look like, never tells you to trade a size.
An illustrative excerpt of a reply for the US Dec 2026 example (the wording of a real run will differ; every figure is copied from the facts above):
{
"lane": "review",
"assessment": "rich",
"stance": "long_basis",
"headline": "B4 is the CTD with an implied repo of 3.84% against 3.62% repo, +22 bp, so the future is rich to cash.",
"ctd": {"id": "B4", "why": "B4 has the highest implied repo among the deliverable bonds, 3.84%, and the lowest net basis per unit of CF, -2.1 ticks, with a conversion factor of 0.7980."},
"delivery_option": "The CTD switches from B4 to B6 on a +10 bp parallel shift and does not switch down to -150 bp. ...",
"trade": {
"construction": "Long basis: long B4 against short US futures, delivering into the future if the implied repo holds.",
"hedge_ratio": "DV01-weighted, 79.8 contracts per 10m face of B4; the CF-weighted ratio is also 79.8 here.",
"carry": "...", "exit": "..."
},
"risks": [{"risk": "A small rise in yields moves the CTD to B6 and the hedge ratio with it.", "severity": "high", "bonds": ["B4", "B6"], "watch": "The +10 bp switch point."}, ...],
"flag_responses": [
{"code": "negative_net_basis", "response": "..."}, {"code": "futures_rich", "response": "..."},
{"code": "ctd_switch_near", "response": "..."}, {"code": "outside_window", "response": "..."}
],
"checks": ["Confirm the conversion factors against the exchange's published list.", ...],
"summary": "..."
}
The flag codes
Raised by basis.js (flagsFor) and sent in facts.flags; the reply must answer each one.
| code | severity | meaning |
|---|---|---|
negative_net_basis | high | A deliverable bond has a net basis below zero: buying it and delivering earns more than repo. Check that bond's repo, that prices are live and synchronous, and whether it is special. |
futures_rich | medium | The CTD's implied repo is more than 5 bp above its repo. |
futures_cheap | medium | The CTD's implied repo is more than 30 bp below its repo. |
single_bond | low | Only one deliverable bond, so no switch analysis. |
ctd_switch_near | high | The CTD changes on a parallel shift within 25 bp; the hedge ratio will jump if it switches. |
ctd_switch_far | medium | The CTD changes on a parallel shift within 50 bp. |
ctd_rank_disagree | low | The highest implied repo and the lowest net basis per unit of CF name different bonds (possible with bond-specific repo). |
negative_carry_ctd | medium | The CTD's carry to delivery is negative, so early delivery is likely and the assumed delivery date overstates the holding period. |
outside_window | high | A pasted bond's remaining maturity from the first day of the delivery month is outside the contract's window. It is shown but never the CTD; if no bond is inside, all are used and the contract or month is probably wrong. |
repo_special | medium | A bond-specific repo is more than 25 bp under term repo. |
yield_outlier | medium | With 4 or more bonds, a bond's yield is more than 20 bp off the line fitted through the others: a typo or stale mark. |
cf_mismatch | high | A pasted CF differs from the computed one by more than 0.0001. Check the contract, the delivery month and the coupon and maturity. |
short_horizon | low | Fewer than 7 days to delivery: carry and implied repo are noisy. |
delivery_outside_month | high | The delivery date is not in the delivery month (TU, Z3N and FV may run into the first days of the next month). |
8. Use it in a basis monitor
The stance is built to gate on, once the reply has passed the checks above. A
no_trade means a fair future or a sheet that cannot be trusted yet; a
long_basis or short_basis is worth a human look, with the risks and checks
kept next to the sheet.
#!/bin/sh
# Rebuild the sheet, run the review, exit 3 when there is a stance worth a look.
set -e
node make-body.js contract.json "Is there a basis trade worth doing into delivery?" > body.json
INPUT=$(cat body.json)
KEY="basis-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(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: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
ST=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];print(json.loads(t[t.index("{"):t.rindex("}")+1]).get("stance","no_trade"))')
echo "stance: $ST"
[ "$ST" = no_trade ] || exit 3
facts = json.loads(INPUT["facts"])
if r["assessment"] != facts["assessment"] or r["ctd"].get("id") != facts["ctd"]["id"]:
raise SystemExit("reply disagrees with the sheet - do not use it")
print("stance:", r["stance"])
raise SystemExit(3 if r["stance"] != "no_trade" else 0)
const facts = JSON.parse(INPUT.facts);
if (r.assessment !== facts.assessment || r.ctd.id !== facts.ctd.id) throw new Error("reply disagrees with the sheet");
console.log("stance:", r.stance);
process.exitCode = r.stance !== "no_trade" ? 3 : 0;
if r.Stance != "no_trade" {
fmt.Println("stance:", r.Stance)
os.Exit(3)
}
if (!"no_trade".equals(stance)) { System.out.println("stance: " + stance); System.exit(3); }
puts "stance: #{r['stance']}"
exit(r["stance"] == "no_trade" ? 0 : 3)
<?php
echo "stance: ", $r["stance"], PHP_EOL;
exit($r["stance"] === "no_trade" ? 0 : 3);
Console.WriteLine($"stance: {stance}");
return stance == "no_trade" ? 0 : 3;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true. What
you hold then is a prefix of the reply: the headline, basis read and CTD may be complete while the
flag responses, checks and summary are missing. The web page closes the cut-off JSON
(Recon.closeJson), shows the sections that arrived and says how many of the nine
(headline, basis read, CTD, delivery option, trade, risks, flag responses, checks, summary) it
recovered; it does the same when a stream ends early. From code, check the flag before you treat a
reply as complete — a truncated reply will usually fail the one-response-per-flag check — then top
up, resubmit and increment the attempt suffix on the Idempotency-Key.