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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run on an app whose publisher does not sponsor guest runs. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
To mint a guest token yourself, POST /guest with
{"slug":"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":"…"}}
# Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "carry-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r: # 201 Created
guest = json.load(r)["data"]
TOKEN = guest["token"]
print(guest["guest_id"], guest["expires_at"])
// Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "carry-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, guest.expires_at);
// Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"carry-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close() // guestRes.StatusCode == 201
var guest struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token, guest.Data.ExpiresAt)
// Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"carry-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.statusCode()); // 201
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered review.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "carry-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) } # 201
guest = JSON.parse(res.body)["data"]
TOKEN = guest["token"]
puts guest["guest_id"], guest["expires_at"]
<?php
// Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "carry-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true); // HTTP 201
curl_close($ch);
echo $guest["data"]["token"], " ", $guest["data"]["expires_at"];
// Open https://carry-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"carry-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq); // 201 Created
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Console.WriteLine($"{guest.GetProperty("token").GetString()} {guest.GetProperty("expires_at")}");
2. A tiny client
One helper that adds the headers, unwraps data and raises on error. No other header is needed.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN" # from https://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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://carry-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://carry-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://carry-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class CarryDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
static String sha256Hex(String s) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://carry-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class CarryDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
public static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me answers {subject_type, subject_id, credits}. Branch on
subject_type: it is guest or user, and a guest
can price a run but, unless the publisher sponsors guest runs, cannot start one. credits
is the wallet balance. Compare it against min_credits from the next step before you run,
so a shortfall surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
me = call("me")
if me["subject_type"] != "user":
print("guest token: /estimate works, a review needs a signed-in token")
print(me["subject_type"], me["subject_id"], me.get("credits"))
const me = await call("me");
if (me.subject_type !== "user") console.warn("guest token: /estimate works, a review needs a signed-in token");
console.log(me.subject_type, me.subject_id, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
if me.SubjectType != "user" {
fmt.Println("guest token: /estimate works, a review needs a signed-in token")
}
fmt.Println(me.SubjectType, me.Credits)
String me = CarryDesk.call("me", null);
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
if (!me.contains("\"subject_type\":\"user\"")) System.out.println("guest token: a review needs a signed-in token");
me = call("me")
warn "guest token: a review needs a signed-in token" unless me["subject_type"] == "user"
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']}"
<?php
$me = call("me");
if ($me["subject_type"] !== "user") fwrite(STDERR, "guest token: a review needs a signed-in token\n");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await CarryDesk.Call("me");
var subject = me.GetProperty("subject_type").GetString();
if (subject != "user") Console.Error.WriteLine("guest token: a review needs a signed-in token");
Console.WriteLine($"{subject} {me.GetProperty("credits")}");
4. Price the review (free)
The input object is exactly what the app's form submits. The first field is
task. This app has one lane, so it is always review. A missing or
unknown task is still answered as review, and the reply's lane
says so.
| task | what it does |
|---|---|
review | Reads the 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. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | A 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. |
question | string, optional | What you want to know, up to 2,000 characters. May be empty. Longer text is cut on a word boundary with [...] and facts.note_clipped_chars says how much was cut. |
retry_note | string, 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 id | required | accepted input |
|---|---|---|
pair | yes | Six 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. |
spot | yes | The 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_ask | no | Both 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_mode | yes | "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_size | yes | "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_tenor | yes | 1M, 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. |
notional | yes | An 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. |
tenors | yes | The forward curve, one tenor per line; see below. Up to 16 tenors. |
history | no | Daily 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:
| key | contents |
|---|---|
units | The 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_measure | attractive, moderate, unattractive or n/a from carry-to-vol at the target, with the figure and thresholds in words. |
skew_signal, skew_measure | supportive, 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. |
rules | The 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_chars | Only 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):
- Forward. F = S + points × p (or the outright).
forward_premium_ann= (F/S − 1) × 365/d, signed. - Carry.
carry_ann= |F − S| / S × 365/d (ACT/365);carry_period= |F − S| / S.long_to_earnis the base currency when F < S and the quote currency when F > S. - Breakeven and P&L.
breakeven_spotis the forward itself;breakeven_pips= |F − S| / p;pnl_quote= N × |F − S| andpnl_base=pnl_quote/ S, spot unchanged at maturity. - Vol.
carry_to_vol= carry_ann / atm_vol;cushion_sd= |ln(F/S)| / (vol × √(d/365));loss_probability= Φ(−cushion), a driftless normal log return. - Skew. The risk reversal is base calls minus base puts. Its favour is the RR when long the base currency and minus the RR when long the quote; |RR| below 0.25 is
neutral, a positive favoursupportive, otherwiseagainst; no RR isn/a. - CIP (both deposit rates given). Simple rates on each currency's day basis (ACT/365 for GBP, AUD, NZD, CAD, HKD, SGD, ZAR, INR, MYR, THB, PLN, ILS, KRW, TWD, PHP and IDR; ACT/360 otherwise):
cip_forward= S × (1 + rqd/Bq) / (1 + rbd/Bb);fx_implied_base_yield= ((1 + rqd/Bq) × S/F − 1) × Bb/d;cip_basisis that minus the base rate, in bp;rate_differentialis base minus quote, in bp. - Assessment. Carry-to-vol at the target, rounded to two decimals: 0.40 or above
attractive, from 0.20moderate, below thatunattractive;n/awith no vol or no carry. - Formatting. Prices to the pip size plus one decimal (five decimals at 0.0001, three at 0.01); points with a sign and two decimals; carry, premium and vols to two decimals with
%;carry_periodto three; carry-to-vol and cushion to two, bare; loss probability to one with%; breakeven pips to one; money whole, with the currency code and thousands commas; RR signed, BF unsigned, both to two decimals.
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.
INPUT = json.load(open("body.json")) # task, facts, question
assert isinstance(INPUT, dict) and isinstance(INPUT.get("facts"), str) # /estimate will not check this for you
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserve", est["hold_credits"], "minimum", est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation, not the price of the run.
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
if (typeof INPUT !== "object" || Array.isArray(INPUT) || typeof INPUT.facts !== "string") throw new Error("send an object with facts as a string");
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
if err := json.Unmarshal(raw, &input); err != nil { // an object, not a string or an array
panic(err)
}
if _, ok := input["facts"].(string); !ok {
panic("facts must be a JSON string")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
if (!input.trim().startsWith("{")) throw new IllegalArgumentException("the body must be a JSON object");
System.out.println(CarryDesk.call("estimate", input));
// {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
// "hold_credits":…,"min_credits":…,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
raise "facts must be a string" unless INPUT.is_a?(Hash) && INPUT["facts"].is_a?(String)
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
if (!is_array($input) || !is_string($input["facts"] ?? null)) throw new RuntimeException("facts must be a string");
$est = call("estimate", $input);
echo $est["model"], " hold ", $est["hold_credits"], " min ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
if (input.ValueKind != JsonValueKind.Object || input.GetProperty("facts").ValueKind != JsonValueKind.String)
throw new Exception("send an object with facts as a string");
var est = await CarryDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
5. Run it, then poll
POST /run needs a signed-in (user) token and returns a
job_id; poll GET jobs/{job_id} until status is
succeeded or failed. The reply is the string at
data.output.output. The terminal job also carries charged_credits (the real
price) and the truncated flag.
Always send an Idempotency-Key on /run and
/run-stream. The web app derives it from the input with the lane and an attempt counter,
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"carry-desk:review:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"]
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `carry-desk:review:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output;
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("carry-desk:review:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println("charged", job.Charged, "truncated", job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "carry-desk:review:" + CarryDesk.sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(CarryDesk.BASE + "/run"))
.header("Authorization", "Bearer " + CarryDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = CarryDesk.HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = CarryDesk.call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) break;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// data.output.output is a string holding the reply JSON; read it with your JSON library
// (Jackson below), along with data.charged_credits and data.truncated.
var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(job).get("data");
String jobOutput = data.get("output").get("output").asText();
System.out.println("charged " + data.get("charged_credits") + " truncated " + data.get("truncated"));
require "digest"
key = "carry-desk:review:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
<?php
$key = "carry-desk:review:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") throw new RuntimeException(json_encode($job));
echo "charged ", $job["charged_credits"], " truncated ", var_export($job["truncated"], true), PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "carry-desk:review:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {CarryDesk.Token}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await CarryDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
Console.WriteLine($"charged {job.GetProperty("charged_credits")} truncated {job.GetProperty("truncated")}");
6. Or stream it
POST /run-stream is the same call over server-sent events, with the same token rules and
the same Idempotency-Key header. From a server or script, each delta event
carries {"text": "..."}, a chunk of the reply, and the final done event
carries status, charged_credits and truncated (and, when
present, the whole reply at output.output; the web app prefers it and falls back to the
concatenated deltas). In a browser, /run-stream sends progress ticks, not text
deltas, so do not build a live typing view on it there; the done event and the finished job
from step 5 always have the whole reply.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"review\",\"assessment\":\"moderate\","}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = (done && done.output && done.output.output) || raw;
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(CarryDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + CarryDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
CarryDesk.HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {CarryDesk.Token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
data.output.output is a string holding one JSON object. The web app
(recon.js, also a Node module) strips any code fence, takes everything from
the first { to the last }, parses it and normalizes it: lane is
forced to review; 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
import re
ASSESS = ("attractive", "moderate", "unattractive", "n/a")
SKEW = ("supportive", "neutral", "against", "n/a")
CONVICTION = ("high", "medium", "low", "none")
def norm(v):
return re.sub(r"[\s-]+", "_", str(v or "").strip().lower())
def one_of(v, allowed):
s = str(v or "").strip().lower()
s = s if s == "n/a" else re.sub(r"[\s-]+", "_", s)
return s if s in allowed else "" # reported as missing
def parse_review(text):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
r["lane"] = "review"
r["assessment"] = one_of(r.get("assessment"), ASSESS)
r["skew_signal"] = one_of(r.get("skew_signal"), SKEW)
r["conviction"] = one_of(r.get("conviction"), CONVICTION)
r["stance"] = norm(r.get("stance")) or "no_trade" # the page's fallback
for k in ("alternatives", "risks", "flag_responses", "checks"):
r[k] = r.get(k) or []
r["trade"] = r.get("trade") or {}
return r
r = parse_review(text)
print(r["assessment"], r["skew_signal"], r["stance"], r["conviction"], [x["severity"] for x in r["risks"]])
// Or reuse the page's own parser: const Recon = require("./recon.js");
// const r = Recon.normalize(Recon.parseResult(text));
const norm = (v) => String(v || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
const oneOf = (v, allowed) => {
let s = String(v || "").trim().toLowerCase();
if (s !== "n/a") s = s.replace(/[\s-]+/g, "_");
return allowed.includes(s) ? s : "";
};
function parseReview(text) {
const t = String(text).trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
r.lane = "review";
r.assessment = oneOf(r.assessment, ["attractive", "moderate", "unattractive", "n/a"]);
r.skew_signal = oneOf(r.skew_signal, ["supportive", "neutral", "against", "n/a"]);
r.conviction = oneOf(r.conviction, ["high", "medium", "low", "none"]);
r.stance = norm(r.stance) || "no_trade";
for (const k of ["alternatives", "risks", "flag_responses", "checks"]) r[k] = r[k] || [];
r.trade = r.trade || {};
return r;
}
const r = parseReview(text);
console.log(r.assessment, r.skew_signal, r.stance, r.conviction, r.risks.map((x) => x.severity));
type Review struct {
Lane string `json:"lane"`
Assessment string `json:"assessment"`
SkewSignal string `json:"skew_signal"`
Stance string `json:"stance"`
Conviction string `json:"conviction"`
Headline string `json:"headline"`
CarryRead string `json:"carry_read"`
VolRead string `json:"vol_read"`
HistoryRead string `json:"history_read"`
Trade struct {
Construction string `json:"construction"`
TenorChoice string `json:"tenor_choice"`
Sizing string `json:"sizing"`
Exit string `json:"exit"`
} `json:"trade"`
Alternatives []struct {
ID string `json:"id"`
Why string `json:"why"`
} `json:"alternatives"`
Risks []struct {
Risk string `json:"risk"`
Severity string `json:"severity"`
Tenors []string `json:"tenors"`
Watch string `json:"watch"`
} `json:"risks"`
FlagResponses []struct {
Code string `json:"code"`
Response string `json:"response"`
} `json:"flag_responses"`
Checks []string `json:"checks"`
Summary string `json:"summary"`
}
text := jobOutput // data.output.output from step 5
var r Review
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
r.Stance = strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(r.Stance)), "-", "_"), " ", "_")
if r.Stance == "" {
r.Stance = "no_trade"
}
r.Assessment = strings.ToLower(strings.TrimSpace(r.Assessment))
r.SkewSignal = strings.ToLower(strings.TrimSpace(r.SkewSignal))
r.Conviction = strings.ToLower(strings.TrimSpace(r.Conviction))
fmt.Println(r.Assessment, r.SkewSignal, r.Stance, r.Conviction, len(r.Risks))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
String stance = r.path("stance").asText("").trim().toLowerCase().replaceAll("[\\s-]+", "_");
if (stance.isEmpty()) stance = "no_trade";
String assessment = r.path("assessment").asText("").trim().toLowerCase();
if (!java.util.List.of("attractive", "moderate", "unattractive", "n/a").contains(assessment)) assessment = "";
String skew = r.path("skew_signal").asText("").trim().toLowerCase();
if (!java.util.List.of("supportive", "neutral", "against", "n/a").contains(skew)) skew = "";
String conviction = r.path("conviction").asText("").trim().toLowerCase();
System.out.println(assessment + " " + skew + " " + stance + " " + conviction);
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
r["assessment"] = r["assessment"].to_s.strip.downcase
r["assessment"] = "" unless %w[attractive moderate unattractive n/a].include?(r["assessment"])
r["skew_signal"] = r["skew_signal"].to_s.strip.downcase
r["skew_signal"] = "" unless %w[supportive neutral against n/a].include?(r["skew_signal"])
r["conviction"] = r["conviction"].to_s.strip.downcase
r["stance"] = r["stance"].to_s.strip.downcase.gsub(/[\s-]+/, "_")
r["stance"] = "no_trade" if r["stance"].empty?
%w[alternatives risks flag_responses checks].each { |k| r[k] ||= [] }
puts r["assessment"], r["skew_signal"], r["stance"], r["conviction"]
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
$r["assessment"] = strtolower(trim($r["assessment"] ?? ""));
if (!in_array($r["assessment"], ["attractive", "moderate", "unattractive", "n/a"], true)) $r["assessment"] = "";
$r["skew_signal"] = strtolower(trim($r["skew_signal"] ?? ""));
if (!in_array($r["skew_signal"], ["supportive", "neutral", "against", "n/a"], true)) $r["skew_signal"] = "";
$r["conviction"] = strtolower(trim($r["conviction"] ?? ""));
$r["stance"] = preg_replace('/[\s-]+/', "_", strtolower(trim($r["stance"] ?? ""))) ?: "no_trade";
foreach (["alternatives", "risks", "flag_responses", "checks"] as $k) $r[$k] = $r[$k] ?? [];
echo $r["assessment"], " ", $r["skew_signal"], " ", $r["stance"], " ", $r["conviction"], PHP_EOL;
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
string Field(string k) => r.TryGetProperty(k, out var v) ? (v.GetString() ?? "").Trim().ToLowerInvariant() : "";
var stance = Field("stance").Replace('-', '_').Replace(' ', '_');
if (stance == "") stance = "no_trade";
var assessment = Field("assessment");
var skew = Field("skew_signal");
var conviction = Field("conviction");
Console.WriteLine($"{assessment} {skew} {stance} {conviction}");
Invariants worth asserting
The page runs Recon.reconcile(result, facts) on every reply and shows each disagreement
next to the review. These are the checks, so a script can hold the reply to the same standard:
- Numbers. Every number written in the prose (headline, carry read, vol read, history read, the four trade fields, each alternative's why, risks and their watch, flag responses, checks, summary) must equal a figure in
factsexactly — re-rounding is a disagreement, so "4.5%" for "4.46%" fails. A number with a unit (%,bp,x,m) must match a figure with the same unit, and an explicit sign must match the figure's sign; an unsigned figure may stand for either sign ("4110.4 pips above spot"). Bare integers of 10 or less and calendar years are not counted as claims. - Names. Tenors, ISO dates, the 25-delta, N-week and N-day windows and MA50/MA200 are cut out of the number scan. Tenors (
6M,3-month), including those inrisks[].tenors, must be infacts.tenorsorfacts.trades(plus3Mwhen a history is supplied); dates must be the history's first or last date; and any ISO currency code the page knows must be the pair's base or quote. - Assessment and skew.
assessmentequalsfacts.assessmentandskew_signalequalsfacts.skew_signal; the model copies them and explains them, never decides them. A missing one is a disagreement too. - Stance.
no_tradeor exactly oneidinfacts.trades; it must beno_tradewhenfacts.signal_trustisuntrusted. - Conviction. Present;
noneexactly when the stance isno_trade;highonly when the assessment isattractiveand no medium or high flag was raised. - Alternatives. Each
alternatives[].idis a trade id infacts.tradesand is not the stance itself. - Flags.
flag_responsesanswers every code infacts.flagsand invents none. - Lane. A reply that names a lane other than
reviewis reported.
// 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"
}
| field | allowed values |
|---|---|
lane | review |
assessment | attractive, moderate, unattractive, n/a — must equal facts.assessment |
skew_signal | supportive, neutral, against, n/a — must equal facts.skew_signal |
stance | one id from facts.trades, or no_trade (required when signal_trust is untrusted) |
conviction | high, medium, low, none — none exactly for no_trade; high only when attractive with no medium or high flag |
risks[].severity | high, medium, low |
alternatives[].id | a trade id from facts.trades other than the stance |
flag_responses[].code | each 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.
| code | severity | meaning |
|---|---|---|
points_scale_suspect | high | Annualised 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_kink | high | A 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_flip | medium | The currency that earns carry changes along the curve; tenors names those on the other side from the target. |
cip_basis_wide | medium | The 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_inverted | medium | The shortest tenor's ATM vol is more than 1 vol point above the longest's: the options market pricing near-term stress. |
skew_against_carry | medium when the target's |RR| is 1.0 or more, otherwise low | The target's skew is against: options pay more for the move that unwinds the carry. |
fat_tails | low | The target's 25d butterfly is 0.5 vol points or more. |
no_vol_at_target | medium | No ATM vol at the target: carry-to-vol, cushion and loss probability are not available there and the assessment is n/a. |
target_missing | medium | The requested target tenor is not in the forward curve; the nearest tenor by days is used. |
spot_history_mismatch | high | The last close is more than 3% from spot: the history is for another pair, inverted, or stale. |
realised_above_implied | medium | 3M 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_carry | medium | Spot moved against the target's carry side over the last 63 closes by more than a quarter of the annualised carry. |
spot_at_range_edge | low | Spot sits within 10% of either end of its range (up to 252 closes). |
short_history | low | Fewer than 64 closes: no 3M realised vol or 3M trend. |
wide_bid_ask | low | The 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_curve | low | Fewer 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:
| key | value | meaning |
|---|---|---|
attractive_cv | 0.4 | Carry-to-vol at the target at or above this: attractive. |
moderate_cv | 0.2 | From this up to attractive_cv: moderate; below: unattractive. |
skew_neutral_vol | 0.25 | |25d RR| below this, in vol points: neutral skew. |
skew_strong_vol | 1 | |25d RR| at or above this makes skew_against_carry medium. |
fat_tail_bf_vol | 0.5 | 25d butterfly at or above this: fat_tails. |
points_scale_pct | 40 | Annualised carry above this at any tenor: points_scale_suspect. |
kink_bp | 150 | Forward premium off the neighbours' line by more than this: carry_kink. |
cip_basis_bp | 50 | CIP basis wider than this: cip_basis_wide. |
vol_inversion_vol | 1 | Shortest ATM vol above the longest by more than this: vol_curve_inverted. |
rv_over_iv_vol | 1 | 3M realised above target implied by more than this: realised_above_implied. |
range_edge_pct | 10 | Spot within this share of either end of the range: spot_at_range_edge. |
history_mismatch_pct | 3 | Last close further than this from spot: spot_history_mismatch. |
spread_share_pct | 25 | Bid-ask above this share of the reference carry: wide_bid_ask. |
min_history | 64 | Closes needed for the 3M realised vol and trend: short_history below it. |
min_tenors | 3 | Fewer 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
import datetime
facts = json.loads(INPUT["facts"])
trade_ids = [t["id"] for t in facts["trades"]]
codes = {f["code"] for f in facts["flags"]}
serious = [f["code"] for f in facts["flags"] if f["severity"] in ("high", "medium")]
problems = []
if r["assessment"] != facts["assessment"]: problems.append("assessment")
if r["skew_signal"] != facts["skew_signal"]: problems.append("skew")
if r["stance"] != "no_trade" and r["stance"] not in trade_ids: problems.append("stance not priced")
if facts["signal_trust"] == "untrusted" and r["stance"] != "no_trade": problems.append("untrusted sheet")
if (r["stance"] == "no_trade") != (r["conviction"] == "none"): problems.append("conviction")
if r["conviction"] == "high" and (facts["assessment"] != "attractive" or serious): problems.append("high conviction")
if {x["code"] for x in r["flag_responses"]} != codes: problems.append("flags")
if problems:
raise SystemExit("reply disagrees with the sheet (" + ", ".join(problems) + ") - do not use it")
with open("carry-monitor.jsonl", "a") as log:
log.write(json.dumps({"date": datetime.date.today().isoformat(), "assessment": r["assessment"],
"skew_signal": r["skew_signal"], "stance": r["stance"], "conviction": r["conviction"],
"sweet_spot": facts["sweet_spot"]}) + "\n")
print("stance:", r["stance"])
raise SystemExit(3 if r["stance"] != "no_trade" else 0)
import { appendFileSync } from "node:fs";
const facts = JSON.parse(INPUT.facts);
const tradeIds = facts.trades.map((t) => t.id);
if (r.assessment !== facts.assessment || r.skew_signal !== facts.skew_signal) throw new Error("reply disagrees with the sheet");
if (r.stance !== "no_trade" && !tradeIds.includes(r.stance)) throw new Error("stance is not a priced trade");
if (facts.signal_trust === "untrusted" && r.stance !== "no_trade") throw new Error("untrusted sheet, stance must be no_trade");
if ((r.stance === "no_trade") !== (r.conviction === "none")) throw new Error("conviction does not match the stance");
appendFileSync("carry-monitor.jsonl", JSON.stringify({ date: new Date().toISOString().slice(0, 10), assessment: r.assessment, skew_signal: r.skew_signal, stance: r.stance, conviction: r.conviction }) + "\n");
console.log("stance:", r.stance);
process.exitCode = r.stance !== "no_trade" ? 3 : 0;
var facts struct {
Assessment string `json:"assessment"`
SkewSignal string `json:"skew_signal"`
SignalTrust string `json:"signal_trust"`
Trades []struct {
ID string `json:"id"`
} `json:"trades"`
}
_ = json.Unmarshal([]byte(input["facts"].(string)), &facts)
if r.Assessment != facts.Assessment || r.SkewSignal != facts.SkewSignal {
panic("reply disagrees with the sheet")
}
if facts.SignalTrust == "untrusted" && r.Stance != "no_trade" {
panic("untrusted sheet, stance must be no_trade")
}
if (r.Stance == "no_trade") != (r.Conviction == "none") {
panic("conviction does not match the stance")
}
log, _ := os.OpenFile("carry-monitor.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
fmt.Fprintf(log, "%s %s %s %s %s\n", time.Now().Format("2006-01-02"), r.Assessment, r.SkewSignal, r.Conviction, r.Stance)
log.Close()
if r.Stance != "no_trade" {
fmt.Println("stance:", r.Stance)
os.Exit(3)
}
var om = new com.fasterxml.jackson.databind.ObjectMapper();
var facts = om.readTree(om.readTree(input).get("facts").asText());
if (!assessment.equals(facts.get("assessment").asText()) || !skew.equals(facts.get("skew_signal").asText()))
throw new IllegalStateException("reply disagrees with the sheet");
if ("untrusted".equals(facts.get("signal_trust").asText()) && !"no_trade".equals(stance))
throw new IllegalStateException("untrusted sheet, stance must be no_trade");
java.nio.file.Files.writeString(java.nio.file.Path.of("carry-monitor.log"),
java.time.LocalDate.now() + " " + assessment + " " + skew + " " + conviction + " " + stance + "\n",
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
if (!"no_trade".equals(stance)) { System.out.println("stance: " + stance); System.exit(3); }
require "date"
facts = JSON.parse(INPUT["facts"])
raise "reply disagrees with the sheet" unless r["assessment"] == facts["assessment"] && r["skew_signal"] == facts["skew_signal"]
raise "untrusted sheet, stance must be no_trade" if facts["signal_trust"] == "untrusted" && r["stance"] != "no_trade"
raise "conviction does not match the stance" if (r["stance"] == "no_trade") != (r["conviction"] == "none")
File.open("carry-monitor.log", "a") { |f| f.puts "#{Date.today} #{r['assessment']} #{r['skew_signal']} #{r['conviction']} #{r['stance']}" }
puts "stance: #{r['stance']}"
exit(r["stance"] == "no_trade" ? 0 : 3)
<?php
$facts = json_decode($input["facts"], true);
if ($r["assessment"] !== $facts["assessment"] || $r["skew_signal"] !== $facts["skew_signal"]) throw new RuntimeException("reply disagrees with the sheet");
if ($facts["signal_trust"] === "untrusted" && $r["stance"] !== "no_trade") throw new RuntimeException("untrusted sheet");
if (($r["stance"] === "no_trade") !== ($r["conviction"] === "none")) throw new RuntimeException("conviction does not match the stance");
file_put_contents("carry-monitor.log", date("Y-m-d") . " {$r["assessment"]} {$r["skew_signal"]} {$r["conviction"]} {$r["stance"]}\n", FILE_APPEND);
echo "stance: ", $r["stance"], PHP_EOL;
exit($r["stance"] === "no_trade" ? 0 : 3);
var facts = JsonSerializer.Deserialize<JsonElement>(input.GetProperty("facts").GetString()!);
if (assessment != facts.GetProperty("assessment").GetString() || skew != facts.GetProperty("skew_signal").GetString())
throw new Exception("reply disagrees with the sheet");
if (facts.GetProperty("signal_trust").GetString() == "untrusted" && stance != "no_trade")
throw new Exception("untrusted sheet, stance must be no_trade");
if ((stance == "no_trade") != (conviction == "none"))
throw new Exception("conviction does not match the stance");
File.AppendAllText("carry-monitor.log", $"{DateTime.Today:yyyy-MM-dd} {assessment} {skew} {conviction} {stance}\n");
Console.WriteLine($"stance: {stance}");
return stance == "no_trade" ? 0 : 3;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true. What
you hold then is a prefix of the reply: the 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.