Drive Client Report Desk from your own code
Everything the web page does is available over HTTP. Send the figures the browser computes for one household's reporting period (each account's begin and end value, the dated contributions, withdrawals, fees, dividends, interest and trades, the holdings, the benchmark's return and weights, and optionally your market notes and planning notes), and get the same client report back: the outcome and the report status copied from the figures, a headline, an executive summary, performance, allocation and holdings reads, a market commentary written only from your notes, an activity read, planning notes, action items, one response per flag, compliance checks and disclosures. The natural use is quarter-end batch reporting: a script builds each household's figures from the custodian export, asks for the report, and files the Markdown next to the figures for review.
One thing to be clear about before the first call: the model never does the arithmetic.
The Modified Dietz returns (net and gross, per account and for the household), the benchmark
comparison, the chain-linked year to date, fees and income, the activity totals, the allocation and
its drift, the holdings with their approximate gains, the tie-out checks, the flags, the outcome and
the report status are all computed by report.js, the same file the web page loads, and
the result is sent as facts, a JSON string. The model writes the narrative and
copies figures; it never recomputes one. A direct API caller must therefore compute the facts the same
way (run report.js in Node, or send the facts the page built) — a hand-rolled
facts with different figures gets a report on 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":"client-report-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":"client-report-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 report 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://client-report-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;
# writing a report 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":"client-report-desk"}'
# HTTP 201
# {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://client-report-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 report.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "client-report-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://client-report-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 report.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "client-report-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, guest.expires_at);
// Open https://client-report-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 report.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"client-report-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://client-report-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 report.
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\":\"client-report-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://client-report-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 report.
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: "client-report-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://client-report-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 report.
$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" => "client-report-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://client-report-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 report.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"client-report-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://client-report-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://client-report-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://client-report-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://client-report-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 ClientReportDesk {
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://client-report-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 ClientReportDesk
{
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 report 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 report 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 report needs a signed-in token")
}
fmt.Println(me.SubjectType, me.Credits)
String me = ClientReportDesk.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 report needs a signed-in token");
me = call("me")
warn "guest token: a report 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 report needs a signed-in token\n");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await ClientReportDesk.Call("me");
var subject = me.GetProperty("subject_type").GetString();
if (subject != "user") Console.Error.WriteLine("guest token: a report needs a signed-in token");
Console.WriteLine($"{subject} {me.GetProperty("credits")}");
4. Price the report (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 report. A missing or
unknown task is still answered as report, and the reply's lane
says so.
| task | what it does |
|---|---|
report | Writes the client-facing narrative of a performance report for one household's period: the outcome (outperformed, underperformed, in_line, n/a) and report status (ready, review_first, hold) copied from the figures, headline, executive summary, performance, allocation and holdings reads, market commentary from the advisor's notes (three parts, empty when there are no notes), activity read, planning notes, action items, flag responses, compliance checks, disclosures and summary, pitched to a retail or sophisticated client. |
| field | type | meaning |
|---|---|---|
task | string, required | "report" |
facts | string, required | A JSON string: the JSON-encoded output of Report.facts(Report.compute(form)), which Report.buildInput wraps for you. It holds the period, the household and account figures, the benchmark and year to date, the activity, the allocation, the holdings, the outcome, the report status, the flags, the rules and the advisor's notes. Fields below. |
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. Send a JSON object with task and
facts as strings; the page's own guard, Report.mustBeObject, throws on
anything else before it calls the API.
Building the facts
report.js is a plain script that also loads as a Node module. Give
Report.compute the same form fields the page has (every value a string, as typed) and
pass the result to Report.buildInput:
// make-body.js - Node 18+
const Report = require("./report.js"); // https://client-report-desk.skillsafe.ai/report.js
const res = Report.compute({
client: "Chen household", period_label: "Q3 2026", start: "2026-06-30", end: "2026-09-30",
currency: "USD", audience: "retail",
benchmark: "60/40 blend (MSCI ACWI / Bloomberg US Aggregate)", bench_return: "3.10",
prior_port: "5.84", prior_bench: "5.60",
accounts: "Joint Taxable | Brokerage | 1,032,000 | 1,114,925\nDavid IRA | Traditional IRA | 775,900 | 802,275\n...",
flows: "2026-07-15 | Joint Taxable | fee | 3,020\n2026-08-01 | Joint Taxable | contribution | 40,000\n...",
holdings: "Vanguard Total Stock Market ETF (VTI) | US equity | 1300 | 318.40 | +4.35 | Joint Taxable\n...",
weights: "US equity | 45\nInternational equity | 20\nUS bonds | 30\nCash | 5",
market_notes: "Global stocks rose in the quarter ...", planning: "Retirement target: ..."
});
if (!res.ok) throw new Error(res.errors.join(" "));
const body = Report.buildInput(res); // { task: "report", facts: "<JSON string>" }
console.log(res.model.status, res.model.outcome, res.warnings);
require("fs").writeFileSync("body.json", JSON.stringify(body)); // the steps below read body.json
The form fields
| field | format |
|---|---|
client, period_label | Free text. The label defaults to "start to end". |
start, end | YYYY-MM-DD or MM/DD/YYYY; the start is the valuation date of the begin values. Required. |
currency | A three-letter code; defaults to USD. Money is formatted as "USD 1,114,925". |
audience | retail (plain words, jargon checked) or sophisticated. |
benchmark, bench_return | The benchmark's name and its return for the period in percent. |
prior_port, prior_bench | Optional: the portfolio's and the benchmark's returns from the start of the year to the period start, in percent, for the chain-linked year to date. |
accounts | One per line: name | type | begin value | end value. Up to 20. Required. |
flows | One per line: date | account | type | amount [| note]; type is contribution, withdrawal, fee, dividend, interest, buy or sell (common synonyms such as deposit or distribution are read). Amounts are taken as positive; the type gives the direction. Up to 300. |
holdings | One per line: security | asset class | shares | price [| period return % [| account]], or security | asset class | value. Up to 150. |
weights | One per line: asset class | weight % (fractions such as 0.45 are read as 45%). |
market_notes, planning | Free text, each cut at 2,000 characters on a word boundary (the facts then carry notes_clipped_chars). |
Lines may be separated by |, tabs or semicolons; commas work when amounts carry no thousands separators that could be confused with the column separator. A header line is skipped.
What is in facts
units, client, audience, currency, period
(label, start, end, days), method, household (begin and end value,
contributions, withdrawals, net external flows, fees, dividends, interest, income, investment gain,
average capital, net and gross return, fee drag, fees and income as a percent of average capital),
benchmark (name, period return, household vs benchmark, or "not supplied"),
year_to_date, accounts, activity (totals, trades, flows left out,
up to 40 external flows), allocation, holdings (count, total, the largest 25,
up to three contributors and detractors, the basis of the approximate gains), outcome,
report_status, flags, rules, market_notes and
planning_notes. Every figure is a pre-formatted string ("+3.63%", "+0.53 pp",
"USD 1,114,925") so the model can copy it exactly.
How the figures are computed
- Modified Dietz. return = (end - begin - net external flows) / (begin + Σ w × flow), where w = (period end - flow date) / (period end - period start). Net of fees counts only contributions (+) and withdrawals (-) as external, so fees taken from the account lower the return; gross also treats each fee as a withdrawal, so it is added back.
- Household. The same formula on the summed begin and end values and all the flows — not an average of the accounts.
- Outcome. outperformed when net minus benchmark, rounded to 0.01 pp, is at least +0.10 pp; underperformed at -0.10 pp or less; in_line between; n/a with no benchmark.
- Year to date. (1 + earlier) × (1 + this period) - 1, for the portfolio and, when given, the benchmark.
- Allocation. Holdings grouped by asset class (case-insensitive) as a share of the holdings total, against the benchmark weights; drift at 5.0 pp or more.
- Approximate gain. value - value / (1 + period return), assuming no trades in the holding.
- Report status. hold when any flag is high, review_first when any is medium, otherwise ready.
The worked example
The full request body the page builds for the Chen household example (a real run input; the facts string is 7,017 characters):
{
"task": "report",
"facts": "{\"units\":\"Returns are cumulative for the period in percent, not annualised; differences between returns and weights are in percentage points (pp); money is in USD, rounded to whole units.\",\"client\":\"Chen household\",\"audience\":\"retail\",\"currency\":\"USD\",\"period\":{\"label\":\"Q3 2026\",\"start\":\"2026-06-30\",\"end\":\"2026-09-30\",\"days\":92},\"method\":\"Modified Dietz: gain / (begin value + time-weighted external flows). Net of fees counts only contributions and withdrawals as external, so fees lower the return; gross adds fees back.\",\"household\":{\"begin_value\":\"USD 2,290,000\",\"end_value\":\"USD 2,426,450\",\"contributions\":\"USD 52,000\",\"withdrawals\":\"USD 0\",\"net_external_flows\":\"USD +52,000\",\"fees\":\"USD 6,165\",\"dividends\":\"USD 7,070\",\"interest\":\"USD 310\",\"income\":\"USD 7,380\",\"investment_gain\":\"USD +84,450\",\"average_capital\":\"USD 2,323,848\",\"return_net\":\"+3.63%\",\"return_gross\":\"+3.91%\",\"fee_drag\":\"-0.27 pp\",\"fees_pct_of_average_capital\":\"0.27%\",\"income_pct_of_average_capital\":\"0.32%\"},\"benchmark\":{\"name\":\"60/40 blend (MSCI ACWI / Bloomberg US Aggregate)\",\"period_return\":\"+3.10%\",\"household_vs_benchmark\":\"+0.53 pp\"},\"year_to_date\":{\"earlier_this_year_portfolio\":\"+5.84%\",\"portfolio\":\"+9.69%\",\"earlier_this_year_benchmark\":\"+5.60%\",\"benchmark\":\"+8.87%\",\"vs_benchmark\":\"+0.81 pp\",\"method\":\"chain-linked: (1 + earlier) x (1 + this period) - 1\"},\"accounts\":[{\"name\":\"Joint Taxable\",\"type\":\"Brokerage\",\"begin_value\":\"USD 1,032,000\",\"end_value\":\"USD 1,114,925\",\"net_external_flows\":\"USD +40,000\",\"fees\":\"USD 3,020\",\"income\":\"USD 4,520\",\"investment_gain\":\"USD +42,925\",\"return_net\":\"+4.06%\",\"return_gross\":\"+4.35%\",\"vs_benchmark\":\"+0.96 pp\",\"share_of_household\":\"45.9%\"},{\"name\":\"David IRA\",\"type\":\"Traditional IRA\",\"begin_value\":\"USD 775,900\",\"end_value\":\"USD 802,275\",\"net_external_flows\":\"USD 0\",\"fees\":\"USD 1,940\",\"income\":\"USD 2,860\",\"investment_gain\":\"USD +26,375\",\"return_net\":\"+3.40%\",\"return_gross\":\"+3.66%\",\"vs_benchmark\":\"+0.30 pp\",\"share_of_household\":\"33.1%\"},{\"name\":\"Mei Roth IRA\",\"type\":\"Roth IRA\",\"begin_value\":\"USD 229,800\",\"end_value\":\"USD 243,035\",\"net_external_flows\":\"USD +7,000\",\"fees\":\"USD 575\",\"income\":\"USD 0\",\"investment_gain\":\"USD +6,235\",\"return_net\":\"+2.64%\",\"return_gross\":\"+2.89%\",\"vs_benchmark\":\"-0.46 pp\",\"share_of_household\":\"10.0%\"},{\"name\":\"529 Plan\",\"type\":\"Education\",\"begin_value\":\"USD 252,300\",\"end_value\":\"USD 266,215\",\"net_external_flows\":\"USD +5,000\",\"fees\":\"USD 630\",\"income\":\"USD 0\",\"investment_gain\":\"USD +8,915\",\"return_net\":\"+3.51%\",\"return_gross\":\"+3.77%\",\"vs_benchmark\":\"+0.41 pp\",\"share_of_household\":\"11.0%\"}],\"activity\":{\"contributions\":\"USD 52,000\",\"withdrawals\":\"USD 0\",\"fees\":\"USD 6,165\",\"dividends\":\"USD 7,070\",\"interest\":\"USD 310\",\"trades\":2,\"bought\":\"USD 25,000\",\"sold\":\"USD 15,000\",\"flows_left_out\":0,\"external_flows\":[{\"date\":\"2026-07-10\",\"account\":\"Mei Roth IRA\",\"type\":\"contribution\",\"amount\":\"USD 7,000\"},{\"date\":\"2026-08-01\",\"account\":\"Joint Taxable\",\"type\":\"contribution\",\"amount\":\"USD 40,000\"},{\"date\":\"2026-09-02\",\"account\":\"529 Plan\",\"type\":\"contribution\",\"amount\":\"USD 5,000\"}]},\"allocation\":[{\"asset_class\":\"US equity\",\"value\":\"USD 1,183,320\",\"weight\":\"48.8%\",\"benchmark_weight\":\"45.0%\",\"vs_benchmark\":\"+3.8 pp\"},{\"asset_class\":\"US bonds\",\"value\":\"USD 722,310\",\"weight\":\"29.8%\",\"benchmark_weight\":\"30.0%\",\"vs_benchmark\":\"-0.2 pp\"},{\"asset_class\":\"International equity\",\"value\":\"USD 482,570\",\"weight\":\"19.9%\",\"benchmark_weight\":\"20.0%\",\"vs_benchmark\":\"-0.1 pp\"},{\"asset_class\":\"Cash\",\"value\":\"USD 38,250\",\"weight\":\"1.6%\",\"benchmark_weight\":\"5.0%\",\"vs_benchmark\":\"-3.4 pp\"}],\"holdings\":{\"count\":10,\"total_value\":\"USD 2,426,450\",\"holdings_sent\":10,\"largest\":[{\"security\":\"Fidelity 500 Index Fund (FXAIX)\",\"asset_class\":\"US equity\",\"value\":\"USD 475,795\",\"weight\":\"19.6%\",\"period_return\":\"+4.60%\",\"approx_gain\":\"USD +20,924\",\"account\":\"David IRA\"},{\"security\":\"Vanguard Total Stock Market ETF (VTI)\",\"asset_class\":\"US equity\",\"value\":\"USD 413,920\",\"weight\":\"17.1%\",\"period_return\":\"+4.35%\",\"approx_gain\":\"USD +17,255\",\"account\":\"Joint Taxable\"},{\"security\":\"iShares Core MSCI EAFE ETF (IEFA)\",\"asset_class\":\"International equity\",\"value\":\"USD 353,220\",\"weight\":\"14.6%\",\"period_return\":\"+5.20%\",\"approx_gain\":\"USD +17,460\",\"account\":\"Joint Taxable\"},{\"security\":\"Vanguard Total Bond Market ETF (BND)\",\"asset_class\":\"US bonds\",\"value\":\"USD 326,480\",\"weight\":\"13.5%\",\"period_return\":\"+1.95%\",\"approx_gain\":\"USD +6,245\",\"account\":\"David IRA\"},{\"security\":\"iShares Core US Aggregate Bond ETF (AGG)\",\"asset_class\":\"US bonds\",\"value\":\"USD 309,535\",\"weight\":\"12.8%\",\"period_return\":\"+2.05%\",\"approx_gain\":\"USD +6,218\",\"account\":\"Joint Taxable\"},{\"security\":\"Vanguard Target Retirement 2040 (VFORX)\",\"asset_class\":\"US equity\",\"value\":\"USD 179,920\",\"weight\":\"7.4%\",\"period_return\":\"+3.90%\",\"approx_gain\":\"USD +6,753\",\"account\":\"529 Plan\"},{\"security\":\"Vanguard FTSE Emerging Markets ETF (VWO)\",\"asset_class\":\"International equity\",\"value\":\"USD 129,350\",\"weight\":\"5.3%\",\"period_return\":\"+6.10%\",\"approx_gain\":\"USD +7,437\",\"account\":\"Mei Roth IRA\"},{\"security\":\"Schwab US Dividend Equity ETF (SCHD)\",\"asset_class\":\"US equity\",\"value\":\"USD 113,685\",\"weight\":\"4.7%\",\"period_return\":\"+2.80%\",\"approx_gain\":\"USD +3,096\",\"account\":\"Mei Roth IRA\"},{\"security\":\"Vanguard Short-Term Bond ETF (BSV)\",\"asset_class\":\"US bonds\",\"value\":\"USD 86,295\",\"weight\":\"3.6%\",\"period_return\":\"+1.20%\",\"approx_gain\":\"USD +1,023\",\"account\":\"529 Plan\"},{\"security\":\"Cash sweep\",\"asset_class\":\"Cash\",\"value\":\"USD 38,250\",\"weight\":\"1.6%\",\"period_return\":\"0.00%\",\"approx_gain\":\"USD 0\",\"account\":\"Joint Taxable\"}],\"contributors\":[{\"security\":\"Fidelity 500 Index Fund (FXAIX)\",\"asset_class\":\"US equity\",\"value\":\"USD 475,795\",\"weight\":\"19.6%\",\"period_return\":\"+4.60%\",\"approx_gain\":\"USD +20,924\",\"account\":\"David IRA\"},{\"security\":\"iShares Core MSCI EAFE ETF (IEFA)\",\"asset_class\":\"International equity\",\"value\":\"USD 353,220\",\"weight\":\"14.6%\",\"period_return\":\"+5.20%\",\"approx_gain\":\"USD +17,460\",\"account\":\"Joint Taxable\"},{\"security\":\"Vanguard Total Stock Market ETF (VTI)\",\"asset_class\":\"US equity\",\"value\":\"USD 413,920\",\"weight\":\"17.1%\",\"period_return\":\"+4.35%\",\"approx_gain\":\"USD +17,255\",\"account\":\"Joint Taxable\"}],\"detractors\":[],\"return_basis\":\"approx_gain = value - value / (1 + period return), assuming no trades in the holding during the period\"},\"outcome\":\"outperformed\",\"report_status\":\"ready\",\"flags\":[],\"rules\":{\"in_line_within\":\"0.10 pp\",\"drift_from\":\"5.0 pp\",\"holdings_tolerance\":\"1.0%\"},\"market_notes\":\"Global stocks rose in the quarter as inflation kept cooling and the Fed cut rates by 0.25% in September. International and emerging markets led, helped by a weaker dollar. Bonds gained as yields fell. We expect slower growth into year end and keep the portfolio balanced; no change to the plan.\",\"planning_notes\":\"Retirement target: David at 62 in 2034, on track per the June plan update. 529: Lily starts college fall 2031; keep contributing 5,000 each quarter. Action: confirm Mei's 2026 Roth contribution is complete; review beneficiary designations. Next review: January 2027.\"}"
}
The same facts decoded, with the long arrays shortened:
{
"units": "Returns are cumulative for the period in percent, not annualised; differences between returns and weights are in percentage points (pp); money is in USD, rounded to whole units.",
"client": "Chen household",
"audience": "retail",
"currency": "USD",
"period": {
"label": "Q3 2026",
"start": "2026-06-30",
"end": "2026-09-30",
"days": 92
},
"method": "Modified Dietz: gain / (begin value + time-weighted external flows). Net of fees counts only contributions and withdrawals as external, so fees lower the return; gross adds fees back.",
"household": {
"begin_value": "USD 2,290,000",
"end_value": "USD 2,426,450",
"contributions": "USD 52,000",
"withdrawals": "USD 0",
"net_external_flows": "USD +52,000",
"fees": "USD 6,165",
"dividends": "USD 7,070",
"interest": "USD 310",
"income": "USD 7,380",
"investment_gain": "USD +84,450",
"average_capital": "USD 2,323,848",
"return_net": "+3.63%",
"return_gross": "+3.91%",
"fee_drag": "-0.27 pp",
"fees_pct_of_average_capital": "0.27%",
"income_pct_of_average_capital": "0.32%"
},
"benchmark": {
"name": "60/40 blend (MSCI ACWI / Bloomberg US Aggregate)",
"period_return": "+3.10%",
"household_vs_benchmark": "+0.53 pp"
},
"year_to_date": {
"earlier_this_year_portfolio": "+5.84%",
"portfolio": "+9.69%",
"earlier_this_year_benchmark": "+5.60%",
"benchmark": "+8.87%",
"vs_benchmark": "+0.81 pp",
"method": "chain-linked: (1 + earlier) x (1 + this period) - 1"
},
"accounts": [
{
"name": "Joint Taxable",
"type": "Brokerage",
"begin_value": "USD 1,032,000",
"end_value": "USD 1,114,925",
"net_external_flows": "USD +40,000",
"fees": "USD 3,020",
"income": "USD 4,520",
"investment_gain": "USD +42,925",
"return_net": "+4.06%",
"return_gross": "+4.35%",
"vs_benchmark": "+0.96 pp",
"share_of_household": "45.9%"
},
"..."
],
"activity": {
"contributions": "USD 52,000",
"withdrawals": "USD 0",
"fees": "USD 6,165",
"dividends": "USD 7,070",
"interest": "USD 310",
"trades": 2,
"bought": "USD 25,000",
"sold": "USD 15,000",
"flows_left_out": 0,
"external_flows": [
{
"date": "2026-07-10",
"account": "Mei Roth IRA",
"type": "contribution",
"amount": "USD 7,000"
},
"..."
]
},
"allocation": [
{
"asset_class": "US equity",
"value": "USD 1,183,320",
"weight": "48.8%",
"benchmark_weight": "45.0%",
"vs_benchmark": "+3.8 pp"
},
{
"asset_class": "US bonds",
"value": "USD 722,310",
"weight": "29.8%",
"benchmark_weight": "30.0%",
"vs_benchmark": "-0.2 pp"
},
{
"asset_class": "International equity",
"value": "USD 482,570",
"weight": "19.9%",
"benchmark_weight": "20.0%",
"vs_benchmark": "-0.1 pp"
},
{
"asset_class": "Cash",
"value": "USD 38,250",
"weight": "1.6%",
"benchmark_weight": "5.0%",
"vs_benchmark": "-3.4 pp"
}
],
"holdings": {
"count": 10,
"total_value": "USD 2,426,450",
"holdings_sent": 10,
"largest": [
{
"security": "Fidelity 500 Index Fund (FXAIX)",
"asset_class": "US equity",
"value": "USD 475,795",
"weight": "19.6%",
"period_return": "+4.60%",
"approx_gain": "USD +20,924",
"account": "David IRA"
},
{
"security": "Vanguard Total Stock Market ETF (VTI)",
"asset_class": "US equity",
"value": "USD 413,920",
"weight": "17.1%",
"period_return": "+4.35%",
"approx_gain": "USD +17,255",
"account": "Joint Taxable"
},
"..."
],
"contributors": [
{
"security": "Fidelity 500 Index Fund (FXAIX)",
"asset_class": "US equity",
"value": "USD 475,795",
"weight": "19.6%",
"period_return": "+4.60%",
"approx_gain": "USD +20,924",
"account": "David IRA"
},
{
"security": "iShares Core MSCI EAFE ETF (IEFA)",
"asset_class": "International equity",
"value": "USD 353,220",
"weight": "14.6%",
"period_return": "+5.20%",
"approx_gain": "USD +17,460",
"account": "Joint Taxable"
},
{
"security": "Vanguard Total Stock Market ETF (VTI)",
"asset_class": "US equity",
"value": "USD 413,920",
"weight": "17.1%",
"period_return": "+4.35%",
"approx_gain": "USD +17,255",
"account": "Joint Taxable"
}
],
"detractors": [],
"return_basis": "approx_gain = value - value / (1 + period return), assuming no trades in the holding during the period"
},
"outcome": "outperformed",
"report_status": "ready",
"flags": [],
"rules": {
"in_line_within": "0.10 pp",
"drift_from": "5.0 pp",
"holdings_tolerance": "1.0%"
},
"market_notes": "Global stocks rose in the quarter as inflation kept cooling and the Fed cut rates by 0.25% in September. International and emerging markets led, helped by a weaker dollar. Bonds gained as yields fell. We expect slower growth into year end and keep the portfolio balanced; no change to the plan.",
"planning_notes": "Retirement target: David at 62 in 2034, on track per the June plan update. 529: Lily starts college fall 2031; keep contributing 5,000 each quarter. Action: confirm Mei's 2026 Roth contribution is complete; review beneficiary designations. Next review: January 2027."
}
/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
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(ClientReportDesk.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 ClientReportDesk.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,
client-report-desk:report:<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
Chen household example it is client-report-desk:report:5eae22d7-1f1b: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="client-report-desk:report:$(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\":\"report\",\"outcome\":\"outperformed\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > report.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"client-report-desk:report:{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 = `client-report-desk:report:${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("client-report-desk:report:%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 = "client-report-desk:report:" + ClientReportDesk.sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(ClientReportDesk.BASE + "/run"))
.header("Authorization", "Bearer " + ClientReportDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = ClientReportDesk.HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = ClientReportDesk.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 = "client-report-desk:report:#{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 = "client-report-desk:report:" . 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 = "client-report-desk:report:" + 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 {ClientReportDesk.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 ClientReportDesk.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\":\"report\",\"outcome\":\"outperformed\","}
# 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(ClientReportDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + ClientReportDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
ClientReportDesk.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 {ClientReportDesk.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 report; outcome and report_status are lower-cased
with spaces and hyphens turned into underscores (n/a kept as written), and an unknown value
becomes empty (then reported as missing); market_commentary becomes an object of three
strings; flag codes are lower-cased; missing arrays become empty and empty items are dropped. A reply
with none of headline, executive_summary and summary, or with
neither performance_read nor executive_summary, 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.
Invariants worth asserting
- Numbers. Every number written anywhere in the reply must equal a figure in
factsexactly — re-rounding is a disagreement, so "3.6%" for "+3.63%" fails. A number with a unit (%,pp) 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. Bare integers of 10 or less, calendar years and digits glued to letters ("Q3") are not counted as claims. - Names. ISO dates must appear in
facts; any ISO currency code must befacts.currency. - Outcome and status.
outcomeequalsfacts.outcomeandreport_statusequalsfacts.report_status. Onhold, the headline or the summary must say the report is on hold. With outcomen/a, no text may say the household beat, trailed, outperformed or underperformed. - Commentary. Empty when
facts.market_notesis "not supplied", present when notes were given. - Audience. For
retail, none of: alpha, beta, Sharpe, tracking error, basis point(s), bps, Modified Dietz, standard deviation, drawdown, active weight. - Flags.
flag_responsesanswers every code infacts.flagsand invents none. - Lane. A reply that names a lane other than
reportis reported.
// Node: the page's own reconciliation, on your reply and the facts you sent.
const Recon = require("./recon.js"); // https://client-report-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, dates and currencies 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": "report",
"outcome": "outperformed" | "underperformed" | "in_line" | "n/a",
"report_status": "ready" | "review_first" | "hold",
"headline": "one sentence",
"executive_summary": "3 to 5 sentences",
"performance_read": "3 to 5 sentences",
"allocation_read": "2 to 4 sentences",
"holdings_read": "2 to 3 sentences",
"market_commentary": {
"what_happened": "from facts.market_notes only, or an empty string",
"portfolio_effect": "or an empty string",
"outlook": "or an empty string"
},
"activity_read": "2 to 3 sentences",
"planning_notes": ["from facts.planning_notes only"],
"action_items": ["1 to 5 next steps; flag fixes first on review_first or hold"],
"flag_responses": [{"code": "a code from facts.flags", "response": "..."}],
"compliance_checks": ["3 to 5 items to verify before distribution"],
"disclosures": ["2 to 4 standard sentences, no numbers"],
"summary": "two sentences"
}
The flag codes
| code | severity | meaning |
|---|---|---|
flow_outside_period | high or low | A cash flow is dated outside the period and was left out; high when it is a contribution, withdrawal or fee. |
flow_unknown_account | high or low | A cash flow names an account that is not in the list and was left out; high for a contribution, withdrawal or fee. |
account_no_capital | high | An account has no invested capital over the period, so its return cannot be computed. |
return_implausible | high | An account's period return is beyond 25% (under about 100 days) or 60%: almost always a typo in a value or a flow. |
holdings_mismatch | high | The holdings total differs from the accounts' end values by more than 1%: the report's tables would contradict each other. |
account_holdings_mismatch | medium | Holdings tagged to an account do not match that account's end value within 1%, or name an unknown account. |
no_benchmark | medium | No benchmark return was given; the outcome is n/a. |
no_holdings | medium | No holdings were given; there is no allocation or holdings detail. |
allocation_drift | medium | An asset class is 5.0 pp or more away from its benchmark weight. |
weights_not_100 | medium | The benchmark weights do not add up to 100% (within 0.5). |
large_flow | medium or low | A contribution or withdrawal is 10% or more of the account's begin value (medium from 30%): the return is sensitive to the market around that date. |
benchmark_unnamed | low | The benchmark return has no name. |
ytd_benchmark_missing | low | A year-to-date portfolio figure was given without the benchmark's. |
no_fees_recorded | low | No fees in the period, so net equals gross. |
period_over_a_year | low | The period is over 400 days; the figures are cumulative, not annualised. |
holdings_rounding | low | Holdings and account values differ by 0.1% to 1%: pending settlement, accrued income or rounding. |
returns_partial | low | Only some holdings carry a period return, so contributors and detractors cover only those. |
class_not_in_benchmark | low | An asset class is held but has no benchmark weight. |
no_benchmark_weights | low | No benchmark weights were given. |
no_market_notes | low | No market notes were given; the commentary is left empty for the advisor. |
no_planning_notes | low | No planning notes were given. |
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 outcome, status, headline and executive summary may be
complete while the later sections are missing. The web page closes the cut-off JSON
(Recon.closeJson), shows the sections that arrived and says how many of the thirteen
(headline, executive summary, performance read, allocation read, holdings read, market commentary, activity read, planning notes, action items, flag responses, compliance checks, disclosures, 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.