Drive Spread Desk from your own code
Everything the web page does is available over HTTP. Send the spread sheet the browser computes for one fixed-coupon bond (coupon, frequency, day count, settlement, maturity and a clean price or a yield, the government curve and, where you have them, a swap curve, an issuer-peer credit spread curve, comparable bonds and a history of the bond's G-spread), and get the same review back: the rich/cheap assessment copied from the sheet, a recommendation (overweight, neutral, underweight or no view) with its conviction, a read of what the G-spread is made of, of the comps and the history, of the rate-shock scenarios and of how much spread move would flip the call, the risks, one response per flag and the checks to make before acting. The natural use is a daily relative value monitor: a script rebuilds the sheet from end-of-day prices, curves and comps, asks for the review, and files it next to the sheet.
One thing to be clear about before the first call: the model never does the arithmetic.
The price and yield, accrued interest, Macaulay and modified duration, convexity and DV01, the G-spread,
I-spread and swap spread, the decomposition into the credit curve spread and the residual, the spread to
the comps line, the history z-score and percentile, the rate-shock scenario table, the breakevens, the
signals with their flips, the assessment and the flags are all computed by spread.js, the
same file the web page loads, and the result is sent as facts, a JSON string. The
model's job is judgement over those figures; it copies them and never recomputes one. A direct API
caller must therefore compute the facts the same way (run spread.js, or send the sheet the
page built) — a hand-rolled facts with different figures gets a review of different
figures. See building the facts below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
There is no slug header. The token is minted for this app (the guest endpoint takes
{"slug":"spread-desk"} in its body), and every later call knows the app from the token.
Send it as Authorization: Bearer … on every call.
The input object IS the request body. There is no {"input": …} wrapper.
A wrapped body returns a 200 with an unknown field 'input' warning, and the model never
sees your facts.
Error codes
| 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":"spread-desk"} in the body and no Authorization header. It answers
201 with {token, guest_id, expires_at}. A guest token can call
/me and /estimate. A review is metered, so it needs a
personal token from signing in: a guest run is refused with 403 unless the
publisher sponsors guest runs (/estimate reports this as sponsor_enabled).
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://spread-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. No Authorization header,
# the slug goes in the body. A guest token is enough for /me and /estimate;
# running a review needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"spread-desk"}'
# HTTP 201
# {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://spread-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": "spread-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://spread-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: "spread-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, guest.expires_at);
// Open https://spread-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":"spread-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://spread-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\":\"spread-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://spread-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: "spread-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://spread-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" => "spread-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://spread-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\":\"spread-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://spread-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
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://spread-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://spread-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://spread-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 SpreadDesk {
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://spread-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 SpreadDesk
{
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 = SpreadDesk.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 SpreadDesk.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 spread sheet like a senior fixed income relative value analyst: the assessment (cheap, fair, rich, n/a) copied from the sheet, a recommendation (overweight, neutral, underweight or no_view) and conviction, headline, spread read, relative read (comps and history), scenario read, flip read, risks, flag responses, checks and summary. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | A JSON string you build: the JSON-encoded output of Spread.buildFacts (the browser builds it from the spread sheet). It holds the bond's price, yield and risk figures, the curves at its maturity, the spreads and their decomposition, the comps fit, the history, the scenarios, the breakevens, the signals and the primary call, the assessment, the signal trust, the flags and the rules. Fields below. |
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,
Spread.mustBeObject, throws on anything else before it calls the API.
Building the facts
A direct API caller builds facts itself; the server does no bond arithmetic and the model
never recomputes a figure, so compute the facts exactly the way the page does. The engine is
spread.js, plain JavaScript with no dependencies that exports itself to Node
through module.exports. Download it next to your script, put the bond in a JSON file with
the form field ids below (the page's Save bond .json button writes exactly this file,
as {"form": {...}, "question": "..."}), and let it build the body:
// make-body.js - node make-body.js bond.json "your question" > body.json
const fs = require("fs");
const Spread = require("./spread.js"); // https://spread-desk.skillsafe.ai/spread.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = Spread.compute(file.form || file); // {ok, errors, warnings, inputs, model}
if (!res.ok) throw new Error(res.errors.join(" "));
res.warnings.forEach((w) => console.error("warning:", w)); // skipped lines, defaults used
const body = Spread.mustBeObject(Spread.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));
Or build it inline. This is the page's Hanseatic 2031 example: a EUR utility quoted on yield, rich to four comparable utilities while its own G-spread history reads fair, with no swap or credit curve (illustrative, not market data):
const Spread = require("./spread.js");
const form = {
bond: "Hanseatic Utilities 3.125% 2031",
currency: "EUR",
coupon: "3.125", // percent a year
frequency: "1", // coupons a year: 1, 2, 4 or 12
day_count: "ACT/ACT", // 30/360, ACT/ACT, ACT/365 or ACT/360
settle: "2026-09-29",
maturity: "2031-11-12",
quote_mode: "yield", // "price" (clean, per 100), "yield" (percent) or "spread" (G-spread, bp)
quote: "3.02",
notional: "5000000", // face amount, in the bond's currency
govt: ["1Y 2.05", "2Y 2.12", "3Y 2.18", "5Y 2.29", "7Y 2.44", "10Y 2.62", "30Y 3.05"].join("\n"),
swap: "",
credit: "",
comps: [
"Moselgrund Energie 2.75% 2030 | 2030-06-20 | 83",
"Nordsee Netz 3.25% 2031 | 2031-04-02 | 91",
"Alpen Strom 3.00% 2032 | 2032-09-15 | 96",
"Baltic Grid 3.50% 2033 | 2033-10-01 | 104",
].join("\n"),
history: "date,g_spread_bp\n2026-01-05,69.1\n2026-01-12,72.3\n...\n2026-09-14,76.0\n2026-09-21,73.0", // 38 weekly lines
};
const res = Spread.compute(form);
console.log(Spread.verdictLine(res.model));
// Hanseatic Utilities 3.125% 2031: yield 3.020%, G-spread +72.1 bp; spread to the comps fit -20.3 bp - RICH (1 of 2 signals agree);
// 10.3 bp widening to fair (3.123%, 100.004); DV01 EUR 2,348.
const body = Spread.buildInput(res, "It trades well inside the other utilities. Is that rich enough to " +
"underweight when the history says fair?");
// body = {task: "review", facts: "<JSON string, 4,484 characters>", question: "..."}
If you do not run JavaScript, copy the sheet instead of re-deriving it: the page's
Save bond .json file plus spread.js in any Node runtime reproduces the
facts byte for byte, and the Sheet .md and Scenarios CSV exports carry
the same figures for a human check. Porting the arithmetic is possible (the formulas are listed
below), but the rounding and formatting must match too, because the page
reconciles the reply against the exact strings it sent.
The form fields
| field id | required | accepted input |
|---|---|---|
bond | yes | A name for the bond (issuer, coupon, maturity), up to 80 characters. Empty: "the bond" is used, with a warning. |
currency | yes | A three-letter code; anything that is not a letter is dropped. Default USD; anything that is not three letters falls back to USD with a warning. It is the only currency the review may name. |
coupon | yes | Percent a year, from 0 (a zero-coupon bond) to 30. Thousands commas, a leading + and a trailing % are stripped. Missing or out of range: an error. |
frequency | yes | Coupons a year: 1, 2 (the default), 4 or 12. Anything else becomes 2 with a warning. |
day_count | yes | 30/360 (the default, US bond basis), ACT/ACT, ACT/365 or ACT/360. Anything else becomes 30/360 with a warning. |
settle, maturity | yes | Real dates written YYYY-MM-DD (/ and . are accepted as separators); also 15.05.2033 (day first), 15-May-2033, 15 May 2033, May 15, 2033, and a slashed 05/15/2033 or 15/05/2033 when one side is above 12 or the other date settles the order. A slashed date that reads both ways is an error naming both readings. Missing or unreadable: an error; the maturity must be after settlement and no more than 100 years later. |
quote_mode | yes | "price" (the default: a clean price per 100 of face), "yield" (the yield to maturity in percent, -50 to 200) or "spread" (the G-spread in bp, -1,000 to 10,000: the yield is the government curve at maturity plus the spread; facts.bond.quote_given is then "G-spread"). |
quote | yes | The clean price (a positive number, or 32nds such as 99-16, 99-16+, 99-163), the yield or the G-spread. Missing or unreadable: an error. A price that no yield between -50% and 200% reproduces is an error too. |
notional | yes | The face amount in the bond's currency, for example 10000000. Empty or not a positive number: 10,000,000 is used (with a warning when something unreadable was typed). Capped at 100,000,000,000. |
govt | yes | The government curve, one tenor per line with its yield in percent; at least two points. See below. |
swap | no | The swap curve, same format, rates in percent; fewer than two points is ignored with a warning. |
credit | no | The issuer-peer credit spread curve, same format, spreads over government in bp. |
comps | no | Comparable bonds, one per line; see below. Up to 25. |
history | no | The bond's own G-spread history in bp; see below. Empty means facts.history is "not supplied". |
The curves. One point per line: a tenor then a value, 5Y 2.29. Cells may
be separated by commas, semicolons, tabs, pipes or spaces; anything after # is a comment;
a line with no number (a header such as tenor yield) is skipped. Tenors are one to three
digits and a unit (3M, 6mo, 2Y, 10yr, weeks
allowed) or a bare number of years up to 100; 12M is read as 1Y and
24M as 2Y, but 18M stays. A tenor given twice keeps the later
line; points are sorted by tenor and at most 40 are used (the shortest; a cut raises input_truncated). 2 YR 3.62 is read as 2Y; a value written with a decimal comma where the comma cannot be a separator (2Y 3,62, 2Y;3,62) is read as 3.62, with a warning; a line with more than one value uses the first after the tenor, with a warning (paste the mid, not bid and ask). Values beyond 1,000 (percent curves) or 100,000 (bp) are skipped as unreadable. Government and swap values are yields in
percent; the credit curve is in bp, and a credit column that tops out at 5 or less is taken as percent
and multiplied by 100 (decided once for the whole column, with a warning). Every skipped or repaired
line comes back in res.warnings.
The comps. One bond per line: a name, a maturity (an ISO date, a tenor or a number of years) and its G-spread in bp. With commas, tabs, pipes or semicolons the cells split on those; with only spaces, the last two cells are the maturity and the spread and everything before them is the name. A dated maturity must fall after settlement. A header line is skipped.
The spread history. One observation per line, 2026-09-21,73.0 or a bare
spread (read oldest first). Dated histories are sorted by date and a repeated date keeps the later
line; a mix of dated and undated lines keeps only the dated ones. At most the last 800 observations
are kept (a cut raises input_truncated), observations dated on or after settlement are left out of the statistics (today is compared against the history, not counted in it), and fewer than two means no history. It must be the same G-spread measure the sheet computes:
a last observation more than 30 bp from today's G-spread raises history_mismatch.
Errors (in res.errors, and no facts are built): no coupon or one out of
range, a missing or unreadable settlement or maturity date, a maturity not after settlement, no quote,
no government curve or fewer than two points on it, no coupon after settlement, or a price no yield
reproduces.
What is in facts
Numbers in facts are pre-formatted strings with their units ("3.020%", "+72.1 bp",
"-20.3 bp", "4.55", "0.0470", "EUR 2,348", "+EUR 241,742", "-0.37", "39.5%"), because the model is told
to copy figures exactly as written and the page re-reads every number in the reply against them. The
top-level keys, in the order buildFacts writes them:
| key | contents |
|---|---|
units | The conventions in words: prices per 100 of face; yields and curve points in percent; spreads in bp; what the G-spread, I-spread, swap spread and credit curve are; durations in years; how DV01 and the scenarios are defined; the money currency. |
bond | {name, currency, coupon, coupons_a_year, day_count, settlement, maturity, years_to_maturity, quote_given, clean_price, accrued, dirty_price, ytm, next_coupon, macaulay_duration, modified_duration, convexity, dv01_per_100, dv01, face, market_value}. |
curves | {govt_at_maturity, govt_tenors, swap_at_maturity, swap_tenors, credit_at_maturity, credit_tenors}. Each *_at_maturity is the interpolated value with the points used ("2.299% (between 5Y and 7Y)", "(at 5Y)" or "(held flat from 30Y)"), or "not supplied"; the tenor lists are the only tenors the review may name. |
spreads | {g_spread, i_spread, swap_spread}; the last two are "not supplied" without a swap curve. |
decomposition | Three rows {component, spread, share}: the G-spread (100%), the credit curve spread and the residual (liquidity + technicals) with their shares of the total; or "not supplied (no credit curve)". |
comps | {method, count, fit_at_maturity, spread_to_comps, slope, rms, maturity_range, rows[]}, each row {name, years, g_spread, fit, gap}; or "not supplied". method is single comp, average (one maturity), line through two comps or least-squares line. |
history | {observations, first_date, last_date, mean, sd, min, max, last, today_vs_last, z_score, percentile, recent_change}, or "not supplied". Undated histories give "undated" dates. |
scenarios[] | One per parallel yield shift (-100, -50, 0, +50, +100 bp): {shift, ytm, clean_price, price_change, pnl, pct_change, duration_convexity_estimate}. |
breakevens | {spread_widening_1y, yield_rise_1y}: the bp of spread widening, or of yield rise, over a year that the spread over government, or the whole yield, pays for. |
signals[] | The rich/cheap signals available, in order of preference: {id, measure, value, call, cheap_from, rich_from, flips[]}, each flip {to, spread_move, size}. See the signals. |
primary_signal | The id of the first available signal (residual, comps, history), or "none". |
assessment, assessment_measure | cheap, fair, rich or n/a: the primary signal's call, with its value and thresholds in words. |
signals_available | How many signals were computed, 0 to 3 (a number). |
signals_agree | "yes" when every signal gives the same call, "no" otherwise, "n/a" with none. |
signal_trust | "trusted", or "untrusted" when any high-severity flag was raised; the recommendation must then be no_view. |
flags[] | {code, severity, detail}; see the flag codes. |
rules | The RULES thresholds as strings with their units, plus the shifts; see the rules. |
note_clipped_chars | Only when the question was longer than 2,000 characters: how many were cut. |
How the figures are computed
For a bond with coupon c (percent a year), f coupons a year, yield y, face N and maturity T years after settlement (actual days / 365.25):
- Schedule and accrued. Coupon dates are generated backward from maturity (an end-of-month coupon stays on the month end), so any stub sits at the front. The accrued fraction is the elapsed share of the current period, in 30/360 days for
30/360and in actual days otherwise;accrued= c/f × that fraction, exceptACT/365(c × days / 365) andACT/360(c × days / 360). w = 1 − the fraction is the part of the period still to run. - Price and yield. Dirty price per 100 = Σ CFi / (1 + y/f)i + w (street convention); clean = dirty − accrued. From a price, the yield is solved by bisection between -50% and 200%.
- Risk.
macaulay_duration= Σ (t/f) PVt / P with t = i + w;modified_duration= Macaulay / (1 + y/f);convexity= Σ t(t + 1) CF / (1 + y/f)t + 2 / f² / P.dv01_per_100= modified × dirty / 10,000;dv01= that × N / 100;market_value= dirty × N / 100. - Curves. Interpolated linearly in years at T and held flat beyond either end (and
*_at_maturitysays so). - Spreads.
g_spread= (ytm − govt(T)) × 100 bp;i_spread= (ytm − swap(T)) × 100;swap_spread= (swap(T) − govt(T)) × 100. Yields are compared as quoted, with no compounding conversion. Residual = G-spread − credit curve spread at T; each share = component / G-spread. - Comps. One comp, or comps all at one maturity: their average. Two: the line through them. Three or more: a least-squares line of G-spread on years.
spread_to_comps= G-spread − the line at T;rms(three or more comps) = √(mean squared gap). - History. Mean and sample standard deviation (n − 1);
z_score= (G-spread − mean) / sd;percentile= share of observations at or below today's G-spread;today_vs_last= G-spread − last;recent_change= last − the value min(21, n − 1) observations earlier. - Scenarios. The yield shifted by -100, -50, 0, +50 and +100 bp and the bond fully revalued at the same settlement:
price_changeper 100,pnl= change × N / 100,pct_change= change / dirty;duration_convexity_estimate= (−modified × Δy + ½ × convexity × Δy²) × dirty. - Breakevens.
spread_widening_1y= G-spread / modified duration;yield_rise_1y= yield (in bp) / modified duration; both ignore roll-down. - Signals and flips. Each signal's value is rounded (spreads to 0.1 bp, the z-score to 0.01) and called
cheapat or above +threshold,richat or below −threshold, elsefair. The flip fromcheapis a tightening of (value − threshold), fromricha widening of (−threshold − value), and afaircall has two: a widening of (threshold − value) to cheap and a tightening of (value + threshold) to rich. For the z-score the size is multiplied by the history's sd, so every flip is in bp of the bond's spread, curves held. - Formatting. Yields, coupons and curve values to three decimals with
%; spreads to one decimal with a sign andbp(thresholds, rms, sd, mean, min, max and breakevens unsigned); prices to three decimals, accrued and DV01 per 100 to four; durations to two, convexity to one; the z-score signed to two; money whole, with the currency code and thousands commas (scenario P&L signed); price changes signed to three, percent changes signed to two.
The worked example
The full request body the page builds for the Hanseatic 2031 example (this is a real run input; the facts string is 4,484 characters):
{
"task": "review",
"facts": "{\"units\":\"Prices per 100 of face; yields and curve points in percent; spreads in basis points (bp); G-spread = yield minus the government curve interpolated at the bond's maturity, I-spread = yield minus the swap curve there, swap spread = swap minus government; credit curve in bp over government; durations in years; DV01 = price change for a 1 bp fall in yield, per 100 and on the face amount; scenarios are parallel shifts of the bond's yield, fully revalued, P&L on the face amount at unchanged settlement; the history is the pasted observations only, and today's G-spread is compared against them, not counted among them; money in EUR.\",\"bond\":{\"name\":\"Hanseatic Utilities 3.125% 2031\",\"currency\":\"EUR\",\"coupon\":\"3.125%\",\"coupons_a_year\":1,\"day_count\":\"ACT/ACT\",\"settlement\":\"2026-09-29\",\"maturity\":\"2031-11-12\",\"years_to_maturity\":\"5.12\",\"quote_given\":\"yield\",\"clean_price\":\"100.486\",\"accrued\":\"2.7483\",\"dirty_price\":\"103.235\",\"ytm\":\"3.020%\",\"next_coupon\":\"2026-11-12\",\"macaulay_duration\":\"4.69\",\"modified_duration\":\"4.55\",\"convexity\":\"26.4\",\"dv01_per_100\":\"0.0470\",\"dv01\":\"EUR 2,348\",\"face\":\"EUR 5,000,000\",\"market_value\":\"EUR 5,161,733\"},\"curves\":{\"govt_at_maturity\":\"2.299% (between 5Y and 7Y)\",\"govt_tenors\":[\"1Y\",\"2Y\",\"3Y\",\"5Y\",\"7Y\",\"10Y\",\"30Y\"],\"swap_at_maturity\":\"not supplied\",\"swap_tenors\":[],\"credit_at_maturity\":\"not supplied\",\"credit_tenors\":[]},\"spreads\":{\"g_spread\":\"+72.1 bp\",\"i_spread\":\"not supplied\",\"swap_spread\":\"not supplied\"},\"decomposition\":\"not supplied (no credit curve)\",\"comps\":{\"method\":\"least-squares line\",\"count\":4,\"fit_at_maturity\":\"+92.4 bp\",\"spread_to_comps\":\"-20.3 bp\",\"slope\":\"+5.9 bp a year\",\"rms\":\"1.4 bp\",\"maturity_range\":\"3.72 to 7.01 years\",\"rows\":[{\"name\":\"Moselgrund Energie 2.75% 2030\",\"years\":\"3.72\",\"g_spread\":\"+83.0 bp\",\"fit\":\"+84.2 bp\",\"gap\":\"-1.2 bp\"},{\"name\":\"Nordsee Netz 3.25% 2031\",\"years\":\"4.51\",\"g_spread\":\"+91.0 bp\",\"fit\":\"+88.8 bp\",\"gap\":\"+2.2 bp\"},{\"name\":\"Alpen Strom 3.00% 2032\",\"years\":\"5.96\",\"g_spread\":\"+96.0 bp\",\"fit\":\"+97.4 bp\",\"gap\":\"-1.4 bp\"},{\"name\":\"Baltic Grid 3.50% 2033\",\"years\":\"7.01\",\"g_spread\":\"+104.0 bp\",\"fit\":\"+103.6 bp\",\"gap\":\"+0.4 bp\"}]},\"history\":{\"observations\":38,\"first_date\":\"2026-01-05\",\"last_date\":\"2026-09-21\",\"mean\":\"73.8 bp\",\"sd\":\"4.6 bp\",\"min\":\"65.3 bp\",\"max\":\"85.7 bp\",\"last\":\"73.0 bp\",\"today_vs_last\":\"-0.9 bp\",\"z_score\":\"-0.37\",\"percentile\":\"39.5%\",\"recent_change\":\"-12.7 bp over the last 21 observations\"},\"scenarios\":[{\"shift\":\"-100 bp\",\"ytm\":\"2.020%\",\"clean_price\":\"105.321\",\"price_change\":\"+4.835\",\"pnl\":\"+EUR 241,742\",\"pct_change\":\"+4.68%\",\"duration_convexity_estimate\":\"+4.832\"},{\"shift\":\"-50 bp\",\"ytm\":\"2.520%\",\"clean_price\":\"102.868\",\"price_change\":\"+2.382\",\"pnl\":\"+EUR 119,105\",\"pct_change\":\"+2.31%\",\"duration_convexity_estimate\":\"+2.382\"},{\"shift\":\"0 bp\",\"ytm\":\"3.020%\",\"clean_price\":\"100.486\",\"price_change\":\"0.000\",\"pnl\":\"EUR 0\",\"pct_change\":\"0.00%\",\"duration_convexity_estimate\":\"0.000\"},{\"shift\":\"+50 bp\",\"ytm\":\"3.520%\",\"clean_price\":\"98.173\",\"price_change\":\"-2.314\",\"pnl\":\"-EUR 115,692\",\"pct_change\":\"-2.24%\",\"duration_convexity_estimate\":\"-2.313\"},{\"shift\":\"+100 bp\",\"ytm\":\"4.020%\",\"clean_price\":\"95.925\",\"price_change\":\"-4.562\",\"pnl\":\"-EUR 228,087\",\"pct_change\":\"-4.42%\",\"duration_convexity_estimate\":\"-4.559\"}],\"breakevens\":{\"spread_widening_1y\":\"15.9 bp\",\"yield_rise_1y\":\"66.4 bp\"},\"signals\":[{\"id\":\"comps\",\"measure\":\"spread to the comps fit\",\"value\":\"-20.3 bp\",\"call\":\"rich\",\"cheap_from\":\"+10 bp\",\"rich_from\":\"-10 bp\",\"flips\":[{\"to\":\"fair\",\"spread_move\":\"widening\",\"size\":\"10.3 bp\"}]},{\"id\":\"history\",\"measure\":\"z-score against the spread history\",\"value\":\"-0.37\",\"call\":\"fair\",\"cheap_from\":\"+1.00\",\"rich_from\":\"-1.00\",\"flips\":[{\"to\":\"cheap\",\"spread_move\":\"widening\",\"size\":\"6.3 bp\"},{\"to\":\"rich\",\"spread_move\":\"tightening\",\"size\":\"2.9 bp\"}]}],\"primary_signal\":\"comps\",\"assessment\":\"rich\",\"assessment_measure\":\"spread to the comps fit -20.3 bp (cheap at +10 bp or more, rich at -10 bp or less)\",\"signals_available\":2,\"signals_agree\":\"no\",\"signal_trust\":\"trusted\",\"flags\":[],\"rules\":{\"residual_cheap_from\":\"+10 bp\",\"residual_rich_from\":\"-10 bp\",\"comps_cheap_from\":\"+10 bp\",\"comps_rich_from\":\"-10 bp\",\"z_cheap_from\":\"+1.00\",\"z_rich_from\":\"-1.00\",\"curve_kink_above\":\"40 bp\",\"comps_rms_above\":\"25 bp\",\"history_gap_above\":\"30 bp\",\"spread_scale_above\":\"3000 bp\",\"min_history_observations\":20,\"min_govt_points\":4,\"small_flip_below\":\"3 bp\",\"long_duration_above\":12,\"deep_discount_below\":80,\"deep_premium_above\":120,\"shifts\":[\"-100 bp\",\"-50 bp\",\"0 bp\",\"+50 bp\",\"+100 bp\"]}}",
"question": "It trades well inside the other utilities. Is that rich enough to underweight when the history says fair?"
}
The same facts decoded, with the long arrays shortened:
{
"units": "Prices per 100 of face; yields and curve points in percent; spreads in basis points (bp); ... money in EUR.",
"bond": {"name": "Hanseatic Utilities 3.125% 2031", "currency": "EUR", "coupon": "3.125%", "coupons_a_year": 1,
"day_count": "ACT/ACT", "settlement": "2026-09-29", "maturity": "2031-11-12", "years_to_maturity": "5.12",
"quote_given": "yield", "clean_price": "100.486", "accrued": "2.7483", "dirty_price": "103.235", "ytm": "3.020%",
"next_coupon": "2026-11-12", "macaulay_duration": "4.69", "modified_duration": "4.55", "convexity": "26.4",
"dv01_per_100": "0.0470", "dv01": "EUR 2,348", "face": "EUR 5,000,000", "market_value": "EUR 5,161,733"},
"curves": {"govt_at_maturity": "2.299% (between 5Y and 7Y)", "govt_tenors": ["1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "30Y"],
"swap_at_maturity": "not supplied", "swap_tenors": [], "credit_at_maturity": "not supplied", "credit_tenors": []},
"spreads": {"g_spread": "+72.1 bp", "i_spread": "not supplied", "swap_spread": "not supplied"},
"decomposition": "not supplied (no credit curve)",
"comps": {"method": "least-squares line", "count": 4, "fit_at_maturity": "+92.4 bp", "spread_to_comps": "-20.3 bp",
"slope": "+5.9 bp a year", "rms": "1.4 bp", "maturity_range": "3.72 to 7.01 years",
"rows": [{"name": "Moselgrund Energie 2.75% 2030", "years": "3.72", "g_spread": "+83.0 bp", "fit": "+84.2 bp", "gap": "-1.2 bp"}, ...]},
"history": {"observations": 38, "first_date": "2026-01-05", "last_date": "2026-09-21", "mean": "73.8 bp", "sd": "4.6 bp",
"min": "65.3 bp", "max": "85.7 bp", "last": "73.0 bp", "today_vs_last": "-0.9 bp", "z_score": "-0.37",
"percentile": "39.5%", "recent_change": "-12.7 bp over the last 21 observations"},
"scenarios": [
{"shift": "-100 bp", "ytm": "2.020%", "clean_price": "105.321", "price_change": "+4.835", "pnl": "+EUR 241,742",
"pct_change": "+4.68%", "duration_convexity_estimate": "+4.832"},
...,
{"shift": "+100 bp", "ytm": "4.020%", "clean_price": "95.925", "price_change": "-4.562", "pnl": "-EUR 228,087",
"pct_change": "-4.42%", "duration_convexity_estimate": "-4.559"}
],
"breakevens": {"spread_widening_1y": "15.9 bp", "yield_rise_1y": "66.4 bp"},
"signals": [
{"id": "comps", "measure": "spread to the comps fit", "value": "-20.3 bp", "call": "rich", "cheap_from": "+10 bp",
"rich_from": "-10 bp", "flips": [{"to": "fair", "spread_move": "widening", "size": "10.3 bp"}]},
{"id": "history", "measure": "z-score against the spread history", "value": "-0.37", "call": "fair", "cheap_from": "+1.00",
"rich_from": "-1.00", "flips": [{"to": "cheap", "spread_move": "widening", "size": "6.3 bp"},
{"to": "rich", "spread_move": "tightening", "size": "2.9 bp"}]}
],
"primary_signal": "comps",
"assessment": "rich",
"assessment_measure": "spread to the comps fit -20.3 bp (cheap at +10 bp or more, rich at -10 bp or less)",
"signals_available": 2,
"signals_agree": "no",
"signal_trust": "trusted",
"flags": [],
"rules": {"residual_cheap_from": "+10 bp", "residual_rich_from": "-10 bp", "comps_cheap_from": "+10 bp", ..., "shifts": ["-100 bp", "-50 bp", "0 bp", "+50 bp", "+100 bp"]}
}
/estimate is free: it creates no job and charges nothing. It answers
model, model_alias, markup_bps, hold_credits and
min_credits (plus sponsor_enabled, input_checked and
warnings). Read hold_credits as a reservation against the
full output cap, not the price; the real cost is charged_credits on the finished job,
which is normally much lower. A balance under min_credits is refused with 402.
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above.
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
# "hold_credits":…,"min_credits":…,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is the real cost.
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(SpreadDesk.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 SpreadDesk.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,
spread-desk:review:<hash>:a<attempt>, where the hash is a short digest of the
JSON body (the page's own is a 32-bit djb2 hash of the body and its length, both in hex; for the
Hanseatic 2031 example it is spread-desk:review:df3e9053-13fc:a1; any stable digest works). A
retried request with the same key returns the same job instead of billing a second run. Replaying a
key with a different body is a 409, so bump the attempt suffix when the body changes — for
example when you add retry_note after an unparseable reply, as the page does with
:a2.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="spread-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"review\",\"assessment\":\"rich\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"spread-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 = `spread-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("spread-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 = "spread-desk:review:" + SpreadDesk.sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(SpreadDesk.BASE + "/run"))
.header("Authorization", "Bearer " + SpreadDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = SpreadDesk.HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = SpreadDesk.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 = "spread-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 = "spread-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 = "spread-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 {SpreadDesk.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 SpreadDesk.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\":\"rich\","}
# 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(SpreadDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + SpreadDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
SpreadDesk.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 {SpreadDesk.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, recommendation and
conviction are lower-cased with spaces and hyphens turned into underscores (n/a
is kept as written), and an unknown assessment or conviction becomes empty (and is then reported as
missing); a missing or unknown recommendation becomes no_view and is reported
as not given; an unknown risk severity becomes medium; flag codes are
lower-cased; missing arrays become empty; risks with neither text nor watch, flag responses without a
code or response, and empty checks are dropped. A reply with none of headline,
spread_read and summary, or with neither a valid recommendation
nor a risks array, is treated as unparseable — that is when the page retries once with
retry_note. Then it checks the reply against the facts it sent. You should do the same.
# review.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("review.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["assessment"], r["recommendation"], r["conviction"], "-", r["headline"])
print("flip:", r["flip_read"])
for x in r["risks"]:
print(x["severity"], x["risk"], "| watch:", x["watch"])
EOF
import re
ASSESS = ("cheap", "fair", "rich", "n/a")
RECS = ("overweight", "neutral", "underweight", "no_view")
CONVICTION = ("high", "medium", "low", "none")
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)
rec = one_of(r.get("recommendation"), RECS)
r["recommendation_given"] = bool(rec)
r["recommendation"] = rec or "no_view" # the page's fallback
r["conviction"] = one_of(r.get("conviction"), CONVICTION)
for k in ("risks", "flag_responses", "checks"):
r[k] = r.get(k) or []
return r
r = parse_review(text)
print(r["assessment"], r["recommendation"], 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 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, ["cheap", "fair", "rich", "n/a"]);
const rec = oneOf(r.recommendation, ["overweight", "neutral", "underweight", "no_view"]);
r.recommendation_given = !!rec;
r.recommendation = rec || "no_view";
r.conviction = oneOf(r.conviction, ["high", "medium", "low", "none"]);
for (const k of ["risks", "flag_responses", "checks"]) r[k] = r[k] || [];
return r;
}
const r = parseReview(text);
console.log(r.assessment, r.recommendation, r.conviction, r.risks.map((x) => x.severity));
type Review struct {
Lane string `json:"lane"`
Assessment string `json:"assessment"`
Recommendation string `json:"recommendation"`
Conviction string `json:"conviction"`
Headline string `json:"headline"`
SpreadRead string `json:"spread_read"`
RelativeRead string `json:"relative_read"`
ScenarioRead string `json:"scenario_read"`
FlipRead string `json:"flip_read"`
Risks []struct {
Risk string `json:"risk"`
Severity string `json:"severity"`
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)
norm := func(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(s)), "-", "_"), " ", "_")
}
r.Recommendation = norm(r.Recommendation)
switch r.Recommendation {
case "overweight", "neutral", "underweight", "no_view":
default:
r.Recommendation = "no_view" // the page's fallback; report it as not given
}
r.Assessment = strings.ToLower(strings.TrimSpace(r.Assessment))
r.Conviction = norm(r.Conviction)
fmt.Println(r.Assessment, r.Recommendation, 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 recommendation = r.path("recommendation").asText("").trim().toLowerCase().replaceAll("[\\s-]+", "_");
if (!java.util.List.of("overweight", "neutral", "underweight", "no_view").contains(recommendation)) recommendation = "no_view";
String assessment = r.path("assessment").asText("").trim().toLowerCase();
if (!java.util.List.of("cheap", "fair", "rich", "n/a").contains(assessment)) assessment = "";
String conviction = r.path("conviction").asText("").trim().toLowerCase();
if (!java.util.List.of("high", "medium", "low", "none").contains(conviction)) conviction = "";
System.out.println(assessment + " " + recommendation + " " + 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[cheap fair rich n/a].include?(r["assessment"])
r["recommendation"] = r["recommendation"].to_s.strip.downcase.gsub(/[\s-]+/, "_")
r["recommendation"] = "no_view" unless %w[overweight neutral underweight no_view].include?(r["recommendation"])
r["conviction"] = r["conviction"].to_s.strip.downcase
r["conviction"] = "" unless %w[high medium low none].include?(r["conviction"])
%w[risks flag_responses checks].each { |k| r[k] ||= [] }
puts r["assessment"], r["recommendation"], 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"], ["cheap", "fair", "rich", "n/a"], true)) $r["assessment"] = "";
$r["recommendation"] = preg_replace('/[\s-]+/', "_", strtolower(trim($r["recommendation"] ?? "")));
if (!in_array($r["recommendation"], ["overweight", "neutral", "underweight", "no_view"], true)) $r["recommendation"] = "no_view";
$r["conviction"] = strtolower(trim($r["conviction"] ?? ""));
foreach (["risks", "flag_responses", "checks"] as $k) $r[$k] = $r[$k] ?? [];
echo $r["assessment"], " ", $r["recommendation"], " ", $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 recommendation = Field("recommendation").Replace('-', '_').Replace(' ', '_');
if (!new[] { "overweight", "neutral", "underweight", "no_view" }.Contains(recommendation)) recommendation = "no_view";
var assessment = Field("assessment");
var conviction = Field("conviction");
Console.WriteLine($"{assessment} {recommendation} {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, spread read, relative read, scenario read, flip read, summary, each risk and its watch, flag responses, checks) must equal a figure in
factsexactly — re-rounding is a disagreement, so "72 bp" for "+72.1 bp" 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 ("20.3 bp inside the comps line"). Bare integers of 10 or less and calendar years are not counted as claims. - Names. Tenors and ISO dates are cut out of the number scan and checked as names. Tenors (
5Y,7-year) must be infacts.curves.govt_tenors,swap_tenorsorcredit_tenors(plus1Y, the breakeven horizon); dates must be the settlement, maturity or next coupon date, or the history's first or last date; and any ISO currency code the page knows must befacts.bond.currency. - Assessment.
assessmentequalsfacts.assessment; the model copies it and explains it, never decides it. A missing one is a disagreement too. - Recommendation. Present, and allowed by the assessment:
no_viewonly whenfacts.signal_trustisuntrustedorfacts.assessmentisn/a; otherwiseoverweightorneutraloncheap,underweightorneutralonrich, and onlyneutralonfair. - Conviction. Present;
noneexactly when the recommendation isno_view;highonly whenfacts.signals_availableis 2 or more,facts.signals_agreeisyesand no medium or high flag was raised. - 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://spread-desk.skillsafe.ai/recon.js
const result = Recon.normalize(Recon.parseResult(text));
const check = Recon.reconcile(result, JSON.parse(body.facts));
console.log(check.numbers_checked, "numbers and names checked,", check.disagreements, "disagreements");
console.log("flags answered:", check.coverage.flags_answered, "of", check.coverage.flags_total);
check.items.filter((i) => !i.ok).forEach((i) => console.log(i.kind, "-", i.text));
The output contract
{
"lane": "review",
"assessment": "cheap" | "fair" | "rich" | "n/a",
"recommendation": "overweight" | "neutral" | "underweight" | "no_view",
"conviction": "high" | "medium" | "low" | "none",
"headline": "one sentence: the bond, its G-spread, the primary signal's value with the assessment, and the recommendation",
"spread_read": "3 to 5 sentences: what the G-spread is made of (the credit curve spread and the residual with their shares, or that no credit curve was supplied), the I-spread and swap spread where given, and what the residual or its absence says about liquidity and technicals",
"relative_read": "2 to 4 sentences on the comps (the spread to the comps line, the slope and how tight the fit is) and the history (z-score, percentile, range and recent change), or a sentence for each saying it was not supplied, and whether the signals agree",
"scenario_read": "2 to 3 sentences on the rate shocks: the P&L at the largest shifts on the face amount, duration and convexity, and the one-year spread breakeven",
"flip_read": "1 to 2 sentences: how many bp of spread tightening or widening would change the primary call, quoting the flips, and whether the other signals would move with it",
"risks": [
{"risk": "what could go wrong", "severity": "high" | "medium" | "low", "watch": "the figure or event to watch"}
],
"flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this call and what to do about it"}],
"checks": ["something to verify before acting on the sheet"],
"summary": "two sentences: the assessment and the recommendation, and why"
}
| field | allowed values |
|---|---|
lane | review |
assessment | cheap, fair, rich, n/a — must equal facts.assessment |
recommendation | overweight, neutral, underweight, no_view — no_view required when signal_trust is untrusted or the assessment is n/a; cheap allows overweight or neutral, rich allows underweight or neutral, fair allows only neutral |
conviction | high, medium, low, none — none exactly for no_view; high only with 2 or more signals that agree and no medium or high flag |
risks[].severity | high, medium, low |
flag_responses[].code | each code in facts.flags, once, in the same order |
Recommendation and conviction. On a cheap or rich call the
review chooses neutral when the signals disagree, when the primary flip is small (a few
bp), or when a medium flag undercuts the primary signal, and says which. high conviction
needs two or more signals that agree and no medium or high flag; medium is used when the
primary signal is supported by at least one other signal or stands alone with no medium flag, and
low when more stands against it. A neutral on a fair assessment
carries the conviction that the bond is fairly priced. For no_view, spread_read
or summary says which input must be fixed or added first. The residual to the credit
curve weighs above the comps gap, and the comps gap above the history: a peer curve prices rates and
credit together, comps are a thinner sample, and a history says only where the spread sits against
itself.
risks has 3 to 5 entries, most important first; checks has 3 to 5;
flag_responses has exactly one entry per code in facts.flags, in the same
order (an empty array when there are no flags). Each risk, watch,
response and checks item is at most 60 words, headline is one
sentence and the reads keep to their sentence counts; empty arrays are [], never omitted.
When question is not empty, spread_read or summary answers it
directly. Spreads are written in bp, money as the facts write it ("EUR 2,348"), tenors
only as in facts.curves, the maturity as facts.bond.maturity or
years_to_maturity, and only the bond's currency is named. A spread is called wide or tight
against its past only when facts.history is supplied. The review is analysis, not advice:
it describes what a position would look like, never tells you to trade a size.
An illustrative excerpt of a reply for the Hanseatic 2031 example (the wording of a real run will differ; every figure is copied from the facts above):
{
"lane": "review",
"assessment": "rich",
"recommendation": "neutral",
"conviction": "low",
"headline": "Hanseatic Utilities 3.125% 2031 at a G-spread of +72.1 bp sits 20.3 bp inside its comps line, a rich assessment, but with the history fair the recommendation is neutral.",
"spread_read": "The whole +72.1 bp G-spread is unexplained by a peer curve, because no credit curve was supplied ... No swap curve was given either, so there is no I-spread or swap spread ...",
"relative_read": "Against four utilities the bond is -20.3 bp to a least-squares line at +92.4 bp, with a tight 1.4 bp scatter ... Its own history puts it at a z-score of -0.37 and the 39.5% percentile, fair, so the signals do not agree.",
"scenario_read": "A 100 bp fall in yield makes +EUR 241,742 on the face and a 100 bp rise loses -EUR 228,087 ... The spread could widen 15.9 bp over a year before the extra yield over government is used up.",
"flip_read": "10.3 bp of widening would take the comps call to fair, while 2.9 bp of tightening would turn the history rich as well.",
"risks": [{"risk": "The comps may carry a new-issue or supply discount this bond does not, so the gap closes by the comps tightening.", "severity": "medium", "watch": "The -20.3 bp spread to the comps line."}, ...],
"flag_responses": [],
"checks": ["Confirm the comps have the same seniority and currency as the bond.", ...],
"summary": "..."
}
The flag codes
Raised by spread.js (in flagsFor, in this order) and sent in facts.flags; the reply must answer each one. Any high-severity flag sets signal_trust to untrusted and forces the recommendation to no_view. A code can appear twice (curve_scale_suspect for both curves, curve_kink per point); it gets one response.
| code | severity | meaning |
|---|---|---|
price_scale_suspect | high | A clean price below 20 or above 250 when a price was given: a price per 1, or a yield in the price box, is far more likely. |
yield_scale_suspect | high | A yield above 40% or below -2%, or a yield typed under 0.3 that sits more than 100 bp under the curve (a decimal such as 0.0515 for 5.15%): check the quote and the coupon. |
coupon_scale_suspect | high | A coupon under 0.3% with the bond more than 100 bp under the government curve: the coupon looks like a decimal (0.0525 for 5.25%). A real low-coupon bond priced at its yield does not raise it. |
curve_scale_suspect | high | The government or swap curve has a point above 25 (basis points or prices were pasted where percent yields belong), or the whole government curve is under 0.3 while the bond is more than 100 bp above it (decimals). |
credit_curve_scale_suspect | high | Every credit curve point, read as a yield, is above the government yield at its tenor, the residual is beyond 100 bp, and reading the curve as yields would explain the bond better: peer yields were pasted where spreads over government belong. |
spread_scale_suspect | high | |G-spread| beyond 3000 bp: the price, the curve or the currency is probably wrong. |
history_mismatch | high | The last history spread is more than 30 bp from today's G-spread: the history is probably another measure (Z, OAS, ASW) or another bond. |
negative_g_spread | medium | The bond yields less than the government curve at its maturity: fine for a supranational or agency, otherwise check the curve and the currency. |
maturity_outside_curve | medium | The bond's maturity is outside the government curve, which is held flat, so the G-spread leans on the nearest point. |
curve_kink | medium | A government curve point sits more than 40 bp off the line through its neighbours. |
comps_dispersion_wide | medium | The comps scatter more than 25 bp (RMS) around their line: the comps gap is a weak signal. |
signals_conflict | medium | One signal calls the bond cheap and another rich. |
no_relative_signal | medium | No credit curve, comps or history: nothing to call the bond rich or cheap against, and the assessment is n/a. |
credit_curve_extrapolated | low | The credit curve does not reach the bond's maturity and is held flat. |
swap_curve_extrapolated | low | The swap curve does not reach the bond's maturity and is held flat. |
few_comps | low | Fewer than three comps: the fit is an average or a line through two points. |
comps_maturity_gap | low | The bond's maturity is outside the comps' maturity range; the fit is extrapolated. |
short_history | low | Fewer than 20 history observations: the z-score and percentile are a sketch. |
history_undated | low | The spread history has no dates; it is read oldest first. |
sparse_govt_curve | low | Fewer than 4 government curve points. |
long_duration | low | Modified duration above 12: convexity matters, so read the full-revaluation column, not the estimate. |
deep_discount_or_premium | low | A clean price below 80 or above 120: spreads of par-priced comps are not like-for-like. |
input_truncated | low | A page limit cut the paste: more than 40 points on a curve (the shortest kept), more than 25 comps (the first kept) or more than 800 history observations (the latest kept). The detail names how many of how many were used. |
short_maturity | low | Under a year to maturity: small price moves swing the spread. |
The signals
spread.js computes up to three rich/cheap signals, in order of preference, and the first
available is primary_signal, whose call is the assessment:
| id | needs | value | cheap from / rich from |
|---|---|---|---|
residual | a credit curve | G-spread minus the credit curve spread at the maturity, in bp | +10 bp / -10 bp |
comps | at least one comp | G-spread minus the comps line at the maturity, in bp | +10 bp / -10 bp |
history | two or more history observations with a non-zero sd | the z-score of today's G-spread against the history | +1.00 / -1.00 |
Positive always means cheap (the bond pays more than its reference). Each signal's flips
give the spread move, in bp of the bond's own spread with the curves held, that takes it to the edge of
the next call: one flip (to fair) for a cheap or rich call, two (to cheap by
widening and to rich by tightening) for a fair one. A spread move shifts every signal at
once, which is what flip_read weighs.
The rules
The engine's thresholds are Spread.RULES, listed below. facts.rules sends the same thresholds as strings that carry their unit (for example "residual_cheap_from": "+10 bp", "z_rich_from": "-1.00", "curve_kink_above": "40 bp"), plus the scenario shifts, so the model can quote a threshold exactly as the page checks it:
| key | value | meaning |
|---|---|---|
residual_bp | 10 | Residual to the credit curve at or beyond ± this: cheap / rich. |
comps_bp | 10 | Spread to the comps line at or beyond ± this: cheap / rich. |
z_band | 1.0 | History z-score at or beyond ± this: cheap / rich. |
kink_bp | 40 | A government curve point off the line through its neighbours by more than this: curve_kink. |
comps_rms_bp | 25 | Comps RMS scatter above this: comps_dispersion_wide. |
history_gap_bp | 30 | Last history spread further than this from today's G-spread: history_mismatch. |
spread_max_bp | 3000 | |G-spread| above this: spread_scale_suspect. |
min_history | 20 | Fewer observations than this: short_history. |
min_govt | 4 | Fewer government curve points than this: sparse_govt_curve. |
long_duration | 12 | Modified duration above this: long_duration. |
deep_price_lo, deep_price_hi | 80, 120 | Clean price outside this range: deep_discount_or_premium. |
8. Use it in a daily relative value monitor
The recommendation is built to gate on, once the reply has passed the checks above. Run
the review once a day after the close: rebuild bond.json from end-of-day prices, the
government curve, the peer curve, the comps and the spread history, send it, reconcile the reply, and
append one line per day to a log. A no_view means the sheet cannot be trusted yet (any
high-severity flag forces it) or has nothing to call the bond against; neutral means the
sheet supports no tilt strongly enough; an overweight or underweight is worth
a human look, with the risks and checks kept next to the sheet. A change of assessment,
recommendation or conviction from the day before is worth a look as well.
#!/bin/sh
# Daily, after the close: rebuild the sheet from today's bond.json, run the review,
# append one line to spread-monitor.log, exit 3 when the recommendation is a tilt.
set -e
node make-body.js bond.json "What changed in the spread today, and does the relative value call still hold?" > body.json
INPUT=$(cat body.json)
KEY="spread-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
LINE=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];r=json.loads(t[t.index("{"):t.rindex("}")+1]);print(r.get("assessment",""),r.get("conviction",""),r.get("recommendation") or "no_view")')
echo "$(date +%F) $LINE" >> spread-monitor.log
echo "today: $LINE"
case "$LINE" in *overweight|*underweight) exit 3 ;; *) exit 0 ;; esac
import datetime
facts = json.loads(INPUT["facts"])
A, untrusted = facts["assessment"], facts["signal_trust"] == "untrusted"
allowed = (["no_view"] if untrusted or A == "n/a" else
["overweight", "neutral"] if A == "cheap" else
["underweight", "neutral"] if A == "rich" else ["neutral"])
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"] != A: problems.append("assessment")
if not r["recommendation_given"] or r["recommendation"] not in allowed: problems.append("recommendation")
if (r["recommendation"] == "no_view") != (r["conviction"] == "none"): problems.append("conviction")
if r["conviction"] == "high" and (facts["signals_available"] < 2 or facts["signals_agree"] != "yes" 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("spread-monitor.jsonl", "a") as log:
log.write(json.dumps({"date": datetime.date.today().isoformat(), "assessment": r["assessment"],
"recommendation": r["recommendation"], "conviction": r["conviction"],
"g_spread": facts["spreads"]["g_spread"], "primary_signal": facts["primary_signal"]}) + "\n")
print("recommendation:", r["recommendation"])
raise SystemExit(3 if r["recommendation"] in ("overweight", "underweight") else 0)
import { appendFileSync } from "node:fs";
const facts = JSON.parse(INPUT.facts);
const A = facts.assessment, untrusted = facts.signal_trust === "untrusted";
const allowed = untrusted || A === "n/a" ? ["no_view"] : A === "cheap" ? ["overweight", "neutral"] : A === "rich" ? ["underweight", "neutral"] : ["neutral"];
if (r.assessment !== A) throw new Error("reply disagrees with the sheet");
if (!r.recommendation_given || !allowed.includes(r.recommendation)) throw new Error("recommendation does not fit the assessment");
if ((r.recommendation === "no_view") !== (r.conviction === "none")) throw new Error("conviction does not match the recommendation");
appendFileSync("spread-monitor.jsonl", JSON.stringify({ date: new Date().toISOString().slice(0, 10), assessment: r.assessment, recommendation: r.recommendation, conviction: r.conviction, g_spread: facts.spreads.g_spread }) + "\n");
console.log("recommendation:", r.recommendation);
process.exitCode = ["overweight", "underweight"].includes(r.recommendation) ? 3 : 0;
var facts struct {
Assessment string `json:"assessment"`
SignalTrust string `json:"signal_trust"`
Spreads struct {
GSpread string `json:"g_spread"`
} `json:"spreads"`
}
_ = json.Unmarshal([]byte(input["facts"].(string)), &facts)
if r.Assessment != facts.Assessment {
panic("reply disagrees with the sheet")
}
mustNoView := facts.SignalTrust == "untrusted" || facts.Assessment == "n/a"
if mustNoView != (r.Recommendation == "no_view") {
panic("recommendation does not fit the sheet")
}
if (facts.Assessment == "cheap" && r.Recommendation == "underweight") ||
(facts.Assessment == "rich" && r.Recommendation == "overweight") ||
(facts.Assessment == "fair" && r.Recommendation != "neutral") {
panic("recommendation does not fit the assessment")
}
if (r.Recommendation == "no_view") != (r.Conviction == "none") {
panic("conviction does not match the recommendation")
}
log, _ := os.OpenFile("spread-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.Conviction, r.Recommendation, facts.Spreads.GSpread)
log.Close()
if r.Recommendation == "overweight" || r.Recommendation == "underweight" {
fmt.Println("recommendation:", r.Recommendation)
os.Exit(3)
}
var om = new com.fasterxml.jackson.databind.ObjectMapper();
var facts = om.readTree(om.readTree(input).get("facts").asText());
String A = facts.get("assessment").asText();
if (!assessment.equals(A)) throw new IllegalStateException("reply disagrees with the sheet");
boolean mustNoView = "untrusted".equals(facts.get("signal_trust").asText()) || "n/a".equals(A);
if (mustNoView != "no_view".equals(recommendation))
throw new IllegalStateException("recommendation does not fit the sheet");
if ("no_view".equals(recommendation) != "none".equals(conviction))
throw new IllegalStateException("conviction does not match the recommendation");
java.nio.file.Files.writeString(java.nio.file.Path.of("spread-monitor.log"),
java.time.LocalDate.now() + " " + assessment + " " + conviction + " " + recommendation + "\n",
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
if (recommendation.equals("overweight") || recommendation.equals("underweight")) {
System.out.println("recommendation: " + recommendation); System.exit(3);
}
require "date"
facts = JSON.parse(INPUT["facts"])
a = facts["assessment"]
allowed = if facts["signal_trust"] == "untrusted" || a == "n/a" then %w[no_view]
elsif a == "cheap" then %w[overweight neutral]
elsif a == "rich" then %w[underweight neutral]
else %w[neutral] end
raise "reply disagrees with the sheet" unless r["assessment"] == a
raise "recommendation does not fit the assessment" unless allowed.include?(r["recommendation"])
raise "conviction does not match the recommendation" if (r["recommendation"] == "no_view") != (r["conviction"] == "none")
File.open("spread-monitor.log", "a") { |f| f.puts "#{Date.today} #{r['assessment']} #{r['conviction']} #{r['recommendation']}" }
puts "recommendation: #{r['recommendation']}"
exit(%w[overweight underweight].include?(r["recommendation"]) ? 3 : 0)
<?php
$facts = json_decode($input["facts"], true);
$a = $facts["assessment"];
$allowed = ($facts["signal_trust"] === "untrusted" || $a === "n/a") ? ["no_view"]
: ($a === "cheap" ? ["overweight", "neutral"] : ($a === "rich" ? ["underweight", "neutral"] : ["neutral"]));
if ($r["assessment"] !== $a) throw new RuntimeException("reply disagrees with the sheet");
if (!in_array($r["recommendation"], $allowed, true)) throw new RuntimeException("recommendation does not fit the assessment");
if (($r["recommendation"] === "no_view") !== ($r["conviction"] === "none")) throw new RuntimeException("conviction does not match the recommendation");
file_put_contents("spread-monitor.log", date("Y-m-d") . " {$r["assessment"]} {$r["conviction"]} {$r["recommendation"]}\n", FILE_APPEND);
echo "recommendation: ", $r["recommendation"], PHP_EOL;
exit(in_array($r["recommendation"], ["overweight", "underweight"], true) ? 3 : 0);
var facts = JsonSerializer.Deserialize<JsonElement>(input.GetProperty("facts").GetString()!);
var a = facts.GetProperty("assessment").GetString();
var allowed = facts.GetProperty("signal_trust").GetString() == "untrusted" || a == "n/a" ? new[] { "no_view" }
: a == "cheap" ? new[] { "overweight", "neutral" }
: a == "rich" ? new[] { "underweight", "neutral" } : new[] { "neutral" };
if (assessment != a) throw new Exception("reply disagrees with the sheet");
if (!allowed.Contains(recommendation)) throw new Exception("recommendation does not fit the assessment");
if ((recommendation == "no_view") != (conviction == "none"))
throw new Exception("conviction does not match the recommendation");
File.AppendAllText("spread-monitor.log", $"{DateTime.Today:yyyy-MM-dd} {assessment} {conviction} {recommendation}\n");
Console.WriteLine($"recommendation: {recommendation}");
return recommendation is "overweight" or "underweight" ? 3 : 0;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true. What
you hold then is a prefix of the reply: the assessment, recommendation, conviction, headline and spread
read may be complete while the risks, flag responses, checks and summary are missing. The web page
closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how
many of the nine (headline, spread read, relative read, scenario read, flip read, risks, flag
responses, checks, summary) it recovered; it does the same when a stream ends early. From code, check
the flag before you treat a reply as complete — a truncated reply will usually fail the
one-response-per-flag check — then top up, resubmit and increment the attempt suffix on the
Idempotency-Key.