← Curve Desk / API
Tokens

Drive Curve Desk from your own code

Everything the web page does is available over HTTP. Send the curve sheet the browser computes for one currency's par swap curve (optionally with government yields and inflation breakevens at the same tenors), and get the same review back: the shape and the belly call copied from the sheet, a stance (one of the curve trades the sheet sized, or no trade), a read of the levels and forwards, a read of swap spreads and real yields, how the trade is built and sized, what it earns or pays over the carry horizon, the alternatives weighed, the risks, one response per flag and the checks to make before acting. The natural use is a daily curve monitor: a script rebuilds the sheet from end-of-day swap marks, 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 bootstrap of discount factors, zero rates, 1y forwards, forward swap rates, DV01 per tenor, swap spreads, real yields, slopes, flies, the shape and belly calls, the DV01-neutral trade sizing, carry and roll-down and the flags are all computed by curve.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":"curve-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":"curve-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://curve-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":"curve-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://curve-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 curve sheet like a senior rates strategist: shape (steep, normal, flat, inverted) and belly (cheap, fair, rich, n/a) copied from the sheet, a stance (a trade id from facts.trades or no_trade), headline, curve read, spreads read, the trade (construction, sizing, carry, exit), alternatives, risks, flag responses, checks and summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredThe JSON-encoded output of Curve.buildFacts: the curve settings, every pillar's figures, the 1y forward curve, forward swaps, slopes, flies, the shape and belly calls, every sized trade with its legs and carry, the best carry trade, the signal trust, flags and 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, Curve.mustBeObject, throws on anything else before it calls the API.

Building the facts

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

// make-body.js - node make-body.js curve.json "your question" > body.json
const fs = require("fs");
const Curve = require("./curve.js");          // https://curve-desk.skillsafe.ai/curve.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = Curve.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));   // unreadable lines, skipped tenors
const body = Curve.mustBeObject(Curve.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));

Or build it inline. This is the page's USD, normal example: a SOFR curve with an inverted front, government yields and a few breakevens (illustrative rates, not market data):

const Curve = require("./curve.js");
const form = {
  curve_label: "USD SOFR OIS",
  fixed_freq: "1",                   // annual fixed leg
  horizon_months: "3",               // carry and roll over 3 months
  dv01_target: "10000",              // $10,000 per bp on each leg
  curve: [
    "tenor swap govt breakeven",
    "1Y 3.550 3.620 -",
    "2Y 3.420 3.500 2.45",
    "3Y 3.400 3.480 -",
    "5Y 3.480 3.600 2.35",
    "7Y 3.600 3.790 -",
    "10Y 3.780 4.150 2.30",
    "12Y 3.860 - -",
    "15Y 3.950 - -",
    "20Y 4.020 4.700 -",
    "30Y 3.960 4.720 2.25",
  ].join("\n"),
};
const res = Curve.compute(form);
console.log(Curve.verdictLine(res.model));
// Normal curve: 2s10s +36.0 bp · belly rich (2s5s10s -7.5 bp vs the line) ·
// best 3m carry and roll: flattener 2s10s +5.77 bp (+$57,747).
const body = Curve.buildInput(res, "Is a 2s10s steepener worth putting on here, " +
  "or does the carry argue for something else?");
// body = {task: "review", facts: "<JSON string, about 8.4 KB>", question: "..."}

The form fields

field idrequiredaccepted input
curve_labelnoA name for the sheet, for example USD SOFR OIS. Up to 80 characters; empty becomes "unnamed curve" in the facts.
fixed_freqyesThe fixed-leg frequency and bootstrap grid: "1" annual (SOFR, €STR, SONIA OIS), "2" semiannual or "4" quarterly. Any other value is read as annual.
horizon_monthsyesThe carry and roll-down horizon: 1, 3, 6 or 12. Anything else is replaced by 3 with a warning.
dv01_targetyesRisk per leg in dollars per bp, for example 10000 (a leading $ and thousands commas are accepted). Not a positive number: $10,000 is used with a warning. Capped at $10,000,000.
curveyesOne pillar per line, see below. At least three usable tenors, up to 30.

The curve. Each line is a tenor and a par swap rate in percent, then optionally the government yield and the inflation breakeven at the same tenor: 10Y 3.780 4.150 2.30. Cells may be separated by spaces, tabs, commas, semicolons or pipes. Tenors are a number with an optional unit (10, 10Y, 10yr, 18M, 6 months; a bare number is years), up to 50 years. Rates may carry a % or a leading +; a dash, n/a, na or none leaves a column empty, and a lone % or bp after a number is ignored. A line with no decimal point whose cells are split by tabs, semicolons or spaces reads a decimal comma (3,42) as a point. A swap rate larger than 60 in absolute value is taken as a unit mistake and the line is skipped; if every swap rate is under 0.25 a warning says they may be decimals. An unreadable government yield or breakeven is left empty. A header row with no digits in it (for example tenor, swap, gilt, breakeven) maps the columns in any order; it needs a tenor column (tenor, term, maturity, years, pillar) and a swap column (swap, par, rate, ois, sofr, estr, sonia, tona, saron, irs), and reads a government column (govt, government, tsy, treasury, ust, bond, gilt, bund, jgb, yield, sovereign) and a breakeven column (breakeven, be, bei, inflation, linker). Anything after # is a comment. A tenor that appears twice keeps the later line. Pillars are sorted by tenor; a tenor shorter than one fixed period, or not a whole number of fixed periods (an 18M pillar on an annual leg), is skipped. At most 30 usable tenors are kept; the longest beyond that are dropped and named in a PARTIAL warning, and res.inputs.dropped lists them. A space-separated header such as Tenor Swap rate Govt yield reads as three columns. Every skipped or repaired line comes back in res.warnings. Fewer than three usable tenors, an empty curve, or a curve that implies a non-positive discount factor is an error in res.errors and no facts are built.

What is in facts

Numbers in facts are pre-formatted strings with their units ("3.780%", "+36.0 bp", "-5.77 bp", "$827", "$52.6m", "+$57,747"), 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: rates in percent with three decimals; slopes, flies, spreads and carry in bp; DV01 and carry in dollars per bp of the stated risk; notionals in millions of dollars.
curve{label, fixed_leg, carry_horizon, risk_per_leg}, for example {"fixed_leg": "annual", "carry_horizon": "3m", "risk_per_leg": "$10,000 per bp"}.
pillars[]One object per pasted tenor: tenor, swap, zero, dv01_per_1m (DV01 of $1m notional), government and swap_spread (swap minus government, or "not supplied"), and, only where a breakeven was given, breakeven and real_yield (government minus breakeven).
forward_1y[]The 1y forward curve, one per year to the last tenor: {period: "0y1y", rate}, {period: "1y1y", rate}, …
forward_swaps[]{id, rate} for whichever of 1y1y, 2y1y, 1y2y, 2y3y, 5y5y, 1y10y, 10y10y, 10y20y, 20y10y end inside the curve.
slopes[]{id, value} for 2s5s, 2s10s, 5s30s, 10s30s where both tenors lie inside the pasted range (a missing tenor is interpolated and flagged).
flies[]{id, fifty_fifty, belly_vs_line, wing_weights} for 2s5s10s and 5s10s30s: the 50/50 fly, the belly against the maturity-weighted line through the wings (above zero means the belly is high, cheap to receive) and the wing weights.
shape, shape_measure"inverted" when 2s10s is below -10 bp, "flat" up to 30 bp, "normal" up to 120 bp, "steep" above. A curve that does not span 2Y to 10Y uses its first-to-last slope; the measure says which ("2s10s +36.0 bp").
belly, belly_measureFrom the 2s5s10s fly (or 5s10s30s when 2s5s10s is not available): "cheap" when the belly is more than 5 bp above the line, "rich" more than 5 bp below, otherwise "fair"; "n/a" and "no fly available" when there is no fly.
trades[]Every trade the page sized: {id, label, legs, carry_roll_bp, carry_roll_usd}. Each leg is {side, tenor, rate, dv01, notional, carry, roll, carry_roll, contribution}; side is receive or pay. A leg's carry, roll and carry_roll are for its own side per unit of its own DV01 (a paid leg's are the receiver's with the sign flipped); contribution is carry_roll times the leg's DV01 weight, and the contributions add up to the trade's carry_roll_bp to rounding. carry_roll_bp is per unit of leg DV01 over the horizon, curve unchanged; carry_roll_usd is for the stated risk per leg. See the trade ids.
best_carry_tradeThe id with the highest carry_roll_bp, or "none". Not automatically the stance.
signal_trust"trusted", or "untrusted" when a kinked_curve flag was raised; the stance must then be no_trade.
flags[]{code, severity, detail, tenors}; see the flag codes.
rulesThe thresholds behind the calls, as text: {shape, belly, carry_roll, trust}.
note_clipped_charsOnly when the question was longer than 2,000 characters: how many were cut.

An excerpt of the real facts for the USD, normal example (10 pillars; long arrays shortened):

{
  "units": "Rates in percent with three decimals; slopes, flies, spreads and carry in basis points; ...",
  "curve": {"label": "USD SOFR OIS", "fixed_leg": "annual", "carry_horizon": "3m", "risk_per_leg": "$10,000 per bp"},
  "pillars": [
    {"tenor": "1Y", "swap": "3.550%", "zero": "3.550%", "dv01_per_1m": "$97", "government": "3.620%", "swap_spread": "-7.0 bp"},
    {"tenor": "2Y", "swap": "3.420%", "zero": "3.418%", "dv01_per_1m": "$190", "government": "3.500%", "swap_spread": "-8.0 bp",
     "breakeven": "2.45%", "real_yield": "1.050%"},
    ...,
    {"tenor": "10Y", "swap": "3.780%", "zero": "3.818%", "dv01_per_1m": "$827", "government": "4.150%", "swap_spread": "-37.0 bp",
     "breakeven": "2.30%", "real_yield": "1.850%"},
    {"tenor": "12Y", "swap": "3.860%", "zero": "3.910%", "dv01_per_1m": "$956", "government": "not supplied", "swap_spread": "not supplied"},
    ... ],
  "forward_1y": [{"period": "0y1y", "rate": "3.550%"}, {"period": "1y1y", "rate": "3.286%"}, {"period": "2y1y", "rate": "3.358%"}, ...],
  "forward_swaps": [{"id": "1y1y", "rate": "3.286%"}, ..., {"id": "5y5y", "rate": "4.142%"}, {"id": "10y10y", "rate": "4.383%"}, ...],
  "slopes": [{"id": "2s5s", "value": "+6.0 bp"}, {"id": "2s10s", "value": "+36.0 bp"}, {"id": "5s30s", "value": "+48.0 bp"}, {"id": "10s30s", "value": "+18.0 bp"}],
  "flies": [
    {"id": "2s5s10s", "fifty_fifty": "-24.0 bp", "belly_vs_line": "-7.5 bp", "wing_weights": "0.625 / 0.375"},
    {"id": "5s10s30s", "fifty_fifty": "+12.0 bp", "belly_vs_line": "+20.4 bp", "wing_weights": "0.800 / 0.200"}
  ],
  "shape": "normal",
  "shape_measure": "2s10s +36.0 bp",
  "belly": "rich",
  "belly_measure": "2s5s10s belly vs line -7.5 bp",
  "trades": [
    {"id": "steepener_2s10s", "label": "Steepener 2s10s",
     "legs": [
       {"side": "receive", "tenor": "2Y", "rate": "3.420%", "dv01": "$10,000", "notional": "$52.6m",
        "carry": "-2.57 bp", "roll": "-1.15 bp", "carry_roll": "-3.72 bp", "contribution": "-3.72 bp"},
       {"side": "pay", "tenor": "10Y", "rate": "3.780%", "dv01": "$10,000", "notional": "$12.1m",
        "carry": "-0.55 bp", "roll": "-1.50 bp", "carry_roll": "-2.05 bp", "contribution": "-2.05 bp"}],
     "carry_roll_bp": "-5.77 bp", "carry_roll_usd": "-$57,747"},
    {"id": "flattener_2s10s", ..., "carry_roll_bp": "+5.77 bp", "carry_roll_usd": "+$57,747"},
    {"id": "steepener_5s30s", ..., "carry_roll_bp": "0.00 bp", "carry_roll_usd": "+$40"},
    {"id": "flattener_5s30s", ..., "carry_roll_bp": "0.00 bp", "carry_roll_usd": "-$40"},
    {"id": "receive_belly_2s5s10s", "label": "Receive the belly 2s5s10s",
     "legs": [
       {"side": "pay", "tenor": "2Y", "rate": "3.420%", "dv01": "$6,250", "notional": "$32.9m", ...},
       {"side": "receive", "tenor": "5Y", "rate": "3.480%", "dv01": "$10,000", "notional": "$22.1m", ...},
       {"side": "pay", "tenor": "10Y", "rate": "3.780%", "dv01": "$3,750", "notional": "$4.5m", ...}],
     "carry_roll_bp": "+1.99 bp", "carry_roll_usd": "+$19,918"},
    {"id": "pay_belly_2s5s10s", ..., "carry_roll_bp": "-1.99 bp", "carry_roll_usd": "-$19,918"},
    {"id": "receive_belly_5s10s30s", ..., "carry_roll_bp": "+1.62 bp", "carry_roll_usd": "+$16,182"},
    {"id": "pay_belly_5s10s30s", ..., "carry_roll_bp": "-1.62 bp", "carry_roll_usd": "-$16,182"}
  ],
  "best_carry_trade": "flattener_2s10s",
  "signal_trust": "trusted",
  "flags": [
    {"code": "inverted_segment", "severity": "low", "detail": "The curve falls from 1Y to 2Y (-13.0 bp), from 20Y to 30Y (-6.0 bp).", "tenors": ["2Y", "30Y"]},
    {"code": "humped_curve", "severity": "low", "detail": "The curve peaks at 20Y (4.020%) and is lower at both ends.", "tenors": ["20Y"]},
    {"code": "negative_swap_spread", "severity": "low", "detail": "Swaps yield less than the government bond at 1Y (-7.0 bp), 2Y (-8.0 bp), ...", "tenors": ["1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "20Y", "30Y"]}
  ],
  "rules": {"shape": "2s10s below -10 bp inverted, up to 30 bp flat, up to 120 bp normal, above that steep", "belly": "...", "carry_roll": "...", "trust": "a kinked_curve flag makes the curve signals untrusted"}
}

The request body wraps that object as a string (abbreviated here):

{
  "task": "review",
  "facts": "{\"units\":\"Rates in percent with three decimals; slopes, flies, spreads and carry in basis points; ...\",\"curve\":{\"label\":\"USD SOFR OIS\",\"fixed_leg\":\"annual\",\"carry_horizon\":\"3m\",...",
  "question": "Is a 2s10s steepener worth putting on here, or does the carry argue for something else?"
}

/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, curve-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 USD, normal example it is curve-desk:review:1f586b9c-2777: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="curve-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\",\"shape\":\"normal\", ...}"},
#   "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\",\"shape\":\"normal\","}
# 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; shape, belly and stance are lower-cased with spaces and hyphens turned into underscores; an unknown shape or belly becomes empty (and is then reported as missing); an empty stance becomes no_trade (an unknown one is kept and reported by the reconciler); an unknown risk severity becomes medium; risk tenors are written as 10Y ("10", "10yr" and "10 years" all become 10Y) and flag codes lower-cased; missing arrays become empty; alternatives without both an id and a why, risks with neither text nor watch, and flag responses without a code or response are dropped. A reply with none of headline, curve_read and summary, or with neither a stance 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["shape"], r["belly"], r["stance"], "-", r["headline"])
print("sizing:", r["trade"]["sizing"])
for x in r["risks"]:
    print(x["severity"], x["risk"], x.get("tenors"))
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://curve-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 curve 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",
  "shape": "steep" | "normal" | "flat" | "inverted",
  "belly": "cheap" | "fair" | "rich" | "n/a",
  "stance": "a trade id from facts.trades, or no_trade",
  "headline": "one sentence: the shape with its measure, the belly call, and the stance",
  "curve_read": "3 to 5 sentences: levels and shape, what the forwards imply about where the curve is priced to go, where it is steepest or inverted",
  "spreads_read": "2 to 3 sentences on swap spreads and real yields, or one sentence saying they were not supplied",
  "trade": {
    "construction": "the position: which legs are received and paid, or why there is no trade",
    "sizing": "the DV01 per leg and the notionals from the trade's legs in facts",
    "carry": "what it earns or pays over the horizon, quoting carry_roll_bp and carry_roll_usd, and which leg drives it",
    "exit": "what move makes it pay and what would make you take it off"
  },
  "alternatives": [{"id": "another trade id from facts.trades", "why": "why it was not preferred"}],
  "risks": [
    {"risk": "what could go wrong", "severity": "high" | "medium" | "low", "tenors": ["tenors it concerns, as in facts, 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 curve and what to do about it"}],
  "checks": ["something to verify before acting on the sheet"],
  "summary": "two sentences: the shape and the stance, and why"
}

alternatives has 1 to 3 entries (empty only when facts.trades is empty); risks has 3 to 5, most important first; checks has 3 to 5; flag_responses has exactly one entry per code in facts.flags, in the same order. Each why, response, risk, watch and trade field is at most 60 words, and empty arrays are [], never omitted. When question is not empty, curve_read or summary answers it directly. The stance weighs the shape and belly call first, then what the forwards already price, then carry and roll; best_carry_trade is not automatically the answer, and a trade that bleeds carry must say so. For no_trade, trade.sizing and trade.carry describe the leading candidate (or say none applies) and trade.exit says what would change the view. 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 USD, normal example (the wording of a real run will differ; every figure is copied from the facts above):

{
  "lane": "review",
  "shape": "normal",
  "belly": "rich",
  "stance": "flattener_2s10s",
  "headline": "The curve is normal at 2s10s +36.0 bp with the 2s5s10s belly rich at -7.5 bp against the line, and the 2s10s flattener is preferred over the steepener.",
  "curve_read": "... The 2s10s steepener asked about bleeds -5.77 bp over 3m, -$57,747, because the front is inverted ...",
  "spreads_read": "Swap spreads are negative at every supplied tenor, from -7.0 bp at 1Y to -76.0 bp at 30Y. ...",
  "trade": {
    "construction": "Pay fixed in 2Y and receive fixed in 10Y, DV01-neutral, so the position gains if 2s10s falls.",
    "sizing": "$10,000 per bp on each leg: $52.6m notional in 2Y and $12.1m in 10Y.",
    "carry": "It earns +5.77 bp over 3m, +$57,747 with the curve unchanged; most of it comes from paying the 2Y, whose receiver carry and roll is -3.72 bp.",
    "exit": "..."
  },
  "alternatives": [
    {"id": "steepener_2s10s", "why": "It bleeds -5.77 bp over the horizon and needs the front to reprice lower quickly to pay."},
    {"id": "pay_belly_2s5s10s", "why": "The rich belly favours paying it, but it costs -1.99 bp of carry and roll."}
  ],
  "risks": [{"risk": "A bull steepening led by the front end moves against the flattener.", "severity": "high", "tenors": ["2Y"], "watch": "The 1y1y forward at 3.286%."}, ...],
  "flag_responses": [
    {"code": "inverted_segment", "response": "..."}, {"code": "humped_curve", "response": "..."},
    {"code": "negative_swap_spread", "response": "..."}
  ],
  "checks": ["Confirm the rates are mid par swap rates for the same index and fixed-leg convention.", ...],
  "summary": "..."
}

The flag codes

Raised by curve.js (in compute, in this order) and sent in facts.flags; the reply must answer each one. tenors lists the tenors or forward periods a flag concerns, and may be empty.

codeseveritymeaning
kinked_curvehighAn inner pillar sits more than 20 bp off the straight line through its two neighbours (skipped when the neighbours are more than 20 years apart). More often a bad quote than a trade: it sets signal_trust to untrusted, and the stance must be no_trade.
inverted_segmentmedium when the shape is inverted, otherwise lowA pillar is more than 5 bp below the one before it; tenors names the lower pillar of each drop.
humped_curvelowThe highest par rate is at an inner pillar, more than 5 bp above both the first and the last pillar.
forward_jumpmediumConsecutive 1y forwards differ by more than 75 bp: a sawtooth forward curve points at a bad pillar or at linear interpolation across a wide gap.
negative_forwardmediumEvery par rate is positive but at least one 1y forward is negative.
negative_swap_spreadlowThe swap rate is below the government yield at one or more tenors.
wide_swap_spreadmediumA swap spread is wider than 100 bp either way; check the government yield is for the same tenor and in percent.
negative_real_yieldlowThe government yield minus the breakeven is below zero at one or more tenors.
extrapolated_short_endlowThe first pillar is longer than one fixed period; par rates before it are held flat at its rate for the bootstrap.
interpolated_tenorlowA tenor used by a slope, fly or trade was not pasted and is interpolated linearly.
sparse_curvemediumFewer than 5 pillars, or two pillars more than 10 years apart: forwards inside a gap are interpolation, not market.
tenor_not_coveredlowThe curve does not span 2s10s or 2s5s10s. The shape then uses the first-to-last slope, and only trades inside the pasted range are sized.

The trade ids

curve.js sizes a trade only when its slope or fly lies inside the pasted range, so facts.trades holds at most these eight ids, and the stance and every alternative must be one of the ids actually present. Every leg's DV01 is the risk per leg times its weight; slope trades weight both legs 1, flies weight the belly 1 and the wings by maturity (the fly's wing_weights), so a fly is neutral to a parallel move and to a straight-line slope change.

idlegsmakes money when
steepener_2s10sreceive 2Y, pay 10Y2s10s rises
flattener_2s10spay 2Y, receive 10Y2s10s falls
steepener_5s30sreceive 5Y, pay 30Y5s30s rises
flattener_5s30spay 5Y, receive 30Y5s30s falls
receive_belly_2s5s10spay 2Y (weight 0.625 on the example), receive 5Y, pay 10Y (0.375)the 5Y falls against the line through 2Y and 10Y
pay_belly_2s5s10sreceive 2Y, pay 5Y, receive 10Ythe 5Y rises against that line
receive_belly_5s10s30spay 5Y (0.800), receive 10Y, pay 30Y (0.200)the 10Y falls against the line through 5Y and 30Y
pay_belly_5s10s30sreceive 5Y, pay 10Y, receive 30Ythe 10Y rises against that line

A trade's carry_roll_bp is the sum over its legs of the receiver carry and roll, with the sign of the side and the leg weight: receiver carry is the forward swap rate at the horizon minus spot, and roll is spot minus the rolled-down par rate. A positive figure is also how far the spread can move against the position over the horizon before it loses.

8. Use it in a daily curve monitor

The stance is built to gate on, once the reply has passed the checks above. Run the review once a day after the close: rebuild curve.json from end-of-day swap marks, send it, reconcile the reply, and append one line per day to a log. A no_trade means the curve supports nothing strongly enough or cannot be trusted yet (a kinked_curve flag forces it); a trade id is worth a human look, with the risks and checks kept next to the sheet. A change of shape or belly from the day before is worth a look as well.

#!/bin/sh
# Daily, after the close: rebuild the sheet from today's curve.json, run the review,
# append one line to curve-monitor.log, exit 3 when the stance is a trade.
set -e
node make-body.js curve.json "What changed in the curve today, and does it support a curve trade?" > body.json
INPUT=$(cat body.json)
KEY="curve-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("shape",""),r.get("belly",""),r.get("stance") or "no_trade")')
echo "$(date +%F) $LINE" >> curve-monitor.log
echo "today: $LINE"
case "$LINE" in *no_trade) exit 0 ;; *) exit 3 ;; 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 shape, belly, stance, headline and curve read may be complete while the alternatives, 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, curve read, spreads read, trade, alternatives, 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.