← Carry Desk / API
Tokens

Drive Carry Desk from your own code

Everything the web page does is available over HTTP. Send the carry sheet the browser computes for one currency pair (spot, forward points or outright forwards by tenor and, where you have them, ATM implied vols, 25-delta risk reversals and butterflies, both deposit rates and a daily spot history), and get the same review back: the assessment and the skew signal copied from the sheet, a stance (one of the carry trades the sheet priced, or no trade) with its conviction, a read of the carry term structure, of the vol surface and of the history, how the position is built and why that tenor, what it earns and where it breaks even, the alternatives weighed, the risks, one response per flag and the checks to make before acting. The natural use is a daily carry monitor: a script rebuilds the sheet from end-of-day forward points and vols, 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 outright forwards, the annualised carry and which currency earns it, carry-to-vol, the breakeven spot and pips, the P&L for the notional, the cushion in standard deviations, the loss probability, the covered interest parity check, the skew read, the sweet spot, the assessment, the history context and the flags are all computed by carry.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 carry.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":"carry-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":"carry-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://carry-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":"carry-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://carry-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 carry sheet like a senior FX strategist: the assessment (attractive, moderate, unattractive, n/a) and skew signal (supportive, neutral, against, n/a) copied from the sheet, a stance (a trade id from facts.trades or no_trade) and conviction, headline, carry read, vol read, history read, the trade (construction, tenor choice, sizing, exit), alternatives, risks, flag responses, checks and summary.
fieldtypemeaning
taskstring, required"review"
factsstring, requiredA JSON string you build: the JSON-encoded output of Carry.buildFacts (the browser builds it from the carry sheet). It holds the pair, every tenor's figures, the target, the assessment and skew calls, the sweet spot, every carry trade priced, the history context, 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, Carry.mustBeObject, throws on anything else before it calls the API.

Building the facts

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

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

Or build it inline. This is the page's USD/MXN example: an emerging-market carry with an inverted vol curve, 25-delta risk reversals and butterflies, a bid and ask, no deposit rates and no history (illustrative rates, not market data):

const Carry = require("./carry.js");
const form = {
  pair: "USDMXN",
  spot: "18.4650",
  spot_bid: "18.4620",
  spot_ask: "18.4680",
  fwd_mode: "points",                // forward points, not outrights
  pip_size: "auto",                  // 0.0001 (0.01 when the quote currency is JPY)
  target_tenor: "6M",
  notional: "5000000",               // in the base currency, USD
  tenors: [
    "tenor, points, vol, rr, bf",
    "1W, 172.91, 13.60, 1.95, 0.45",
    "1M, 732.79, 13.10, 2.10, 0.48",
    "2M, 1459.53, 12.70, 2.20, 0.50",
    "3M, 2132.52, 12.40, 2.30, 0.52",
    "6M, 4110.39, 12.05, 2.45, 0.55",
    "1Y, 7994.66, 11.80, 2.60, 0.58",
  ].join("\n"),
  history: "",
};
const res = Carry.compute(form);
console.log(Carry.verdictLine(res.model));
// USD/MXN 6M: long MXN earns 4.46% a year against 12.05% implied vol, carry-to-vol 0.37 - moderate
// · sweet spot 1M (0.37) · skew against · signals trusted.
const body = Carry.buildInput(res, "Long MXN has been the carry trade of the year. How much cushion " +
  "does it have at 6M, and what does the vol surface say about the risk?");
// body = {task: "review", facts: "<JSON string, 7,112 characters>", question: "..."}

If you do not run JavaScript, copy the sheet instead of re-deriving it: the page's Save pair .json file plus carry.js in any Node runtime reproduces the facts byte for byte, and the Sheet .md and Tenors 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
pairyesSix letters, base then quote, such as USDMXN, USD/MXN or AUD/USD; anything that is not a letter is dropped. The same currency twice, or anything that is not six letters, is an error.
spotyesThe spot mid, quote-currency units per one unit of the base currency. Thousands commas, a leading + and a trailing % are stripped. Missing but with a valid bid and ask: their mid is used, with a warning. Not a positive number: an error.
spot_bid, spot_asknoBoth or neither. They must straddle the mid with the bid below the ask, otherwise both are ignored with a warning. When given, the facts carry the spread in pips, as a share of spot and as a share of the carry at 1M (or the first tenor of 28 days or more).
fwd_modeyes"points" (the default: forward points in pips of the pip size, so the forward is spot + points × pip) or "outright" (outright forward rates). A header row with an outright or points column overrides it.
pip_sizeyes"auto" (0.01 when the quote currency is JPY, otherwise 0.0001) or a number above 0 and at most 1. Anything else falls back to auto with a warning.
target_tenoryes1M, 3M, 6M or 1Y; anything else becomes 3M with a warning. The assessment, the skew signal and the history drawdown are read at this tenor. If the forward curve does not contain it, the tenor nearest in days is used and a target_missing flag is raised.
notionalyesAn amount of the base 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 10,000,000,000.
tenorsyesThe forward curve, one tenor per line; see below. Up to 16 tenors.
historynoDaily spot closes, one per line; see below. Empty means facts.history is "not supplied".

The forward curve. Each line is a tenor and its forward, then optionally the ATM implied vol in percent, the 25-delta risk reversal and butterfly in vol points, and the base and quote deposit rates in percent: 6M, 4110.39, 12.05, 2.45, 0.55. Cells may be separated by commas, semicolons, tabs, pipes or spaces; anything after # is a comment; a dash, n/a, na or none leaves a cell empty. Tenors are one or two digits and a unit (1W, 2wk, 3M, 6mo, 1Y, 2yr); 12M is read as 1Y and 24M as 2Y, but 18M stays. Without a header the columns are, in order, tenor, forward, vol, RR, BF, base rate, quote rate. A header row (its first cell is not a tenor and it holds no number) maps the columns in any order: tenor/term/maturity/expiry; points/pts/fwd_points/swap_points/pips or outright (which switches the mode), or plain fwd/forward; vol/atm/iv/implied_vol; rr/rr25/25d_rr/risk_reversal; bf/bf25/fly/butterfly; a cell equal to the base or quote currency code (for example USD and JPY) or base_rate/quote_rate for the deposit rates; and days/dtm/days_to_maturity; with no forward column, mid is the forward, or bid and ask are averaged to the mid (said in the warnings). When every line carries a comma, semicolon, tab or pipe, only those split cells, so a header such as Fwd Points or ATM Vol is one column; in a space-separated paste multi-word header names are re-joined and 1 M reads as 1M. SW is read as 1W; ON, TN and SN lines are skipped with one warning. At most 16 tenors are kept (the shortest); the rest are named in the warnings and the sheet is marked partial. Days default to the standard count (1W 7, 2W 14, 3W 21, 1M 30, 2M 61, 3M 91, 4M 122, 5M 152, 6M 182, 9M 273, 1Y 365, 18M 547, 2Y 730, 3Y 1095, 5Y 1826); a days cell overrides it when it is a whole number from 1 to 3700. A line with no readable tenor or forward is skipped; an unreadable vol, RR, BF, rate or days cell is ignored, as are a vol at or below zero and a butterfly below zero. A tenor given twice keeps the later line. Tenors are sorted by days and a forward at or below zero is skipped. The CIP fields appear only for tenors with both deposit rates. Every skipped or repaired line comes back in res.warnings.

The spot history. One close per line, optionally with a date: 2025-09-26 (/ and . also accepted), 20250926, 26-Sep-2025, or numeric 09/26/2025 / 26/09/2025 (day/month when any first field is above 12, otherwise month/day, with a warning when it cannot tell). Lines without a digit are headers: a Close, PX_LAST, Last Price, Last, Mid or Price column is then the close (so a Volume column is never read), and a Date column the date. A line without a positive close is skipped. When every line is dated the closes are sorted by date and a repeated date keeps the first; otherwise they are taken oldest first as pasted, unless the first close is within 3% of spot and the last is not, in which case the paste is read as newest first and reversed (said in the warnings). At most the last 800 closes are kept (the sheet is then marked partial), and fewer than two means no history. The range, range position, range return and range realised vol use the last 252 closes; the 3M return and realised vol need 64 closes.

Errors (in res.errors, and no facts are built): no pair or not a pair, no spot or a non-positive spot, an empty forward curve, no readable tenor, or no tenor with a usable forward.

What is in facts

Numbers in facts are pre-formatted strings with their units ("18.87604", "+4110.39", "4.46%", "0.37", "39.8%", "MXN 2,055,195", "USD 111,302"), 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:

keycontents
unitsThe conventions in words: spot, forwards and breakevens in quote per one base; points in pips of the pip size; carry, vols and rates in percent a year; how P&L, cushion, loss probability and the risk reversal are defined.
pair{label, base, quote, spot, pip_size, forward_quote, notional, day_basis} plus bid, ask, spread_pips, spread_pct_of_spot, spread_share_of_carry, spread_ref_tenor, or bid_ask: "not supplied".
tenors[]One object per tenor: tenor, days, forward_points, forward, forward_premium_ann, long_to_earn, carry_ann, carry_period, breakeven_spot, breakeven_pips, pnl_quote, pnl_base, atm_vol, carry_to_vol, cushion_sd, loss_probability, rr_25d, bf_25d, skew, then either base_rate, quote_rate, rate_differential, cip_forward, forward_vs_cip_pips, fx_implied_base_yield, cip_basis or deposit_rates: "not supplied". Missing vol figures are "n/a" or "not supplied".
target{tenor, requested, long_to_earn}: the tenor actually used and the one asked for.
assessment, assessment_measureattractive, moderate, unattractive or n/a from carry-to-vol at the target, with the figure and thresholds in words.
skew_signal, skew_measuresupportive, neutral, against or n/a from the target's 25d risk reversal, with the figure.
sweet_spot{tenor, trade, basis, value}: the tenor of 28 days or more with the best carry-to-vol (or best annualised carry when no vol is given), or "none".
trades[]Every carry trade priced, one per tenor whose forward differs from spot, always on the side that earns carry: {id, label, tenor, days, long, short, forward, carry_ann, carry_period, carry_to_vol, cushion_sd, loss_probability, breakeven_spot, breakeven_pips, pnl_quote, pnl_base, skew}. See the trade ids.
signal_trust"trusted", or "untrusted" when any high-severity flag was raised; the stance must then be no_trade.
flags[]{code, severity, detail, tenors}; see the flag codes.
rulesThe RULES thresholds as numbers; see the rules.
history{closes, first_date, last_date, last_close, last_close_vs_spot, range_closes, range_low, range_high, range_position, return_range, return_3m, realised_vol_range, realised_vol_3m, ma50, ma200, max_drawdown_for_carry}, or "not supplied". Undated closes give "undated" dates.
note_clipped_charsOnly when the question was longer than 2,000 characters: how many were cut.

How the figures are computed

For a tenor of d days with forward F, spot S, pip size p and notional N (base currency):

The worked example

The full request body the page builds for the USD/MXN example (this is a real run input; the facts string is 7,112 characters):

{
 "task": "review",
 "facts": "{\"units\":\"Spot, forwards and breakevens in MXN per 1 USD; forward points in pips of 0.0001; carry, vols and rates in percent a year; carry_period in percent of spot over the tenor; P&L for the stated notional if spot is unchanged at maturity; cushion_sd in standard deviations of the log return at the implied vol; loss_probability under a driftless normal log return at the implied vol; rr_25d and bf_25d in vol points, rr_25d = USD calls minus USD puts.\",\"pair\":{\"label\":\"USD/MXN\",\"base\":\"USD\",\"quote\":\"MXN\",\"spot\":\"18.46500\",\"pip_size\":\"0.0001\",\"forward_quote\":\"forward points\",\"notional\":\"USD 5,000,000\",\"day_basis\":{\"USD\":\"ACT/360\",\"MXN\":\"ACT/360\"},\"bid\":\"18.46200\",\"ask\":\"18.46800\",\"spread_pips\":\"60.0\",\"spread_pct_of_spot\":\"0.0325%\",\"spread_share_of_carry\":\"8.2%\",\"spread_ref_tenor\":\"1M\"},\"tenors\":[{\"tenor\":\"1W\",\"days\":7,\"forward_points\":\"+172.91\",\"forward\":\"18.48229\",\"forward_premium_ann\":\"+4.88%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.88%\",\"carry_period\":\"0.094%\",\"breakeven_spot\":\"18.48229\",\"breakeven_pips\":\"172.9\",\"pnl_quote\":\"MXN 86,455\",\"pnl_base\":\"USD 4,682\",\"atm_vol\":\"13.60%\",\"carry_to_vol\":\"0.36\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.05\",\"loss_probability\":\"48.0%\",\"rr_25d\":\"+1.95\",\"bf_25d\":\"0.45\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"},{\"tenor\":\"1M\",\"days\":30,\"forward_points\":\"+732.79\",\"forward\":\"18.53828\",\"forward_premium_ann\":\"+4.83%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.83%\",\"carry_period\":\"0.397%\",\"breakeven_spot\":\"18.53828\",\"breakeven_pips\":\"732.8\",\"pnl_quote\":\"MXN 366,395\",\"pnl_base\":\"USD 19,843\",\"atm_vol\":\"13.10%\",\"carry_to_vol\":\"0.37\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.11\",\"loss_probability\":\"45.8%\",\"rr_25d\":\"+2.10\",\"bf_25d\":\"0.48\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"},{\"tenor\":\"2M\",\"days\":61,\"forward_points\":\"+1459.53\",\"forward\":\"18.61095\",\"forward_premium_ann\":\"+4.73%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.73%\",\"carry_period\":\"0.790%\",\"breakeven_spot\":\"18.61095\",\"breakeven_pips\":\"1459.5\",\"pnl_quote\":\"MXN 729,765\",\"pnl_base\":\"USD 39,522\",\"atm_vol\":\"12.70%\",\"carry_to_vol\":\"0.37\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.15\",\"loss_probability\":\"44.0%\",\"rr_25d\":\"+2.20\",\"bf_25d\":\"0.50\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"},{\"tenor\":\"3M\",\"days\":91,\"forward_points\":\"+2132.52\",\"forward\":\"18.67825\",\"forward_premium_ann\":\"+4.63%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.63%\",\"carry_period\":\"1.155%\",\"breakeven_spot\":\"18.67825\",\"breakeven_pips\":\"2132.5\",\"pnl_quote\":\"MXN 1,066,260\",\"pnl_base\":\"USD 57,745\",\"atm_vol\":\"12.40%\",\"carry_to_vol\":\"0.37\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.19\",\"loss_probability\":\"42.6%\",\"rr_25d\":\"+2.30\",\"bf_25d\":\"0.52\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"},{\"tenor\":\"6M\",\"days\":182,\"forward_points\":\"+4110.39\",\"forward\":\"18.87604\",\"forward_premium_ann\":\"+4.46%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.46%\",\"carry_period\":\"2.226%\",\"breakeven_spot\":\"18.87604\",\"breakeven_pips\":\"4110.4\",\"pnl_quote\":\"MXN 2,055,195\",\"pnl_base\":\"USD 111,302\",\"atm_vol\":\"12.05%\",\"carry_to_vol\":\"0.37\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.26\",\"loss_probability\":\"39.8%\",\"rr_25d\":\"+2.45\",\"bf_25d\":\"0.55\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"},{\"tenor\":\"1Y\",\"days\":365,\"forward_points\":\"+7994.66\",\"forward\":\"19.26447\",\"forward_premium_ann\":\"+4.33%\",\"long_to_earn\":\"MXN\",\"carry_ann\":\"4.33%\",\"carry_period\":\"4.330%\",\"breakeven_spot\":\"19.26447\",\"breakeven_pips\":\"7994.7\",\"pnl_quote\":\"MXN 3,997,330\",\"pnl_base\":\"USD 216,481\",\"atm_vol\":\"11.80%\",\"carry_to_vol\":\"0.37\",\"carry_to_vol_band\":\"moderate\",\"cushion_sd\":\"0.36\",\"loss_probability\":\"36.0%\",\"rr_25d\":\"+2.60\",\"bf_25d\":\"0.58\",\"skew\":\"against\",\"deposit_rates\":\"not supplied\"}],\"target\":{\"tenor\":\"6M\",\"requested\":\"6M\",\"long_to_earn\":\"MXN\"},\"assessment\":\"moderate\",\"assessment_measure\":\"6M carry-to-vol 0.37 (attractive at 0.40 or above, moderate from 0.20)\",\"skew_signal\":\"against\",\"skew_measure\":\"6M 25d risk reversal +2.45 for long MXN\",\"sweet_spot\":{\"tenor\":\"1M\",\"trade\":\"long_mxn_1m\",\"basis\":\"carry-to-vol\",\"value\":\"0.37\",\"tied_with\":[\"2M\",\"3M\",\"6M\",\"1Y\"]},\"trades\":[{\"id\":\"long_mxn_1w\",\"label\":\"Long MXN / short USD, 1W forward\",\"tenor\":\"1W\",\"days\":7,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"18.48229\",\"carry_ann\":\"4.88%\",\"carry_period\":\"0.094%\",\"carry_to_vol\":\"0.36\",\"cushion_sd\":\"0.05\",\"loss_probability\":\"48.0%\",\"breakeven_spot\":\"18.48229\",\"breakeven_pips\":\"172.9\",\"pnl_quote\":\"MXN 86,455\",\"pnl_base\":\"USD 4,682\",\"skew\":\"against\"},{\"id\":\"long_mxn_1m\",\"label\":\"Long MXN / short USD, 1M forward\",\"tenor\":\"1M\",\"days\":30,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"18.53828\",\"carry_ann\":\"4.83%\",\"carry_period\":\"0.397%\",\"carry_to_vol\":\"0.37\",\"cushion_sd\":\"0.11\",\"loss_probability\":\"45.8%\",\"breakeven_spot\":\"18.53828\",\"breakeven_pips\":\"732.8\",\"pnl_quote\":\"MXN 366,395\",\"pnl_base\":\"USD 19,843\",\"skew\":\"against\"},{\"id\":\"long_mxn_2m\",\"label\":\"Long MXN / short USD, 2M forward\",\"tenor\":\"2M\",\"days\":61,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"18.61095\",\"carry_ann\":\"4.73%\",\"carry_period\":\"0.790%\",\"carry_to_vol\":\"0.37\",\"cushion_sd\":\"0.15\",\"loss_probability\":\"44.0%\",\"breakeven_spot\":\"18.61095\",\"breakeven_pips\":\"1459.5\",\"pnl_quote\":\"MXN 729,765\",\"pnl_base\":\"USD 39,522\",\"skew\":\"against\"},{\"id\":\"long_mxn_3m\",\"label\":\"Long MXN / short USD, 3M forward\",\"tenor\":\"3M\",\"days\":91,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"18.67825\",\"carry_ann\":\"4.63%\",\"carry_period\":\"1.155%\",\"carry_to_vol\":\"0.37\",\"cushion_sd\":\"0.19\",\"loss_probability\":\"42.6%\",\"breakeven_spot\":\"18.67825\",\"breakeven_pips\":\"2132.5\",\"pnl_quote\":\"MXN 1,066,260\",\"pnl_base\":\"USD 57,745\",\"skew\":\"against\"},{\"id\":\"long_mxn_6m\",\"label\":\"Long MXN / short USD, 6M forward\",\"tenor\":\"6M\",\"days\":182,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"18.87604\",\"carry_ann\":\"4.46%\",\"carry_period\":\"2.226%\",\"carry_to_vol\":\"0.37\",\"cushion_sd\":\"0.26\",\"loss_probability\":\"39.8%\",\"breakeven_spot\":\"18.87604\",\"breakeven_pips\":\"4110.4\",\"pnl_quote\":\"MXN 2,055,195\",\"pnl_base\":\"USD 111,302\",\"skew\":\"against\"},{\"id\":\"long_mxn_1y\",\"label\":\"Long MXN / short USD, 1Y forward\",\"tenor\":\"1Y\",\"days\":365,\"long\":\"MXN\",\"short\":\"USD\",\"forward\":\"19.26447\",\"carry_ann\":\"4.33%\",\"carry_period\":\"4.330%\",\"carry_to_vol\":\"0.37\",\"cushion_sd\":\"0.36\",\"loss_probability\":\"36.0%\",\"breakeven_spot\":\"19.26447\",\"breakeven_pips\":\"7994.7\",\"pnl_quote\":\"MXN 3,997,330\",\"pnl_base\":\"USD 216,481\",\"skew\":\"against\"}],\"signal_trust\":\"trusted\",\"flags\":[{\"code\":\"vol_curve_inverted\",\"severity\":\"medium\",\"detail\":\"1W ATM vol 13.60% is above 1Y 11.80%: short-dated vol bid over long-dated is how the options market prices near-term stress, the regime in which carry trades unwind.\",\"tenors\":[\"1W\",\"1Y\"]},{\"code\":\"skew_against_carry\",\"severity\":\"medium\",\"detail\":\"6M 25d risk reversal +2.45 prices options on a USD rally against MXN above the opposite move: the options market pays for the move that unwinds this carry.\",\"tenors\":[\"6M\"]},{\"code\":\"fat_tails\",\"severity\":\"low\",\"detail\":\"6M 25d butterfly 0.55 vol points: the wings are priced above ATM, so large moves either way are expected more often than a normal distribution allows.\",\"tenors\":[\"6M\"]}],\"rules\":{\"attractive_carry_to_vol\":\"0.40\",\"moderate_carry_to_vol\":\"0.20\",\"skew_neutral_below\":\"0.25 vol\",\"skew_strong_from\":\"1.00 vol\",\"fat_tails_bf_from\":\"0.50 vol\",\"points_scale_above\":\"40%\",\"kink_above\":\"150 bp\",\"cip_basis_above\":\"50 bp\",\"vol_inversion_above\":\"1.00 vol\",\"realised_over_implied_above\":\"1.00 vol\",\"range_edge_within\":\"10%\",\"history_mismatch_above\":\"3%\",\"spread_share_above\":\"25%\",\"min_history_closes\":64,\"min_tenors\":3,\"sweet_spot_min_days\":28,\"trend_window_closes\":63,\"range_window_closes\":252},\"history\":\"not supplied\"}",
 "question": "Long MXN has been the carry trade of the year. How much cushion does it have at 6M, and what does the vol surface say about the risk?"
}

The same facts decoded, with the long arrays shortened:

{
  "units": "Spot, forwards and breakevens in MXN per 1 USD; forward points in pips of 0.0001; ...",
  "pair": {"label": "USD/MXN", "base": "USD", "quote": "MXN", "spot": "18.46500", "pip_size": "0.0001",
           "forward_quote": "forward points", "notional": "USD 5,000,000", "day_basis": {"USD": "ACT/360", "MXN": "ACT/360"},
           "bid": "18.46200", "ask": "18.46800", "spread_pips": "60.0", "spread_pct_of_spot": "0.0325%",
           "spread_share_of_carry": "8.2%", "spread_ref_tenor": "1M"},
  "tenors": [
    {"tenor": "1W", "days": 7, "forward_points": "+172.91", "forward": "18.48229", ..., "atm_vol": "13.60%", ...},
    ...,
    {"tenor": "6M", "days": 182, "forward_points": "+4110.39", "forward": "18.87604", "forward_premium_ann": "+4.46%",
     "long_to_earn": "MXN", "carry_ann": "4.46%", "carry_period": "2.226%", "breakeven_spot": "18.87604",
     "breakeven_pips": "4110.4", "pnl_quote": "MXN 2,055,195", "pnl_base": "USD 111,302", "atm_vol": "12.05%",
     "carry_to_vol": "0.37", "cushion_sd": "0.26", "loss_probability": "39.8%", "rr_25d": "+2.45", "bf_25d": "0.55",
     "skew": "against", "deposit_rates": "not supplied"},
    {"tenor": "1Y", ..., "atm_vol": "11.80%", "carry_to_vol": "0.37", "cushion_sd": "0.36", "loss_probability": "36.0%", ...}
  ],
  "target": {"tenor": "6M", "requested": "6M", "long_to_earn": "MXN"},
  "assessment": "moderate",
  "assessment_measure": "6M carry-to-vol 0.37 (attractive at 0.40 or above, moderate from 0.20)",
  "skew_signal": "against",
  "skew_measure": "6M 25d risk reversal +2.45 for long MXN",
  "sweet_spot": {"tenor": "1M", "trade": "long_mxn_1m", "basis": "carry-to-vol", "value": "0.37"},
  "trades": [
    {"id": "long_mxn_1w", "label": "Long MXN / short USD, 1W forward", ...},
    ...,
    {"id": "long_mxn_6m", "label": "Long MXN / short USD, 6M forward", "tenor": "6M", "days": 182, "long": "MXN", "short": "USD",
     "forward": "18.87604", "carry_ann": "4.46%", "carry_period": "2.226%", "carry_to_vol": "0.37", "cushion_sd": "0.26",
     "loss_probability": "39.8%", "breakeven_spot": "18.87604", "breakeven_pips": "4110.4",
     "pnl_quote": "MXN 2,055,195", "pnl_base": "USD 111,302", "skew": "against"},
    {"id": "long_mxn_1y", ...}
  ],
  "signal_trust": "trusted",
  "flags": [
    {"code": "vol_curve_inverted", "severity": "medium", "detail": "1W ATM vol 13.60% is above 1Y 11.80%: ...", "tenors": ["1W", "1Y"]},
    {"code": "skew_against_carry", "severity": "medium", "detail": "6M 25d risk reversal +2.45 prices options on a USD rally against MXN ...", "tenors": ["6M"]},
    {"code": "fat_tails", "severity": "low", "detail": "6M 25d butterfly 0.55 vol points: ...", "tenors": ["6M"]}
  ],
  "rules": {"attractive_carry_to_vol": "0.40", "moderate_carry_to_vol": "0.20", "skew_neutral_below": "0.25 vol", ..., "kink_above": "150 bp", ..., "range_window_closes": 252},
  "history": "not supplied"
}

/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, carry-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/MXN example it is carry-desk:review:63b3288a-20ab: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="carry-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\":\"moderate\", ...}"},
#   "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\":\"moderate\","}
# 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, skew_signal and conviction are lower-cased with spaces and hyphens turned into underscores (n/a is kept as written), and an unknown value becomes empty (and is then reported as missing); stance is normalized the same way and an empty one becomes no_trade (an unknown one is kept and reported by the reconciler); an unknown risk severity becomes medium; risk tenors are written as the facts write them ("3m", "3 months" and "12M" become 3M, 3M and 1Y) and flag codes are 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, carry_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["assessment"], r["skew_signal"], r["stance"], r["conviction"], "-", 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://carry-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": "attractive" | "moderate" | "unattractive" | "n/a",
  "skew_signal": "supportive" | "neutral" | "against" | "n/a",
  "stance": "a trade id from facts.trades, or no_trade",
  "conviction": "high" | "medium" | "low" | "none",
  "headline": "one sentence: the pair, the carry and carry-to-vol at the target tenor with the assessment, and the stance",
  "carry_read": "3 to 5 sentences: which currency earns carry and how much, how carry and carry-to-vol change along the curve, where the sweet spot is, and what the CIP check says (or that deposit rates were not supplied)",
  "vol_read": "2 to 4 sentences on the implied vol level and term structure, the risk reversal and butterfly, and the cushion and loss probability at the target tenor, or one sentence saying no vol was supplied",
  "history_read": "2 to 3 sentences on the range, the trend against or with the carry, realised against implied vol and the worst drawdown, or one sentence saying no history was supplied",
  "trade": {
    "construction": "the position: which currency is bought and sold forward and through which tenor, or why there is no trade",
    "tenor_choice": "why this tenor rather than the target or the sweet spot, quoting their carry-to-vol",
    "sizing": "the notional, what it earns over the tenor in both currencies, the breakeven spot and pips",
    "exit": "what would make you take it off: a spot level from the facts, a vol or skew change, or a flag turning"
  },
  "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.tenors; 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 trade 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"
}
fieldallowed values
lanereview
assessmentattractive, moderate, unattractive, n/a — must equal facts.assessment
skew_signalsupportive, neutral, against, n/a — must equal facts.skew_signal
stanceone id from facts.trades, or no_trade (required when signal_trust is untrusted)
convictionhigh, medium, low, none — none exactly for no_trade; high only when attractive with no medium or high flag
risks[].severityhigh, medium, low
alternatives[].ida trade id from facts.trades other than the stance
flag_responses[].codeeach code in facts.flags, once, in the same order

alternatives has 1 to 3 entries (empty only when facts.trades is empty or holds only the stance); 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, checks item and trade field is at most 60 words, and empty arrays are [], never omitted. When question is not empty, carry_read or summary answers it directly. The stance weighs risk-adjusted carry first (carry-to-vol, cushion and loss probability by tenor, the sweet spot and the target compared), then the vol surface (skew against the carry and an inverted vol curve), then the history (a trend against the carry, realised above implied, a deep drawdown); neither the sweet spot nor the target tenor is automatically the answer. For no_trade, trade.tenor_choice, trade.sizing and trade.exit describe the leading candidate (or say none applies) and what would change the view. Money is written as the facts write it ("USD 111,302"), tenors only as in facts.tenors, and only the pair's two currencies are named. 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/MXN example (the wording of a real run will differ; every figure is copied from the facts above):

{
  "lane": "review",
  "assessment": "moderate",
  "skew_signal": "against",
  "stance": "long_mxn_6m",
  "conviction": "low",
  "headline": "USD/MXN 6M long MXN earns 4.46% a year at carry-to-vol 0.37, a moderate assessment, and the 6M forward is the stance with low conviction.",
  "carry_read": "MXN earns the carry at every tenor, from 4.88% at 1W to 4.33% at 1Y ... At 6M the cushion is only 0.26 standard deviations, with a 39.8% loss probability ...",
  "vol_read": "Implied vol falls from 13.60% at 1W to 11.80% at 1Y, an inverted vol curve ... The 6M risk reversal of +2.45 prices a USD rally ...",
  "history_read": "No spot history was supplied, so the trend and realised vol cannot be read.",
  "trade": {
    "construction": "Buy MXN and sell USD through the 6M forward at 18.87604.",
    "tenor_choice": "6M carries the same 0.37 carry-to-vol as the 1M sweet spot with a wider cushion and no monthly roll ...",
    "sizing": "On USD 5,000,000 it earns MXN 2,055,195, or USD 111,302, if spot is unchanged; breakeven is 18.87604, 4110.4 pips from spot.",
    "exit": "..."
  },
  "alternatives": [
    {"id": "long_mxn_1m", "why": "Same 0.37 carry-to-vol but a cushion of only 0.11 and a 45.8% loss probability, rolled every month."},
    {"id": "long_mxn_1y", "why": "A 0.36 cushion and 36.0% loss probability, but it holds the position through a longer stretch of the skew against it."}
  ],
  "risks": [{"risk": "A risk-off unwind sends USD higher against MXN faster than the carry accrues.", "severity": "high", "tenors": ["6M"], "watch": "Spot against the 18.87604 breakeven."}, ...],
  "flag_responses": [
    {"code": "vol_curve_inverted", "response": "..."}, {"code": "skew_against_carry", "response": "..."},
    {"code": "fat_tails", "response": "..."}
  ],
  "checks": ["Confirm the forward points are on the 0.0001 pip size and for the same value date convention.", ...],
  "summary": "..."
}

The flag codes

Raised by carry.js (in compute, 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 stance to no_trade. Every threshold is compared against the figure as it is shown (rounded). tenors lists the tenors a flag concerns, and may be empty.

codeseveritymeaning
points_scale_suspecthighAnnualised carry above 40% at some tenor: forward points on the wrong pip size, or an outright entered as points, is far more likely than a real differential that wide.
carry_kinkhighA tenor of 28 days or more has an annualised forward premium more than 150 bp off the straight line through its two neighbours: more often a bad forward quote than a real term structure.
carry_sign_flipmediumThe currency that earns carry changes along the curve; tenors names those on the other side from the target.
cip_basis_widemediumThe FX-implied base yield differs from the base deposit rate by more than 50 bp: the rates may be for other tenors or day bases, or a real cross-currency basis is at work.
vol_curve_invertedmediumThe shortest tenor's ATM vol is more than 1 vol point above the longest's: the options market pricing near-term stress.
skew_against_carrymedium when the target's |RR| is 1.0 or more, otherwise lowThe target's skew is against: options pay more for the move that unwinds the carry.
fat_tailslowThe target's 25d butterfly is 0.5 vol points or more.
no_vol_at_targetmediumNo ATM vol at the target: carry-to-vol, cushion and loss probability are not available there and the assessment is n/a.
target_missingmediumThe requested target tenor is not in the forward curve; the nearest tenor by days is used.
spot_history_mismatchhighThe last close is more than 3% from spot: the history is for another pair, inverted, or stale.
realised_above_impliedmedium3M realised vol is more than 1 vol point above the target's implied vol, so carry-to-vol on implied flatters the trade.
trend_against_carrymediumSpot moved against the target's carry side over the last 63 closes by more than a quarter of the annualised carry.
spot_at_range_edgelowSpot sits within 10% of either end of its range (up to 252 closes).
short_historylowFewer than 64 closes: no 3M realised vol or 3M trend.
wide_bid_asklowThe round-trip bid-ask is more than 25% of the carry over the reference tenor (1M, or the first of 28 days or more).
sparse_curvelowFewer than 3 tenors: the term structure and the sweet spot rest on very few points.

The trade ids

carry.js prices one carry trade per tenor whose forward differs from spot, always on the side that earns carry there. The id is long_<currency>_<tenor> in lower case — long_mxn_6m, long_usd_3m, long_jpy_1y — and the label says the same in words ("Long MXN / short USD, 6M forward"). A tenor whose forward equals spot has no trade. The stance and every alternative must be one of the ids actually present in facts.trades. With a carry_sign_flip flag the trades are not all long the same currency.

The rules

The engine's thresholds are Carry.RULES, listed below. facts.rules sends the same thresholds as strings that carry their unit (for example "attractive_carry_to_vol": "0.40", "kink_above": "150 bp", "cip_basis_above": "50 bp", "skew_neutral_below": "0.25 vol"), plus the window lengths (sweet_spot_min_days 28, trend_window_closes 63, range_window_closes 252), so the model can quote a threshold exactly as the page checks it:

keyvaluemeaning
attractive_cv0.4Carry-to-vol at the target at or above this: attractive.
moderate_cv0.2From this up to attractive_cv: moderate; below: unattractive.
skew_neutral_vol0.25|25d RR| below this, in vol points: neutral skew.
skew_strong_vol1|25d RR| at or above this makes skew_against_carry medium.
fat_tail_bf_vol0.525d butterfly at or above this: fat_tails.
points_scale_pct40Annualised carry above this at any tenor: points_scale_suspect.
kink_bp150Forward premium off the neighbours' line by more than this: carry_kink.
cip_basis_bp50CIP basis wider than this: cip_basis_wide.
vol_inversion_vol1Shortest ATM vol above the longest by more than this: vol_curve_inverted.
rv_over_iv_vol13M realised above target implied by more than this: realised_above_implied.
range_edge_pct10Spot within this share of either end of the range: spot_at_range_edge.
history_mismatch_pct3Last close further than this from spot: spot_history_mismatch.
spread_share_pct25Bid-ask above this share of the reference carry: wide_bid_ask.
min_history64Closes needed for the 3M realised vol and trend: short_history below it.
min_tenors3Fewer tenors than this: sparse_curve.

8. Use it in a daily carry 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 pair.json from end-of-day spot, forward points, vols and closes, send it, reconcile the reply, and append one line per day to a log. A no_trade means the sheet supports no carry trade strongly enough or cannot be trusted yet (any high-severity flag forces it); a trade id is worth a human look, with the risks and checks kept next to the sheet. A change of assessment, skew_signal or tenor from the day before is worth a look as well.

#!/bin/sh
# Daily, after the close: rebuild the sheet from today's pair.json, run the review,
# append one line to carry-monitor.log, exit 3 when the stance is a trade.
set -e
node make-body.js pair.json "What changed in the carry today, and is a carry trade still worth holding?" > body.json
INPUT=$(cat body.json)
KEY="carry-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("skew_signal",""),r.get("conviction",""),r.get("stance") or "no_trade")')
echo "$(date +%F) $LINE" >> carry-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 assessment, skew signal, stance, conviction, headline and carry 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 ten (headline, carry read, vol read, history 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.