← Spread Desk / API
Tokens

Drive Spread Desk from your own code

Everything the web page does is available over HTTP. Send the spread sheet the browser computes for one fixed-coupon bond (coupon, frequency, day count, settlement, maturity and a clean price or a yield, the government curve and, where you have them, a swap curve, an issuer-peer credit spread curve, comparable bonds and a history of the bond's G-spread), and get the same review back: the rich/cheap assessment copied from the sheet, a recommendation (overweight, neutral, underweight or no view) with its conviction, a read of what the G-spread is made of, of the comps and the history, of the rate-shock scenarios and of how much spread move would flip the call, the risks, one response per flag and the checks to make before acting. The natural use is a daily relative value monitor: a script rebuilds the sheet from end-of-day prices, curves and comps, 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. The price and yield, accrued interest, Macaulay and modified duration, convexity and DV01, the G-spread, I-spread and swap spread, the decomposition into the credit curve spread and the residual, the spread to the comps line, the history z-score and percentile, the rate-shock scenario table, the breakevens, the signals with their flips, the assessment and the flags are all computed by spread.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; it copies them and never recomputes one. A direct API caller must therefore compute the facts the same way (run spread.js, or send the sheet the page built) — a hand-rolled facts with different figures gets a review of different 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":"spread-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":"spread-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://spread-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":"spread-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://spread-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 spread sheet like a senior fixed income relative value analyst: the assessment (cheap, fair, rich, n/a) copied from the sheet, a recommendation (overweight, neutral, underweight or no_view) and conviction, headline, spread read, relative read (comps and history), scenario read, flip read, risks, flag responses, checks and summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredA JSON string you build: the JSON-encoded output of Spread.buildFacts (the browser builds it from the spread sheet). It holds the bond's price, yield and risk figures, the curves at its maturity, the spreads and their decomposition, the comps fit, the history, the scenarios, the breakevens, the signals and the primary call, the assessment, the signal trust, the flags and the rules. Fields below.
questionstring, optionalWhat 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_notestring, optional (app-set only)Only when resubmitting after an unparseable reply: a plain instruction about the reply's shape. The web app sends it once, as attempt 2; you normally never set it on a first run.

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, Spread.mustBeObject, throws on anything else before it calls the API.

Building the facts

A direct API caller builds facts itself; the server does no bond arithmetic and the model never recomputes a figure, so compute the facts exactly the way the page does. The engine is spread.js, plain JavaScript with no dependencies that exports itself to Node through module.exports. Download it next to your script, put the bond in a JSON file with the form field ids below (the page's Save bond .json button writes exactly this file, as {"form": {...}, "question": "..."}), and let it build the body:

// make-body.js - node make-body.js bond.json "your question" > body.json
const fs = require("fs");
const Spread = require("./spread.js");         // https://spread-desk.skillsafe.ai/spread.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = Spread.compute(file.form || file); // {ok, errors, warnings, inputs, model}
if (!res.ok) throw new Error(res.errors.join(" "));
res.warnings.forEach((w) => console.error("warning:", w));   // skipped lines, defaults used
const body = Spread.mustBeObject(Spread.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));

Or build it inline. This is the page's Hanseatic 2031 example: a EUR utility quoted on yield, rich to four comparable utilities while its own G-spread history reads fair, with no swap or credit curve (illustrative, not market data):

const Spread = require("./spread.js");
const form = {
  bond: "Hanseatic Utilities 3.125% 2031",
  currency: "EUR",
  coupon: "3.125",                   // percent a year
  frequency: "1",                    // coupons a year: 1, 2, 4 or 12
  day_count: "ACT/ACT",              // 30/360, ACT/ACT, ACT/365 or ACT/360
  settle: "2026-09-29",
  maturity: "2031-11-12",
  quote_mode: "yield",               // "price" (clean, per 100), "yield" (percent) or "spread" (G-spread, bp)
  quote: "3.02",
  notional: "5000000",               // face amount, in the bond's currency
  govt: ["1Y 2.05", "2Y 2.12", "3Y 2.18", "5Y 2.29", "7Y 2.44", "10Y 2.62", "30Y 3.05"].join("\n"),
  swap: "",
  credit: "",
  comps: [
    "Moselgrund Energie 2.75% 2030 | 2030-06-20 | 83",
    "Nordsee Netz 3.25% 2031 | 2031-04-02 | 91",
    "Alpen Strom 3.00% 2032 | 2032-09-15 | 96",
    "Baltic Grid 3.50% 2033 | 2033-10-01 | 104",
  ].join("\n"),
  history: "date,g_spread_bp\n2026-01-05,69.1\n2026-01-12,72.3\n...\n2026-09-14,76.0\n2026-09-21,73.0",  // 38 weekly lines
};
const res = Spread.compute(form);
console.log(Spread.verdictLine(res.model));
// Hanseatic Utilities 3.125% 2031: yield 3.020%, G-spread +72.1 bp; spread to the comps fit -20.3 bp - RICH (1 of 2 signals agree);
// 10.3 bp widening to fair (3.123%, 100.004); DV01 EUR 2,348.
const body = Spread.buildInput(res, "It trades well inside the other utilities. Is that rich enough to " +
  "underweight when the history says fair?");
// body = {task: "review", facts: "<JSON string, 4,484 characters>", question: "..."}

If you do not run JavaScript, copy the sheet instead of re-deriving it: the page's Save bond .json file plus spread.js in any Node runtime reproduces the facts byte for byte, and the Sheet .md and Scenarios CSV exports carry the same figures for a human check. Porting the arithmetic is possible (the formulas are listed below), but the rounding and formatting must match too, because the page reconciles the reply against the exact strings it sent.

The form fields

field idrequiredaccepted input
bondyesA name for the bond (issuer, coupon, maturity), up to 80 characters. Empty: "the bond" is used, with a warning.
currencyyesA three-letter code; anything that is not a letter is dropped. Default USD; anything that is not three letters falls back to USD with a warning. It is the only currency the review may name.
couponyesPercent a year, from 0 (a zero-coupon bond) to 30. Thousands commas, a leading + and a trailing % are stripped. Missing or out of range: an error.
frequencyyesCoupons a year: 1, 2 (the default), 4 or 12. Anything else becomes 2 with a warning.
day_countyes30/360 (the default, US bond basis), ACT/ACT, ACT/365 or ACT/360. Anything else becomes 30/360 with a warning.
settle, maturityyesReal dates written YYYY-MM-DD (/ and . are accepted as separators); also 15.05.2033 (day first), 15-May-2033, 15 May 2033, May 15, 2033, and a slashed 05/15/2033 or 15/05/2033 when one side is above 12 or the other date settles the order. A slashed date that reads both ways is an error naming both readings. Missing or unreadable: an error; the maturity must be after settlement and no more than 100 years later.
quote_modeyes"price" (the default: a clean price per 100 of face), "yield" (the yield to maturity in percent, -50 to 200) or "spread" (the G-spread in bp, -1,000 to 10,000: the yield is the government curve at maturity plus the spread; facts.bond.quote_given is then "G-spread").
quoteyesThe clean price (a positive number, or 32nds such as 99-16, 99-16+, 99-163), the yield or the G-spread. Missing or unreadable: an error. A price that no yield between -50% and 200% reproduces is an error too.
notionalyesThe face amount in the bond's currency, for example 10000000. Empty or not a positive number: 10,000,000 is used (with a warning when something unreadable was typed). Capped at 100,000,000,000.
govtyesThe government curve, one tenor per line with its yield in percent; at least two points. See below.
swapnoThe swap curve, same format, rates in percent; fewer than two points is ignored with a warning.
creditnoThe issuer-peer credit spread curve, same format, spreads over government in bp.
compsnoComparable bonds, one per line; see below. Up to 25.
historynoThe bond's own G-spread history in bp; see below. Empty means facts.history is "not supplied".

The curves. One point per line: a tenor then a value, 5Y 2.29. Cells may be separated by commas, semicolons, tabs, pipes or spaces; anything after # is a comment; a line with no number (a header such as tenor yield) is skipped. Tenors are one to three digits and a unit (3M, 6mo, 2Y, 10yr, weeks allowed) or a bare number of years up to 100; 12M is read as 1Y and 24M as 2Y, but 18M stays. A tenor given twice keeps the later line; points are sorted by tenor and at most 40 are used (the shortest; a cut raises input_truncated). 2 YR 3.62 is read as 2Y; a value written with a decimal comma where the comma cannot be a separator (2Y 3,62, 2Y;3,62) is read as 3.62, with a warning; a line with more than one value uses the first after the tenor, with a warning (paste the mid, not bid and ask). Values beyond 1,000 (percent curves) or 100,000 (bp) are skipped as unreadable. Government and swap values are yields in percent; the credit curve is in bp, and a credit column that tops out at 5 or less is taken as percent and multiplied by 100 (decided once for the whole column, with a warning). Every skipped or repaired line comes back in res.warnings.

The comps. One bond per line: a name, a maturity (an ISO date, a tenor or a number of years) and its G-spread in bp. With commas, tabs, pipes or semicolons the cells split on those; with only spaces, the last two cells are the maturity and the spread and everything before them is the name. A dated maturity must fall after settlement. A header line is skipped.

The spread history. One observation per line, 2026-09-21,73.0 or a bare spread (read oldest first). Dated histories are sorted by date and a repeated date keeps the later line; a mix of dated and undated lines keeps only the dated ones. At most the last 800 observations are kept (a cut raises input_truncated), observations dated on or after settlement are left out of the statistics (today is compared against the history, not counted in it), and fewer than two means no history. It must be the same G-spread measure the sheet computes: a last observation more than 30 bp from today's G-spread raises history_mismatch.

Errors (in res.errors, and no facts are built): no coupon or one out of range, a missing or unreadable settlement or maturity date, a maturity not after settlement, no quote, no government curve or fewer than two points on it, no coupon after settlement, or a price no yield reproduces.

What is in facts

Numbers in facts are pre-formatted strings with their units ("3.020%", "+72.1 bp", "-20.3 bp", "4.55", "0.0470", "EUR 2,348", "+EUR 241,742", "-0.37", "39.5%"), because the model is told to copy figures exactly as written and the page re-reads every number in the reply against them. The top-level keys, in the order buildFacts writes them:

keycontents
unitsThe conventions in words: prices per 100 of face; yields and curve points in percent; spreads in bp; what the G-spread, I-spread, swap spread and credit curve are; durations in years; how DV01 and the scenarios are defined; the money currency.
bond{name, currency, coupon, coupons_a_year, day_count, settlement, maturity, years_to_maturity, quote_given, clean_price, accrued, dirty_price, ytm, next_coupon, macaulay_duration, modified_duration, convexity, dv01_per_100, dv01, face, market_value}.
curves{govt_at_maturity, govt_tenors, swap_at_maturity, swap_tenors, credit_at_maturity, credit_tenors}. Each *_at_maturity is the interpolated value with the points used ("2.299% (between 5Y and 7Y)", "(at 5Y)" or "(held flat from 30Y)"), or "not supplied"; the tenor lists are the only tenors the review may name.
spreads{g_spread, i_spread, swap_spread}; the last two are "not supplied" without a swap curve.
decompositionThree rows {component, spread, share}: the G-spread (100%), the credit curve spread and the residual (liquidity + technicals) with their shares of the total; or "not supplied (no credit curve)".
comps{method, count, fit_at_maturity, spread_to_comps, slope, rms, maturity_range, rows[]}, each row {name, years, g_spread, fit, gap}; or "not supplied". method is single comp, average (one maturity), line through two comps or least-squares line.
history{observations, first_date, last_date, mean, sd, min, max, last, today_vs_last, z_score, percentile, recent_change}, or "not supplied". Undated histories give "undated" dates.
scenarios[]One per parallel yield shift (-100, -50, 0, +50, +100 bp): {shift, ytm, clean_price, price_change, pnl, pct_change, duration_convexity_estimate}.
breakevens{spread_widening_1y, yield_rise_1y}: the bp of spread widening, or of yield rise, over a year that the spread over government, or the whole yield, pays for.
signals[]The rich/cheap signals available, in order of preference: {id, measure, value, call, cheap_from, rich_from, flips[]}, each flip {to, spread_move, size}. See the signals.
primary_signalThe id of the first available signal (residual, comps, history), or "none".
assessment, assessment_measurecheap, fair, rich or n/a: the primary signal's call, with its value and thresholds in words.
signals_availableHow many signals were computed, 0 to 3 (a number).
signals_agree"yes" when every signal gives the same call, "no" otherwise, "n/a" with none.
signal_trust"trusted", or "untrusted" when any high-severity flag was raised; the recommendation must then be no_view.
flags[]{code, severity, detail}; see the flag codes.
rulesThe RULES thresholds as strings with their units, plus the shifts; see the rules.
note_clipped_charsOnly when the question was longer than 2,000 characters: how many were cut.

How the figures are computed

For a bond with coupon c (percent a year), f coupons a year, yield y, face N and maturity T years after settlement (actual days / 365.25):

The worked example

The full request body the page builds for the Hanseatic 2031 example (this is a real run input; the facts string is 4,484 characters):

{
 "task": "review",
 "facts": "{\"units\":\"Prices per 100 of face; yields and curve points in percent; spreads in basis points (bp); G-spread = yield minus the government curve interpolated at the bond's maturity, I-spread = yield minus the swap curve there, swap spread = swap minus government; credit curve in bp over government; durations in years; DV01 = price change for a 1 bp fall in yield, per 100 and on the face amount; scenarios are parallel shifts of the bond's yield, fully revalued, P&L on the face amount at unchanged settlement; the history is the pasted observations only, and today's G-spread is compared against them, not counted among them; money in EUR.\",\"bond\":{\"name\":\"Hanseatic Utilities 3.125% 2031\",\"currency\":\"EUR\",\"coupon\":\"3.125%\",\"coupons_a_year\":1,\"day_count\":\"ACT/ACT\",\"settlement\":\"2026-09-29\",\"maturity\":\"2031-11-12\",\"years_to_maturity\":\"5.12\",\"quote_given\":\"yield\",\"clean_price\":\"100.486\",\"accrued\":\"2.7483\",\"dirty_price\":\"103.235\",\"ytm\":\"3.020%\",\"next_coupon\":\"2026-11-12\",\"macaulay_duration\":\"4.69\",\"modified_duration\":\"4.55\",\"convexity\":\"26.4\",\"dv01_per_100\":\"0.0470\",\"dv01\":\"EUR 2,348\",\"face\":\"EUR 5,000,000\",\"market_value\":\"EUR 5,161,733\"},\"curves\":{\"govt_at_maturity\":\"2.299% (between 5Y and 7Y)\",\"govt_tenors\":[\"1Y\",\"2Y\",\"3Y\",\"5Y\",\"7Y\",\"10Y\",\"30Y\"],\"swap_at_maturity\":\"not supplied\",\"swap_tenors\":[],\"credit_at_maturity\":\"not supplied\",\"credit_tenors\":[]},\"spreads\":{\"g_spread\":\"+72.1 bp\",\"i_spread\":\"not supplied\",\"swap_spread\":\"not supplied\"},\"decomposition\":\"not supplied (no credit curve)\",\"comps\":{\"method\":\"least-squares line\",\"count\":4,\"fit_at_maturity\":\"+92.4 bp\",\"spread_to_comps\":\"-20.3 bp\",\"slope\":\"+5.9 bp a year\",\"rms\":\"1.4 bp\",\"maturity_range\":\"3.72 to 7.01 years\",\"rows\":[{\"name\":\"Moselgrund Energie 2.75% 2030\",\"years\":\"3.72\",\"g_spread\":\"+83.0 bp\",\"fit\":\"+84.2 bp\",\"gap\":\"-1.2 bp\"},{\"name\":\"Nordsee Netz 3.25% 2031\",\"years\":\"4.51\",\"g_spread\":\"+91.0 bp\",\"fit\":\"+88.8 bp\",\"gap\":\"+2.2 bp\"},{\"name\":\"Alpen Strom 3.00% 2032\",\"years\":\"5.96\",\"g_spread\":\"+96.0 bp\",\"fit\":\"+97.4 bp\",\"gap\":\"-1.4 bp\"},{\"name\":\"Baltic Grid 3.50% 2033\",\"years\":\"7.01\",\"g_spread\":\"+104.0 bp\",\"fit\":\"+103.6 bp\",\"gap\":\"+0.4 bp\"}]},\"history\":{\"observations\":38,\"first_date\":\"2026-01-05\",\"last_date\":\"2026-09-21\",\"mean\":\"73.8 bp\",\"sd\":\"4.6 bp\",\"min\":\"65.3 bp\",\"max\":\"85.7 bp\",\"last\":\"73.0 bp\",\"today_vs_last\":\"-0.9 bp\",\"z_score\":\"-0.37\",\"percentile\":\"39.5%\",\"recent_change\":\"-12.7 bp over the last 21 observations\"},\"scenarios\":[{\"shift\":\"-100 bp\",\"ytm\":\"2.020%\",\"clean_price\":\"105.321\",\"price_change\":\"+4.835\",\"pnl\":\"+EUR 241,742\",\"pct_change\":\"+4.68%\",\"duration_convexity_estimate\":\"+4.832\"},{\"shift\":\"-50 bp\",\"ytm\":\"2.520%\",\"clean_price\":\"102.868\",\"price_change\":\"+2.382\",\"pnl\":\"+EUR 119,105\",\"pct_change\":\"+2.31%\",\"duration_convexity_estimate\":\"+2.382\"},{\"shift\":\"0 bp\",\"ytm\":\"3.020%\",\"clean_price\":\"100.486\",\"price_change\":\"0.000\",\"pnl\":\"EUR 0\",\"pct_change\":\"0.00%\",\"duration_convexity_estimate\":\"0.000\"},{\"shift\":\"+50 bp\",\"ytm\":\"3.520%\",\"clean_price\":\"98.173\",\"price_change\":\"-2.314\",\"pnl\":\"-EUR 115,692\",\"pct_change\":\"-2.24%\",\"duration_convexity_estimate\":\"-2.313\"},{\"shift\":\"+100 bp\",\"ytm\":\"4.020%\",\"clean_price\":\"95.925\",\"price_change\":\"-4.562\",\"pnl\":\"-EUR 228,087\",\"pct_change\":\"-4.42%\",\"duration_convexity_estimate\":\"-4.559\"}],\"breakevens\":{\"spread_widening_1y\":\"15.9 bp\",\"yield_rise_1y\":\"66.4 bp\"},\"signals\":[{\"id\":\"comps\",\"measure\":\"spread to the comps fit\",\"value\":\"-20.3 bp\",\"call\":\"rich\",\"cheap_from\":\"+10 bp\",\"rich_from\":\"-10 bp\",\"flips\":[{\"to\":\"fair\",\"spread_move\":\"widening\",\"size\":\"10.3 bp\"}]},{\"id\":\"history\",\"measure\":\"z-score against the spread history\",\"value\":\"-0.37\",\"call\":\"fair\",\"cheap_from\":\"+1.00\",\"rich_from\":\"-1.00\",\"flips\":[{\"to\":\"cheap\",\"spread_move\":\"widening\",\"size\":\"6.3 bp\"},{\"to\":\"rich\",\"spread_move\":\"tightening\",\"size\":\"2.9 bp\"}]}],\"primary_signal\":\"comps\",\"assessment\":\"rich\",\"assessment_measure\":\"spread to the comps fit -20.3 bp (cheap at +10 bp or more, rich at -10 bp or less)\",\"signals_available\":2,\"signals_agree\":\"no\",\"signal_trust\":\"trusted\",\"flags\":[],\"rules\":{\"residual_cheap_from\":\"+10 bp\",\"residual_rich_from\":\"-10 bp\",\"comps_cheap_from\":\"+10 bp\",\"comps_rich_from\":\"-10 bp\",\"z_cheap_from\":\"+1.00\",\"z_rich_from\":\"-1.00\",\"curve_kink_above\":\"40 bp\",\"comps_rms_above\":\"25 bp\",\"history_gap_above\":\"30 bp\",\"spread_scale_above\":\"3000 bp\",\"min_history_observations\":20,\"min_govt_points\":4,\"small_flip_below\":\"3 bp\",\"long_duration_above\":12,\"deep_discount_below\":80,\"deep_premium_above\":120,\"shifts\":[\"-100 bp\",\"-50 bp\",\"0 bp\",\"+50 bp\",\"+100 bp\"]}}",
 "question": "It trades well inside the other utilities. Is that rich enough to underweight when the history says fair?"
}

The same facts decoded, with the long arrays shortened:

{
  "units": "Prices per 100 of face; yields and curve points in percent; spreads in basis points (bp); ... money in EUR.",
  "bond": {"name": "Hanseatic Utilities 3.125% 2031", "currency": "EUR", "coupon": "3.125%", "coupons_a_year": 1,
           "day_count": "ACT/ACT", "settlement": "2026-09-29", "maturity": "2031-11-12", "years_to_maturity": "5.12",
           "quote_given": "yield", "clean_price": "100.486", "accrued": "2.7483", "dirty_price": "103.235", "ytm": "3.020%",
           "next_coupon": "2026-11-12", "macaulay_duration": "4.69", "modified_duration": "4.55", "convexity": "26.4",
           "dv01_per_100": "0.0470", "dv01": "EUR 2,348", "face": "EUR 5,000,000", "market_value": "EUR 5,161,733"},
  "curves": {"govt_at_maturity": "2.299% (between 5Y and 7Y)", "govt_tenors": ["1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "30Y"],
             "swap_at_maturity": "not supplied", "swap_tenors": [], "credit_at_maturity": "not supplied", "credit_tenors": []},
  "spreads": {"g_spread": "+72.1 bp", "i_spread": "not supplied", "swap_spread": "not supplied"},
  "decomposition": "not supplied (no credit curve)",
  "comps": {"method": "least-squares line", "count": 4, "fit_at_maturity": "+92.4 bp", "spread_to_comps": "-20.3 bp",
            "slope": "+5.9 bp a year", "rms": "1.4 bp", "maturity_range": "3.72 to 7.01 years",
            "rows": [{"name": "Moselgrund Energie 2.75% 2030", "years": "3.72", "g_spread": "+83.0 bp", "fit": "+84.2 bp", "gap": "-1.2 bp"}, ...]},
  "history": {"observations": 38, "first_date": "2026-01-05", "last_date": "2026-09-21", "mean": "73.8 bp", "sd": "4.6 bp",
              "min": "65.3 bp", "max": "85.7 bp", "last": "73.0 bp", "today_vs_last": "-0.9 bp", "z_score": "-0.37",
              "percentile": "39.5%", "recent_change": "-12.7 bp over the last 21 observations"},
  "scenarios": [
    {"shift": "-100 bp", "ytm": "2.020%", "clean_price": "105.321", "price_change": "+4.835", "pnl": "+EUR 241,742",
     "pct_change": "+4.68%", "duration_convexity_estimate": "+4.832"},
    ...,
    {"shift": "+100 bp", "ytm": "4.020%", "clean_price": "95.925", "price_change": "-4.562", "pnl": "-EUR 228,087",
     "pct_change": "-4.42%", "duration_convexity_estimate": "-4.559"}
  ],
  "breakevens": {"spread_widening_1y": "15.9 bp", "yield_rise_1y": "66.4 bp"},
  "signals": [
    {"id": "comps", "measure": "spread to the comps fit", "value": "-20.3 bp", "call": "rich", "cheap_from": "+10 bp",
     "rich_from": "-10 bp", "flips": [{"to": "fair", "spread_move": "widening", "size": "10.3 bp"}]},
    {"id": "history", "measure": "z-score against the spread history", "value": "-0.37", "call": "fair", "cheap_from": "+1.00",
     "rich_from": "-1.00", "flips": [{"to": "cheap", "spread_move": "widening", "size": "6.3 bp"},
                                     {"to": "rich", "spread_move": "tightening", "size": "2.9 bp"}]}
  ],
  "primary_signal": "comps",
  "assessment": "rich",
  "assessment_measure": "spread to the comps fit -20.3 bp (cheap at +10 bp or more, rich at -10 bp or less)",
  "signals_available": 2,
  "signals_agree": "no",
  "signal_trust": "trusted",
  "flags": [],
  "rules": {"residual_cheap_from": "+10 bp", "residual_rich_from": "-10 bp", "comps_cheap_from": "+10 bp", ..., "shifts": ["-100 bp", "-50 bp", "0 bp", "+50 bp", "+100 bp"]}
}

/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, spread-desk:review:<hash>:a<attempt>, where the hash is a short digest of the JSON body (the page's own is a 32-bit djb2 hash of the body and its length, both in hex; for the Hanseatic 2031 example it is spread-desk:review:df3e9053-13fc: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="spread-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; assessment, recommendation and conviction are lower-cased with spaces and hyphens turned into underscores (n/a is kept as written), and an unknown assessment or conviction becomes empty (and is then reported as missing); a missing or unknown recommendation becomes no_view and is reported as not given; an unknown risk severity becomes medium; flag codes are lower-cased; missing arrays become empty; risks with neither text nor watch, flag responses without a code or response, and empty checks are dropped. A reply with none of headline, spread_read and summary, or with neither a valid recommendation 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["recommendation"], r["conviction"], "-", r["headline"])
print("flip:", r["flip_read"])
for x in r["risks"]:
    print(x["severity"], x["risk"], "| watch:", x["watch"])
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://spread-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 and names checked,", check.disagreements, "disagreements");
console.log("flags answered:", check.coverage.flags_answered, "of", check.coverage.flags_total);
check.items.filter((i) => !i.ok).forEach((i) => console.log(i.kind, "-", i.text));

The output contract

{
  "lane": "review",
  "assessment": "cheap" | "fair" | "rich" | "n/a",
  "recommendation": "overweight" | "neutral" | "underweight" | "no_view",
  "conviction": "high" | "medium" | "low" | "none",
  "headline": "one sentence: the bond, its G-spread, the primary signal's value with the assessment, and the recommendation",
  "spread_read": "3 to 5 sentences: what the G-spread is made of (the credit curve spread and the residual with their shares, or that no credit curve was supplied), the I-spread and swap spread where given, and what the residual or its absence says about liquidity and technicals",
  "relative_read": "2 to 4 sentences on the comps (the spread to the comps line, the slope and how tight the fit is) and the history (z-score, percentile, range and recent change), or a sentence for each saying it was not supplied, and whether the signals agree",
  "scenario_read": "2 to 3 sentences on the rate shocks: the P&L at the largest shifts on the face amount, duration and convexity, and the one-year spread breakeven",
  "flip_read": "1 to 2 sentences: how many bp of spread tightening or widening would change the primary call, quoting the flips, and whether the other signals would move with it",
  "risks": [
    {"risk": "what could go wrong", "severity": "high" | "medium" | "low", "watch": "the figure or event to watch"}
  ],
  "flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this call and what to do about it"}],
  "checks": ["something to verify before acting on the sheet"],
  "summary": "two sentences: the assessment and the recommendation, and why"
}
fieldallowed values
lanereview
assessmentcheap, fair, rich, n/a — must equal facts.assessment
recommendationoverweight, neutral, underweight, no_view — no_view required when signal_trust is untrusted or the assessment is n/a; cheap allows overweight or neutral, rich allows underweight or neutral, fair allows only neutral
convictionhigh, medium, low, none — none exactly for no_view; high only with 2 or more signals that agree and no medium or high flag
risks[].severityhigh, medium, low
flag_responses[].codeeach code in facts.flags, once, in the same order

Recommendation and conviction. On a cheap or rich call the review chooses neutral when the signals disagree, when the primary flip is small (a few bp), or when a medium flag undercuts the primary signal, and says which. high conviction needs two or more signals that agree and no medium or high flag; medium is used when the primary signal is supported by at least one other signal or stands alone with no medium flag, and low when more stands against it. A neutral on a fair assessment carries the conviction that the bond is fairly priced. For no_view, spread_read or summary says which input must be fixed or added first. The residual to the credit curve weighs above the comps gap, and the comps gap above the history: a peer curve prices rates and credit together, comps are a thinner sample, and a history says only where the spread sits against itself.

risks has 3 to 5 entries, most important first; checks has 3 to 5; flag_responses has exactly one entry per code in facts.flags, in the same order (an empty array when there are no flags). Each risk, watch, response and checks item is at most 60 words, headline is one sentence and the reads keep to their sentence counts; empty arrays are [], never omitted. When question is not empty, spread_read or summary answers it directly. Spreads are written in bp, money as the facts write it ("EUR 2,348"), tenors only as in facts.curves, the maturity as facts.bond.maturity or years_to_maturity, and only the bond's currency is named. A spread is called wide or tight against its past only when facts.history is supplied. 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 Hanseatic 2031 example (the wording of a real run will differ; every figure is copied from the facts above):

{
  "lane": "review",
  "assessment": "rich",
  "recommendation": "neutral",
  "conviction": "low",
  "headline": "Hanseatic Utilities 3.125% 2031 at a G-spread of +72.1 bp sits 20.3 bp inside its comps line, a rich assessment, but with the history fair the recommendation is neutral.",
  "spread_read": "The whole +72.1 bp G-spread is unexplained by a peer curve, because no credit curve was supplied ... No swap curve was given either, so there is no I-spread or swap spread ...",
  "relative_read": "Against four utilities the bond is -20.3 bp to a least-squares line at +92.4 bp, with a tight 1.4 bp scatter ... Its own history puts it at a z-score of -0.37 and the 39.5% percentile, fair, so the signals do not agree.",
  "scenario_read": "A 100 bp fall in yield makes +EUR 241,742 on the face and a 100 bp rise loses -EUR 228,087 ... The spread could widen 15.9 bp over a year before the extra yield over government is used up.",
  "flip_read": "10.3 bp of widening would take the comps call to fair, while 2.9 bp of tightening would turn the history rich as well.",
  "risks": [{"risk": "The comps may carry a new-issue or supply discount this bond does not, so the gap closes by the comps tightening.", "severity": "medium", "watch": "The -20.3 bp spread to the comps line."}, ...],
  "flag_responses": [],
  "checks": ["Confirm the comps have the same seniority and currency as the bond.", ...],
  "summary": "..."
}

The flag codes

Raised by spread.js (in flagsFor, in this order) and sent in facts.flags; the reply must answer each one. Any high-severity flag sets signal_trust to untrusted and forces the recommendation to no_view. A code can appear twice (curve_scale_suspect for both curves, curve_kink per point); it gets one response.

codeseveritymeaning
price_scale_suspecthighA clean price below 20 or above 250 when a price was given: a price per 1, or a yield in the price box, is far more likely.
yield_scale_suspecthighA yield above 40% or below -2%, or a yield typed under 0.3 that sits more than 100 bp under the curve (a decimal such as 0.0515 for 5.15%): check the quote and the coupon.
coupon_scale_suspecthighA coupon under 0.3% with the bond more than 100 bp under the government curve: the coupon looks like a decimal (0.0525 for 5.25%). A real low-coupon bond priced at its yield does not raise it.
curve_scale_suspecthighThe government or swap curve has a point above 25 (basis points or prices were pasted where percent yields belong), or the whole government curve is under 0.3 while the bond is more than 100 bp above it (decimals).
credit_curve_scale_suspecthighEvery credit curve point, read as a yield, is above the government yield at its tenor, the residual is beyond 100 bp, and reading the curve as yields would explain the bond better: peer yields were pasted where spreads over government belong.
spread_scale_suspecthigh|G-spread| beyond 3000 bp: the price, the curve or the currency is probably wrong.
history_mismatchhighThe last history spread is more than 30 bp from today's G-spread: the history is probably another measure (Z, OAS, ASW) or another bond.
negative_g_spreadmediumThe bond yields less than the government curve at its maturity: fine for a supranational or agency, otherwise check the curve and the currency.
maturity_outside_curvemediumThe bond's maturity is outside the government curve, which is held flat, so the G-spread leans on the nearest point.
curve_kinkmediumA government curve point sits more than 40 bp off the line through its neighbours.
comps_dispersion_widemediumThe comps scatter more than 25 bp (RMS) around their line: the comps gap is a weak signal.
signals_conflictmediumOne signal calls the bond cheap and another rich.
no_relative_signalmediumNo credit curve, comps or history: nothing to call the bond rich or cheap against, and the assessment is n/a.
credit_curve_extrapolatedlowThe credit curve does not reach the bond's maturity and is held flat.
swap_curve_extrapolatedlowThe swap curve does not reach the bond's maturity and is held flat.
few_compslowFewer than three comps: the fit is an average or a line through two points.
comps_maturity_gaplowThe bond's maturity is outside the comps' maturity range; the fit is extrapolated.
short_historylowFewer than 20 history observations: the z-score and percentile are a sketch.
history_undatedlowThe spread history has no dates; it is read oldest first.
sparse_govt_curvelowFewer than 4 government curve points.
long_durationlowModified duration above 12: convexity matters, so read the full-revaluation column, not the estimate.
deep_discount_or_premiumlowA clean price below 80 or above 120: spreads of par-priced comps are not like-for-like.
input_truncatedlowA page limit cut the paste: more than 40 points on a curve (the shortest kept), more than 25 comps (the first kept) or more than 800 history observations (the latest kept). The detail names how many of how many were used.
short_maturitylowUnder a year to maturity: small price moves swing the spread.

The signals

spread.js computes up to three rich/cheap signals, in order of preference, and the first available is primary_signal, whose call is the assessment:

idneedsvaluecheap from / rich from
residuala credit curveG-spread minus the credit curve spread at the maturity, in bp+10 bp / -10 bp
compsat least one compG-spread minus the comps line at the maturity, in bp+10 bp / -10 bp
historytwo or more history observations with a non-zero sdthe z-score of today's G-spread against the history+1.00 / -1.00

Positive always means cheap (the bond pays more than its reference). Each signal's flips give the spread move, in bp of the bond's own spread with the curves held, that takes it to the edge of the next call: one flip (to fair) for a cheap or rich call, two (to cheap by widening and to rich by tightening) for a fair one. A spread move shifts every signal at once, which is what flip_read weighs.

The rules

The engine's thresholds are Spread.RULES, listed below. facts.rules sends the same thresholds as strings that carry their unit (for example "residual_cheap_from": "+10 bp", "z_rich_from": "-1.00", "curve_kink_above": "40 bp"), plus the scenario shifts, so the model can quote a threshold exactly as the page checks it:

keyvaluemeaning
residual_bp10Residual to the credit curve at or beyond ± this: cheap / rich.
comps_bp10Spread to the comps line at or beyond ± this: cheap / rich.
z_band1.0History z-score at or beyond ± this: cheap / rich.
kink_bp40A government curve point off the line through its neighbours by more than this: curve_kink.
comps_rms_bp25Comps RMS scatter above this: comps_dispersion_wide.
history_gap_bp30Last history spread further than this from today's G-spread: history_mismatch.
spread_max_bp3000|G-spread| above this: spread_scale_suspect.
min_history20Fewer observations than this: short_history.
min_govt4Fewer government curve points than this: sparse_govt_curve.
long_duration12Modified duration above this: long_duration.
deep_price_lo, deep_price_hi80, 120Clean price outside this range: deep_discount_or_premium.

8. Use it in a daily relative value monitor

The recommendation is built to gate on, once the reply has passed the checks above. Run the review once a day after the close: rebuild bond.json from end-of-day prices, the government curve, the peer curve, the comps and the spread history, send it, reconcile the reply, and append one line per day to a log. A no_view means the sheet cannot be trusted yet (any high-severity flag forces it) or has nothing to call the bond against; neutral means the sheet supports no tilt strongly enough; an overweight or underweight is worth a human look, with the risks and checks kept next to the sheet. A change of assessment, recommendation or conviction from the day before is worth a look as well.

#!/bin/sh
# Daily, after the close: rebuild the sheet from today's bond.json, run the review,
# append one line to spread-monitor.log, exit 3 when the recommendation is a tilt.
set -e
node make-body.js bond.json "What changed in the spread today, and does the relative value call still hold?" > body.json
INPUT=$(cat body.json)
KEY="spread-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
LINE=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];r=json.loads(t[t.index("{"):t.rindex("}")+1]);print(r.get("assessment",""),r.get("conviction",""),r.get("recommendation") or "no_view")')
echo "$(date +%F) $LINE" >> spread-monitor.log
echo "today: $LINE"
case "$LINE" in *overweight|*underweight) exit 3 ;; *) exit 0 ;; esac

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 assessment, recommendation, conviction, headline and spread read may be complete while the risks, 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, spread read, relative read, scenario read, flip read, 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.