Bond Desk — API

Price the book and write the review from your risk system, your reporting job or a nightly cron.

API tokens Open the app

Drive Bond Desk from your own code

The app is a thin client over a public REST API. Everything the page does — estimate a run, submit one, stream it, and keep the review on your account — is available to you directly. Base URL https://api.skillsafe.ai/v1/app-api. Every response is the same envelope: {"ok":true,"data":{...}} or {"ok":false,"error":{"code":"...","message":"..."}}.

What the model is and is not asked to do

This matters before you write a line of code, because it decides what you have to send. The measurement is not the model's job. The parsing, the schedule generation, the accrued interest, the yield solve, duration, convexity, DV01, the aggregation and its invariants, the waterfall, every scenario repricing and the removal simulation all happen deterministically — in the browser in the app, and in your code if you are driving the API. What you send is that measurement, in facts, and what comes back is one theme and one note per holding, a positioning paragraph, a scenario note and three to six actions.

The binding is two-way. facts.admissible_themes[holding_id] lists the only themes the model may choose for that holding, and it is computed from the numbers: duration_driver, for instance, is admissible only where the holding's DV01 share exceeds its market value share. After the run you check the reply back against the same object — step 8 — and a theme that was not on the list is an invention, not a judgement call.

One consequence worth internalising: an empty admissible list is a real answer. A holding whose numbers clear none of the thresholds gets "theme": "none", and a reply that reaches for a plausible theme anyway fails the check.

POST /guest GET /me POST /estimate POST /run POST /run-stream GET /jobs/{job_id} POST /collections/books/records POST /collections/books/query POST /collections/books/similar

Errors

Every failure is {"ok":false,"error":{"code":"...","message":"...","details":{...}}}. Branch on code, never on the message.

CodeHTTPWhat it means
UNAUTHORIZED401No token, an expired one, or a token minted for a different app. Mint a guest token or sign in on the token page.
INSUFFICIENT_CREDITS402The balance is below min_credits. /estimate is free and tells you this before you submit, so a 402 after submit means the preflight was skipped.
FORBIDDEN403The token is valid but not permitted here — most often a guest token on an app whose owner has not enabled sponsorship.
NOT_FOUND404Unknown job id, unknown record id, or a collection this release does not declare.
VALIDATION_ERROR400The body is not the shape the app expects. error.details.violations names the offending field. A run input over 1 MB of JSON lands here — clip the excerpt, never the facts.
RATE_LIMITED429Too many requests. /collections/{name}/similar is the tightest at 30 per minute per IP — debounce it and prefer a where filter when an exact match will do.
JOB_FAILED502The model call failed upstream. Retry with the SAME Idempotency-Key: the platform replays rather than re-billing.

1. A tiny client

One helper covers every call. Keep the token out of source control — the token page will copy a ready-made shell export for you.

# Every call is one POST to the same host. Keep the token in a shell
# variable; never commit it. Get one from /tokens.html, or step 2.
API=https://api.skillsafe.ai/v1/app-api
SKILLSAFE_TOKEN="YOUR_TOKEN"

call() {           # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$API$1" \
      -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
  fi
}
import json, urllib.request

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"          # or read it from your own secret store

def call(path, body=None, method=None, token=TOKEN):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(API + path, data=data,
                                 method=method or ("POST" if data else "GET"))
    req.add_header("Authorization", "Bearer " + token)
    if data:
        req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        payload = json.load(r)
    if not payload.get("ok"):
        raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
    return payload["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN";     // or read it from your own secret store

async function call(path, body, method) {
  const res = await fetch(API + path, {
    method: 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 (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"net/http"
)

const api = "https://api.skillsafe.ai/v1/app-api"

var token = "YOUR_TOKEN" // or read it from your own secret store

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, method string) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
		if method == "" {
			method = "POST"
		}
	}
	if method == "" {
		method = "GET"
	}
	req, _ := http.NewRequest(method, api+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 e envelope
	if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
		return nil, err
	}
	if !e.OK {
		return nil, errors.New(e.Error.Code + ": " + e.Error.Message)
	}
	return e.Data, nil
}
import java.net.URI;
import java.net.http.*;

public class BondDesk {
  static final String API = "https://api.skillsafe.ai/v1/app-api";
  static String token = "YOUR_TOKEN";   // or read it from your own secret store
  static final HttpClient HTTP = HttpClient.newHttpClient();

  static String call(String path, String jsonBody) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Authorization", "Bearer " + token);
    if (jsonBody == null) {
      b.GET();
    } else {
      b.header("Content-Type", "application/json")
       .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
    }
    HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (r.body().contains("\"ok\":false")) throw new RuntimeException(r.body());
    return r.body();   // parse with your JSON library of choice
  }
}
require "json"
require "net/http"

API = URI("https://api.skillsafe.ai/v1/app-api")
TOKEN = "YOUR_TOKEN"          # or read it from your own secret store

def call(path, body = nil, method = nil)
  uri = URI(API.to_s + path)
  req = if (method || (body ? "POST" : "GET")) == "POST"
          Net::HTTP::Post.new(uri)
        else
          Net::HTTP::Get.new(uri)
        end
  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 API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN";        // or read it from your own secret store

function call(string $path, $body = null, ?string $method = null) {
  global $TOKEN;
  $ch = curl_init(API . $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));
  }
  if ($method !== null) curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
  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 Exception($payload["error"]["code"] . ": " . $payload["error"]["message"]);
  }
  return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class BondDesk {
  const string Api = "https://api.skillsafe.ai/v1/app-api";
  static string Token = "YOUR_TOKEN";   // or read it from your own secret store
  static readonly HttpClient Http = new();

  public static async Task<JsonElement> Call(string path, object? body = null, HttpMethod? method = null) {
    var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Api + path);
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    if (body is not null)
      req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    var res = await Http.SendAsync(req);
    using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    var root = doc.RootElement.Clone();
    if (!root.GetProperty("ok").GetBoolean())
      throw new Exception(root.GetProperty("error").GetProperty("code").GetString());
    return root.GetProperty("data").Clone();
  }
}

2. Get a token

A guest token is enough for /me and the free /estimate. Metered runs need a personal token, which the token page issues after sign-in. The slug goes in the request body — an X-App-Slug header returns 400.

# A guest token. The slug goes in the BODY - an X-App-Slug header 400s.
curl -sS -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"bond-desk"}'
# -> {"ok":true,"data":{"token":"sk_app_...","subject_type":"guest",...}}
tok = call("/guest", {"slug": "bond-desk"}, token="")["token"]
print(tok[:8] + "...")
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "bond-desk" }),
})).json();
TOKEN = guest.data.token;
raw, err := call("/guest", map[string]string{"slug": "bond-desk"}, "POST")
if err != nil {
	panic(err)
}
var g struct {
	Token string `json:"token"`
}
json.Unmarshal(raw, &g)
token = g.Token
String guest = call("/guest", "{\"slug\":\"bond-desk\"}");
// pull data.token out of the envelope and assign it to `token`
token = call("/guest", { "slug" => "bond-desk" })["token"]
$guest = call("/guest", ["slug" => "bond-desk"]);
$TOKEN = $guest["token"];
var guest = await BondDesk.Call("/guest", new { slug = "bond-desk" });
// guest.GetProperty("token").GetString()

3. Check the session

GET /me returns subject_type (user or guest), subject_id and credits. Compare that balance against min_credits from step 5 before you submit anything.

curl -sS "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
me = call("/me")
print(me["subject_type"], me["credits"])
const me = await call("/me");
console.log(me.subject_type, me.credits);
raw, _ := call("/me", nil, "GET")
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int    `json:"credits"`
}
json.Unmarshal(raw, &me)
String me = call("/me", null);
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await BondDesk.Call("/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());

4. Build the input

The run input is two keys, plus a third the app sends only on an automatic re-ask. Everything numeric lives in facts; holdings_excerpt is the pasted table itself, clipped from the middle on whole-row boundaries with the header kept, so the model can see the raw rows behind the numbers.

{
  "facts": { ... the whole measurement, exactly as the engine produced it ... },
  "holdings_excerpt": "cusip,issuer,sector,rating,ccy,coupon,maturity,par,clean_price\n912828ZT,...",
  "retry_note": "optional - see below"
}

retry_note, and why it must reuse the idempotency key

When a reply cannot be parsed as a single JSON object, the app re-asks once with a retry_note added — a short instruction to reply with the JSON object only. The system prompt defines the field, so the model is never handed a key it has not been told about. Two things matter if you drive this yourself:

The facts object in full

Field for field, this is what the app sends. Anything you omit the model simply cannot use — and commentary_ids, admissible_themes and excluded_ids are what make the reply checkable, so they are not optional in practice.

{
  "ok": true,
  "measurable": true,                 // false when no row could be priced at all
  "portfolio": "Core IG credit / USD / May 2026",
  "settlement": "2026-05-15",         // every measurement is taken from here
  "default_frequency": 2,
  "default_basis": "30/360",
  "benchmark_duration": 6.2,          // or null
  "rows_read": 8,
  "priced_count": 8,
  "excluded_count": 0,
  "excluded_ids": [],                 // must each be named in output.unverified
  "totals": {
    "par": 17900000.0,
    "market_value": 17978609.03,      // dirty: clean value plus accrued
    "clean_value": 17836483.0,
    "accrued_value": 142126.03,
    "dv01": 7920.71,
    "carry_next_12m": 804050.0
  },
  "weighted": {                       // market-value weighted throughout
    "ytw": 5.1415, "modified": 4.4056, "convexity": 30.31,
    "years": 7.62, "coupon": 4.7461
  },
  "active_duration": -1.7944,         // weighted.modified minus benchmark, or null
  "independence_note": "Modified duration, Macaulay duration and DV01 are one measurement written three ways ...",
  "holdings": [{
    "id": "H1", "identifier": "912828ZT", "issuer": "US Treasury 2.875 2029",
    "sector": "Government", "rating": "AAA", "currency": "USD",
    "coupon": 2.875, "maturity": "2029-05-15", "years_to_maturity": 2.9993,
    "bucket": "1-3y", "frequency": 2, "basis": "ACT/ACT",
    "par": 4000000.0, "clean_price": 95.42, "accrued": 0.0, "accrued_days": 0,
    "dirty_price": 95.42, "market_value": 3816800.0, "accrued_value": 0.0,
    "mv_share": 21.23,
    "ytm": 4.6234, "ytc": null, "ytw": 4.6234, "worst_to": "maturity",
    "ytm_refused": null,              // the REASON, where no yield reproduces the price
    "macaulay": 2.8613, "modified": 2.7967, "convexity": 9.83,
    "dv01": 1067.36, "dv01_share": 13.48,
    "call_date": null, "call_price": null
  }],
  "excluded_holdings": [{ "id": "H4", "identifier": "...", "issuer": "...", "reason": "no price could be read" }],
  "composition": {
    "sector":   [{ "name": "Government", "market_value": 3816800.0, "dv01": 1067.36, "ids": ["H1"], "share_pct": 21.23 }],
    "rating":   [ ... ], "maturity": [ ... ], "currency": [ ... ]
  },
  "waterfall": [{ "label": "Q1", "from": "2026-05-15", "to": "2026-08-15",
                  "coupon": 129062.5, "principal": 0.0, "total": 129062.5,
                  "contributors": { "H1": 57500.0 } }],
  "waterfall_window_end": "2028-05-15",
  "scenarios": [{
    "id": "Pp100", "label": "+100 bp parallel", "kind": "parallel", "bp": 100,
    "pnl": -772431.55,                // FULL repricing, not an approximation
    "pnl_pct": -4.2963,
    "approx_pnl": -770597.2,          // duration and convexity together
    "approx_error": -1834.35,
    "approx_error_bp_of_mv": -1.02,   // how far the shortcut is out
    "per_holding": [{ "id": "H1", "bp": 100, "full": -106279.0, "approx": -106171.0, "gap": -108.0 }]
  }],
  "decisive": {
    "scenario": "+100 bp parallel", "base_pct": -4.2963,
    "ranked": [{ "id": "H8", "issuer": "Alder Utilities", "mv_share": 14.34,
                 "dv01_share": 6.87, "modified": 1.16,
                 "portfolio_pct_without": -4.8164, "swing": 0.5201 }],
    "driver_id": "H8", "driver_issuer": "Alder Utilities", "driver_mv_share": 14.34,
    "largest_mv_id": "H1", "driver_is_largest": false, "reason": null
  },
  "break_even": { "bp": 106.6, "carry": 804050.0, "reason": null },
  "integrity": [{ "check": "stated_par_total_does_not_foot", "stated": 4000000.0,
                  "measured": 3500000.0, "gap": 500000.0, "detail": ["..."] }],
  "data_notes": ["the coupon column tops out at 0.0425, so it was read as a decimal fraction ..."],
  "table": { "rows": 8, "columns": 11, "delimiter": ",", "headerless": false,
             "roles": [{ "role": "coupon", "column": "coupon", "index": 5 }],
             "date_votes": { "dayFirst": false, "votes_day_first": 0, "votes_month_first": 0 } },
  "commentary_ids": ["H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8"],
  "admissible_themes": { "H1": ["concentration"], "H2": [], "H3": ["duration_driver"] },
  "portfolio_themes": ["scenario_driver", "cash_lumpiness"],
  "theme_reasons": { "H3": ["duration_driver: it carries 18.42% of the portfolio's DV01 on 16.70% of its market value"] }
}
Refusals are values. ytm is null with a reason in ytm_refused where no yield reproduces the supplied price; break_even.bp is null with a reason where the book carries no positive coupon income over the next twelve months, or where a year of carry survives a 1000 bp move. Send the refusal through as it stands. Filling it in with something plausible is the one thing this contract exists to prevent.

5. Estimate first — it is free

/estimate creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits (a worst-case reservation, priced at the full output cap) and min_credits. Only what a run actually uses is charged, so charged_credits is usually far below the hold.

call /estimate "$(cat input.json)"
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#     "markup_bps":1000,"hold_credits":...,"min_credits":...}}
# Free: no job is created and nothing is charged.
est = call("/estimate", run_input)
print(est["model"], est["hold_credits"], est["min_credits"])
if me["credits"] < est["min_credits"]:
    raise SystemExit("top up first - a 402 after submit is a preflight you skipped")
const est = await call("/estimate", runInput);
if (me.credits < est.min_credits) throw new Error("balance below min_credits");
console.log("reserves", est.hold_credits, "on", est.model);
raw, _ = call("/estimate", runInput, "POST")
var est struct {
	Model       string `json:"model"`
	HoldCredits int    `json:"hold_credits"`
	MinCredits  int    `json:"min_credits"`
}
json.Unmarshal(raw, &est)
String est = call("/estimate", inputJson);
// compare data.min_credits against the balance from /me before running
est = call("/estimate", run_input)
abort "top up first" if me["credits"] < est["min_credits"]
$est = call("/estimate", $runInput);
if ($me["credits"] < $est["min_credits"]) { throw new Exception("top up first"); }
var est = await BondDesk.Call("/estimate", runInput);
var hold = est.GetProperty("hold_credits").GetInt32();

6. Run it

Send an Idempotency-Key on every run: a content hash of the input plus one nonce per user gesture. A reformat retry after a malformed reply must reuse the same key, or a bad first answer costs twice.

# Idempotency-Key is a content hash of the input plus one nonce per
# gesture. Reuse it for a retry and the platform replays the job it
# already has instead of billing a second one.
KEY="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open("input.json","rb").read()).hexdigest()[:32])')-1"
curl -sS -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json

# The reply is a job. Poll until it is terminal.
curl -sS "$API/jobs/JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, json, time

key = hashlib.sha256(json.dumps(run_input, sort_keys=True).encode()).hexdigest()[:32] + "-1"
job = call("/run", run_input)          # add the Idempotency-Key header in your own request builder
while job["status"] not in ("succeeded", "failed"):
    time.sleep(1.0)
    job = call("/jobs/" + job["job_id"], method="GET")
result = json.loads(job["output_text"])
const key = (await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(runInput))))
  ? "run-" + Date.now().toString(36) : "run";
let job = await call("/run", runInput);          // send Idempotency-Key: key
while (job.status !== "succeeded" && job.status !== "failed") {
  await new Promise((r) => setTimeout(r, 1000));
  job = await call("/jobs/" + job.job_id);
}
const result = JSON.parse(job.output_text);
raw, _ = call("/run", runInput, "POST")
var job struct {
	JobID      string `json:"job_id"`
	Status     string `json:"status"`
	OutputText string `json:"output_text"`
}
json.Unmarshal(raw, &job)
for job.Status != "succeeded" && job.Status != "failed" {
	time.Sleep(time.Second)
	raw, _ = call("/jobs/"+job.JobID, nil, "GET")
	json.Unmarshal(raw, &job)
}
String job = call("/run", inputJson);
// read data.job_id, then poll GET /jobs/{job_id} until status is
// "succeeded" or "failed"; the reply text is data.output_text
job = call("/run", run_input)
until %w[succeeded failed].include?(job["status"])
  sleep 1
  job = call("/jobs/#{job['job_id']}")
end
result = JSON.parse(job["output_text"])
$job = call("/run", $runInput);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
  sleep(1);
  $job = call("/jobs/" . $job["job_id"]);
}
$result = json_decode($job["output_text"], true);
var job = await BondDesk.Call("/run", runInput);
var jobId = job.GetProperty("job_id").GetString();
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
  await Task.Delay(1000);
  job = await BondDesk.Call("/jobs/" + jobId);
}

7. Or stream it

Same body, same key. The frame name arrives on the event: line and the payload on the following data: line — parsing only data: and guessing the type is the usual bug. A stream that ends early is still worth rendering: trim the buffer back to the last complete member, close it, and show what arrived rather than discarding it.

curl -N -sS -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json
# The frame NAME arrives on the "event:" line, the payload on "data:".
# event: delta   -> {"text":"..."}      append it
# event: done    -> the terminal job object
# event: error   -> {"code":"...","message":"..."}
import json, urllib.request

req = urllib.request.Request(API + "/run-stream",
                             data=json.dumps(run_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)

buf, event = "", None
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            payload = json.loads(line[5:].strip())
            if event == "delta":
                buf += payload.get("text", "")
            elif event == "done":
                job = payload
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + TOKEN,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(runInput),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", pending = "", event = null;
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  pending += dec.decode(value, { stream: true });
  const lines = pending.split("\n");
  pending = lines.pop();
  for (const line of lines) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const payload = JSON.parse(line.slice(5).trim());
      if (event === "delta") buf += payload.text || "";
    }
  }
}
req, _ := http.NewRequest("POST", api+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:") && event == "delta":
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d)
		buf += d.Text
	}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(inputJson))
    .build();
HttpResponse<java.util.stream.Stream<String>> res =
    HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
final String[] event = { null };
res.body().forEach(line -> {
  if (line.startsWith("event:")) event[0] = line.substring(6).trim();
  else if (line.startsWith("data:") && "delta".equals(event[0]))
    buf.append(line.substring(5).trim());   // then pull .text with your JSON library
});
uri = URI("#{API}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(run_input)

buf = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.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[6..].strip
        elsif line.start_with?("data:") && event == "delta"
          buf << (JSON.parse(line[5..].strip)["text"] || "")
        end
      end
    end
  end
end
$buf = "";
$event = null;
$ch = curl_init(API . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runInput));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
  "Authorization: Bearer {$TOKEN}",
  "Content-Type: application/json",
  "Idempotency-Key: {$key}",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$buf, &$event) {
  foreach (explode("\n", $chunk) as $line) {
    if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); }
    elseif (str_starts_with($line, "data:") && $event === "delta") {
      $d = json_decode(trim(substr($line, 5)), true);
      $buf .= $d["text"] ?? "";
    }
  }
  return strlen($chunk);
});
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(runInput), Encoding.UTF8, "application/json");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
string? evt = null;
while (await sr.ReadLineAsync() is { } line) {
  if (line.StartsWith("event:")) evt = line[6..].Trim();
  else if (line.StartsWith("data:") && evt == "delta") {
    using var d = JsonDocument.Parse(line[5..].Trim());
    buf.Append(d.RootElement.GetProperty("text").GetString());
  }
}

8. Check the reply before you use it

The reply is only usable once it has been checked back against facts. These are the checks the app itself runs, and they are worth copying because they catch the two failure modes that matter: a holding quietly skipped, and a theme the numbers never supported.

# The reply is only usable once it has been checked against facts.
# Three checks carry almost all the weight:
#   1. exactly one entry per id in facts.commentary_ids
#   2. every theme is in facts.admissible_themes[<that id>]
#   3. every id in facts.excluded_ids is named in unverified
python3 - "$@" <<'PY'
import json
facts = json.load(open("input.json"))["facts"]
out = json.load(open("output.json"))
ids = [h["holding_id"] for h in out["holdings"]]
missing = [i for i in facts["commentary_ids"] if i not in ids]
dupes = sorted({i for i in ids if ids.count(i) > 1})
alien = [i for i in ids if i not in facts["commentary_ids"]]
bad = [h["holding_id"] for h in out["holdings"]
       if h["theme"] not in (facts["admissible_themes"].get(h["holding_id"]) or ["none"])]
print("missing", missing, "dupes", dupes, "alien", alien, "inadmissible", bad)
PY
def check(facts, out):
    problems = []
    ids = [h["holding_id"] for h in out["holdings"]]
    for want in facts["commentary_ids"]:
        n = ids.count(want)
        if n == 0:
            problems.append("missing " + want)
        elif n > 1:
            problems.append("duplicated " + want)
    for got in ids:
        if got in facts["excluded_ids"]:
            problems.append("commented on an unpriceable holding: " + got)
        elif got not in facts["commentary_ids"]:
            problems.append("not a holding: " + got)
    for h in out["holdings"]:
        allowed = facts["admissible_themes"].get(h["holding_id"], [])
        if not allowed:
            if h["theme"] != "none":
                problems.append("nothing admissible for " + h["holding_id"])
        elif h["theme"] not in allowed:
            problems.append(h["holding_id"] + " -> " + h["theme"] + " is not permitted")
    for gone in facts["excluded_ids"]:
        if not any(gone in u for u in out["unverified"]):
            problems.append("unpriceable " + gone + " is never named")
    return problems
function check(facts, out) {
  const problems = [];
  const ids = out.holdings.map((h) => h.holding_id);
  for (const want of facts.commentary_ids) {
    const n = ids.filter((i) => i === want).length;
    if (n === 0) problems.push("missing " + want);
    else if (n > 1) problems.push("duplicated " + want);
  }
  for (const got of ids) {
    if (facts.excluded_ids.includes(got)) problems.push("commented on unpriceable " + got);
    else if (!facts.commentary_ids.includes(got)) problems.push("not a holding: " + got);
  }
  for (const h of out.holdings) {
    const allowed = facts.admissible_themes[h.holding_id] || [];
    if (allowed.length === 0) {
      if (h.theme !== "none") problems.push("nothing admissible for " + h.holding_id);
    } else if (!allowed.includes(h.theme)) {
      problems.push(h.holding_id + " -> " + h.theme + " is not permitted");
    }
  }
  for (const gone of facts.excluded_ids) {
    if (!out.unverified.some((u) => u.includes(gone))) problems.push("unpriceable " + gone + " is never named");
  }
  return problems;
}
func check(facts Facts, out Output) []string {
	var problems []string
	count := map[string]int{}
	for _, h := range out.Holdings {
		count[h.HoldingID]++
	}
	for _, want := range facts.CommentaryIDs {
		switch count[want] {
		case 0:
			problems = append(problems, "missing "+want)
		case 1:
		default:
			problems = append(problems, "duplicated "+want)
		}
	}
	for _, h := range out.Holdings {
		allowed := facts.AdmissibleThemes[h.HoldingID]
		if len(allowed) == 0 {
			if h.Theme != "none" {
				problems = append(problems, "nothing admissible for "+h.HoldingID)
			}
			continue
		}
		if !slices.Contains(allowed, h.Theme) {
			problems = append(problems, h.HoldingID+" -> "+h.Theme+" is not permitted")
		}
	}
	return problems
}
List<String> problems = new ArrayList<>();
Map<String, Long> counts = out.holdings.stream()
    .collect(Collectors.groupingBy(h -> h.holdingId, Collectors.counting()));
for (String want : facts.commentaryIds) {
  long n = counts.getOrDefault(want, 0L);
  if (n == 0) problems.add("missing " + want);
  else if (n > 1) problems.add("duplicated " + want);
}
for (var h : out.holdings) {
  List<String> allowed = facts.admissibleThemes.getOrDefault(h.holdingId, List.of());
  if (allowed.isEmpty()) {
    if (!"none".equals(h.theme)) problems.add("nothing admissible for " + h.holdingId);
  } else if (!allowed.contains(h.theme)) {
    problems.add(h.holdingId + " -> " + h.theme + " is not permitted");
  }
}
if (!problems.isEmpty()) throw new IllegalStateException(String.join("; ", problems));
def check(facts, out)
  problems = []
  ids = out["holdings"].map { |h| h["holding_id"] }
  facts["commentary_ids"].each do |want|
    n = ids.count(want)
    problems << "missing #{want}" if n.zero?
    problems << "duplicated #{want}" if n > 1
  end
  out["holdings"].each do |h|
    allowed = facts["admissible_themes"][h["holding_id"]] || []
    if allowed.empty?
      problems << "nothing admissible for #{h['holding_id']}" unless h["theme"] == "none"
    elsif !allowed.include?(h["theme"])
      problems << "#{h['holding_id']} -> #{h['theme']} is not permitted"
    end
  end
  facts["excluded_ids"].each do |gone|
    problems << "unpriceable #{gone} is never named" unless out["unverified"].any? { |u| u.include?(gone) }
  end
  problems
end
function check(array $facts, array $out): array {
  $problems = [];
  $ids = array_column($out["holdings"], "holding_id");
  foreach ($facts["commentary_ids"] as $want) {
    $n = count(array_keys($ids, $want, true));
    if ($n === 0) { $problems[] = "missing {$want}"; }
    elseif ($n > 1) { $problems[] = "duplicated {$want}"; }
  }
  foreach ($out["holdings"] as $h) {
    $allowed = $facts["admissible_themes"][$h["holding_id"]] ?? [];
    if (!$allowed) {
      if ($h["theme"] !== "none") { $problems[] = "nothing admissible for {$h['holding_id']}"; }
    } elseif (!in_array($h["theme"], $allowed, true)) {
      $problems[] = "{$h['holding_id']} -> {$h['theme']} is not permitted";
    }
  }
  return $problems;
}
var problems = new List<string>();
var ids = output.Holdings.Select(h => h.HoldingId).ToList();
foreach (var want in facts.CommentaryIds) {
  var n = ids.Count(i => i == want);
  if (n == 0) problems.Add($"missing {want}");
  else if (n > 1) problems.Add($"duplicated {want}");
}
foreach (var h in output.Holdings) {
  var allowed = facts.AdmissibleThemes.GetValueOrDefault(h.HoldingId) ?? new List<string>();
  if (allowed.Count == 0) {
    if (h.Theme != "none") problems.Add($"nothing admissible for {h.HoldingId}");
  } else if (!allowed.Contains(h.Theme)) {
    problems.Add($"{h.HoldingId} -> {h.Theme} is not permitted");
  }
}
if (problems.Count > 0) throw new Exception("the reply does not honour the contract");

The output contract

Exactly this shape and nothing outside it. The parser reads these fields and no others; a missing theme stays missing rather than being defaulted, because defaulting it would put a claim on the review that the model never made.

{
  "title": "Core IG credit - May 2026 review",
  "positioning": "three to five sentences",
  "holdings": [
    { "holding_id": "H1", "theme": "concentration", "note": "one sentence naming a mechanism" }
  ],
  "scenario_note": "one to three sentences; must name facts.decisive.driver_id or its issuer",
  "actions": [
    { "holding_id": "PORTFOLIO", "action": "one sentence a manager can act on" }
  ],
  "unverified": ["one entry naming each id in facts.excluded_ids"]
}

The seven themes, and the eighth answer

ThemeWhen the engine admits it
duration_driverThe holding's DV01 share exceeds its market value share by at least two percentage points. Note the shape of the test: a large DV01 on a large position is not evidence, because the market value weight already says that.
carry_engineIt yields at least 50 bp more than the market-value weighted portfolio yield and carries no more modified duration than the portfolio. Both halves are required - out-yielding the book by taking more duration is not carry.
convexity_benefitAt −100 bp a full repricing gains at least 0.05% of that holding's own market value more than duration and convexity together predict.
reinvestment_concentrationIt is a quarter or more of the cash arriving in one quarter of the waterfall, and that quarter is at least a tenth of everything the book pays in the window. Without the second half the theme fires on almost every semiannual payer and means nothing.
roll_down_soonIt matures within two years of the settlement date.
call_riskA call date and price were supplied and the yield to that call solves below the yield to maturity.
concentrationIt is a fifth or more of market value, or sits in a sector sleeve that is over a third of the book.
noneThe engine admitted nothing. The theme must then be the literal string none and the note the verbatim string no theme this holding's own numbers support. This is a correct answer, not a failure - one of the three bundled books exists to show it.
Two more checks the app applies and you probably should too: every figure in the reply has to trace to a number in facts or in the excerpt, measured at the precision it was written to so a permitted rounding is not called an invention; and any sentence claiming something is the largest rate contributor has to name the holding with the highest dv01_share — tested on the sentence carrying the claim, not on the whole paragraph.

9. Keep the review

The release declares one collection, books, with acl_read: "owner" and acl_write: "user", so a review follows the manager across devices instead of living in one browser. The iteration loop it serves is comparative — re-price the same book against fresh marks and ask whether the driver moved — which is why the record keeps the pasted table as well as the rendered review.

# Record creation is POST /collections/{name}/records - note "records".
call /collections/books/records '{
  "title":"Core IG credit review",
  "portfolio":"Core IG credit / USD / May 2026",
  "summary":"Duration sits below the benchmark and the plus-hundred driver is not the largest line.",
  "themes":"duration_driver, carry_engine, concentration",
  "state":"clean",
  "holdings":8,"excluded":0,
  "market_value":17978609.03,"duration":4.4056,"dv01":7920.71,
  "settlement":"2026-05-15",
  "ran_at":"2026-05-15T09:00:00Z",
  "doc_md":"# Fixed income portfolio review ..."
}'

# where takes OPERATOR OBJECTS, and the sort key is `sort` - order_by is
# silently ignored and you quietly get created_at desc instead.
call /collections/books/query '{
  "where":{"state":{"eq":"not-priceable"}},
  "sort":{"field":"ran_at","dir":"desc"},
  "limit":20
}'

# Semantic search over the four embedded fields. 30/min per IP.
call /collections/books/similar '{"text":"the month the long end did the damage","limit":8}' 
rec = call("/collections/books/records", {
    "title": "Core IG credit review",
    "portfolio": "Core IG credit / USD / May 2026",
    "summary": "Duration sits below the benchmark and the plus-hundred driver is not the largest line.",
    "themes": "duration_driver, carry_engine, concentration",
    "state": "clean",
    "holdings": 8, "excluded": 0,
    "market_value": 17978609.03, "duration": 4.4056, "dv01": 7920.71,
    "settlement": "2026-05-15",
    "ran_at": "2026-05-15T09:00:00Z",
    "doc_md": review_markdown,
})

page = call("/collections/books/query", {
    "where": {"state": {"eq": "not-priceable"}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 20,
})
hits = call("/collections/books/similar", {"text": "the month the long end did the damage", "limit": 8})
await call("/collections/books/records", {
  title: "Core IG credit review",
  portfolio: "Core IG credit / USD / May 2026",
  summary: "Duration sits below the benchmark and the plus-hundred driver is not the largest line.",
  themes: "duration_driver, carry_engine, concentration",
  state: "clean",
  holdings: 8, excluded: 0,
  market_value: 17978609.03, duration: 4.4056, dv01: 7920.71,
  settlement: "2026-05-15",
  ran_at: new Date().toISOString(),
  doc_md: reviewMarkdown,
});

const page = await call("/collections/books/query", {
  where: { state: { eq: "not-priceable" } },
  sort: { field: "ran_at", dir: "desc" },
  limit: 20,
});
const hits = await call("/collections/books/similar", { text: "the long end did the damage", limit: 8 });
_, _ = call("/collections/books/records", map[string]any{
	"title":        "Core IG credit review",
	"portfolio":    "Core IG credit / USD / May 2026",
	"summary":      "The plus-hundred driver is not the largest line.",
	"themes":       "duration_driver, carry_engine, concentration",
	"state":        "clean",
	"holdings":     8,
	"market_value": 17978609.03,
	"duration":     4.4056,
	"dv01":         7920.71,
	"ran_at":       time.Now().UTC().Format(time.RFC3339),
}, "POST")

_, _ = call("/collections/books/query", map[string]any{
	"where": map[string]any{"state": map[string]any{"eq": "not-priceable"}},
	"sort":  map[string]any{"field": "ran_at", "dir": "desc"},
	"limit": 20,
}, "POST")
call("/collections/books/records", """
  {"title":"Core IG credit review",
   "portfolio":"Core IG credit / USD / May 2026",
   "summary":"The plus-hundred driver is not the largest line.",
   "themes":"duration_driver, carry_engine, concentration",
   "state":"clean","holdings":8,"excluded":0,
   "market_value":17978609.03,"duration":4.4056,"dv01":7920.71,
   "ran_at":"2026-05-15T09:00:00Z"}
  """);

call("/collections/books/query",
     "{\"where\":{\"state\":{\"eq\":\"not-priceable\"}},"
   + "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}");
call("/collections/books/records", {
  "title" => "Core IG credit review",
  "portfolio" => "Core IG credit / USD / May 2026",
  "summary" => "The plus-hundred driver is not the largest line.",
  "themes" => "duration_driver, carry_engine, concentration",
  "state" => "clean",
  "holdings" => 8, "excluded" => 0,
  "market_value" => 17_978_609.03, "duration" => 4.4056, "dv01" => 7_920.71,
  "ran_at" => Time.now.utc.iso8601,
  "doc_md" => review_markdown,
})

call("/collections/books/query", {
  "where" => { "state" => { "eq" => "not-priceable" } },
  "sort" => { "field" => "ran_at", "dir" => "desc" },
  "limit" => 20,
})
call("/collections/books/records", [
  "title" => "Core IG credit review",
  "portfolio" => "Core IG credit / USD / May 2026",
  "summary" => "The plus-hundred driver is not the largest line.",
  "themes" => "duration_driver, carry_engine, concentration",
  "state" => "clean",
  "holdings" => 8, "excluded" => 0,
  "market_value" => 17978609.03, "duration" => 4.4056, "dv01" => 7920.71,
  "ran_at" => gmdate("c"),
  "doc_md" => $reviewMarkdown,
]);

call("/collections/books/query", [
  "where" => ["state" => ["eq" => "not-priceable"]],
  "sort" => ["field" => "ran_at", "dir" => "desc"],
  "limit" => 20,
]);
await BondDesk.Call("/collections/books/records", new {
  title = "Core IG credit review",
  portfolio = "Core IG credit / USD / May 2026",
  summary = "The plus-hundred driver is not the largest line.",
  themes = "duration_driver, carry_engine, concentration",
  state = "clean",
  holdings = 8, excluded = 0,
  market_value = 17978609.03, duration = 4.4056, dv01 = 7920.71,
  ran_at = DateTime.UtcNow.ToString("o"),
});

await BondDesk.Call("/collections/books/query", new {
  where = new { state = new { eq = "not-priceable" } },
  sort = new { field = "ran_at", dir = "desc" },
  limit = 20,
});

The declared document

Four fields are embedded for semantic search — title, portfolio, summary and themes. The platform never backfills vectors, so records written before a field was embedded stay unsearchable by it. Documents cap at 64 KB each.

{
  "title":        "string   (embedded)",
  "portfolio":    "string   (embedded)  - the sleeve name people actually search by",
  "summary":      "string   (embedded)  - the positioning sentence",
  "themes":       "string   (embedded)  - the flat list of themes assigned",
  "state":        "string   - clean | not-priceable | does-not-foot | no-carry | driver-not-largest",
  "holdings":     "number", "excluded": "number",
  "market_value": "number", "duration": "number", "dv01": "number",
  "settlement":   "string",
  "ran_at":       "timestamp - the sort key; `sort`, never `order_by`",
  "doc_md":       "string  - the rendered review, undeclared keys round-trip fine",
  "context":      "object  - the pasted holdings table, dropped first when 64 KB is tight",
  "model":        "object  - the raw reply",
  "meta":         "object"
}
similar() resolves to the record array while query() resolves to {records}. Accept either shape rather than trusting one — reading .records off an array is how a semantic search returns nothing forever with no error anywhere to find.