← Basis Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The 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_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A 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_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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":"…"}}

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
}

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

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.

taskwhat it does
reviewReads 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.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredThe 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.
questionstringWhat 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_notestringOnly 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 idrequiredaccepted input
contract_namenoA label for the sheet, for example USZ6. Up to 80 characters.
contractyesTU (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_monthyesThe delivery month: 2026-12, Dec 2026 or Dec26. A bare month code like Z6 is not accepted (ambiguous decade).
futures_priceyesIn 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_dateyes2026-09-28, 09/28/2026, 28-Sep-2026 or Sep 28 2026.
delivery_datenoSame 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_rateyesTerm repo to delivery in percent, between -2 and 25, for example 3.62. Used for every bond without its own repo.
basketyesOne 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.

keycontents
unitsThe 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_price32nds with the decimal in brackets: "116-18 (116.5625)"
settle_date, delivery_dateISO dates; the delivery date carries "(assumed: …)" when it was defaulted.
days_to_deliveryA 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.
ctdThe 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.
rulesThe 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_charsOnly 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.

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

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}

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

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:

// 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.

codeseveritymeaning
negative_net_basishighA 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_richmediumThe CTD's implied repo is more than 5 bp above its repo.
futures_cheapmediumThe CTD's implied repo is more than 30 bp below its repo.
single_bondlowOnly one deliverable bond, so no switch analysis.
ctd_switch_nearhighThe CTD changes on a parallel shift within 25 bp; the hedge ratio will jump if it switches.
ctd_switch_farmediumThe CTD changes on a parallel shift within 50 bp.
ctd_rank_disagreelowThe highest implied repo and the lowest net basis per unit of CF name different bonds (possible with bond-specific repo).
negative_carry_ctdmediumThe CTD's carry to delivery is negative, so early delivery is likely and the assumed delivery date overstates the holding period.
outside_windowhighA 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_specialmediumA bond-specific repo is more than 25 bp under term repo.
yield_outliermediumWith 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_mismatchhighA pasted CF differs from the computed one by more than 0.0001. Check the contract, the delivery month and the coupon and maturity.
short_horizonlowFewer than 7 days to delivery: carry and implied repo are noisy.
delivery_outside_monthhighThe 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

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.