← Client Report Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run on an app whose publisher does not sponsor guest runs.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

To mint a guest token yourself, POST /guest with {"slug":"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":"…"}}

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
}

3. Check the session and the balance

GET /me answers {subject_type, subject_id, credits}. Branch on subject_type: it is guest or user, and a guest can price a run but, unless the publisher sponsors guest runs, cannot start one. credits is the wallet balance. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}

4. Price the 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.

taskwhat it does
reportWrites 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.
fieldtypemeaning
taskstring, required"report"
factsstring, requiredA 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_notestring, optional (app-set only)Only when resubmitting after an unparseable reply: a plain instruction about the reply's shape. The web app sends it once, as attempt 2; you normally never set it on a first run.

The app declares an input schema with task and facts required, so an estimate of an empty object comes back with missing required field warnings. A warning is not a rejection: check the warnings array yourself before you run. And /estimate does very little body validation — a bare string or an array prices as happily as the real input. 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

fieldformat
client, period_labelFree text. The label defaults to "start to end".
start, endYYYY-MM-DD or MM/DD/YYYY; the start is the valuation date of the begin values. Required.
currencyA three-letter code; defaults to USD. Money is formatted as "USD 1,114,925".
audienceretail (plain words, jargon checked) or sophisticated.
benchmark, bench_returnThe benchmark's name and its return for the period in percent.
prior_port, prior_benchOptional: 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.
accountsOne per line: name | type | begin value | end value. Up to 20. Required.
flowsOne 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.
holdingsOne per line: security | asset class | shares | price [| period return % [| account]], or security | asset class | value. Up to 150.
weightsOne per line: asset class | weight % (fractions such as 0.45 are read as 45%).
market_notes, planningFree 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

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.

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

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}

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

// 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

codeseveritymeaning
flow_outside_periodhigh or lowA cash flow is dated outside the period and was left out; high when it is a contribution, withdrawal or fee.
flow_unknown_accounthigh or lowA cash flow names an account that is not in the list and was left out; high for a contribution, withdrawal or fee.
account_no_capitalhighAn account has no invested capital over the period, so its return cannot be computed.
return_implausiblehighAn account's period return is beyond 25% (under about 100 days) or 60%: almost always a typo in a value or a flow.
holdings_mismatchhighThe holdings total differs from the accounts' end values by more than 1%: the report's tables would contradict each other.
account_holdings_mismatchmediumHoldings tagged to an account do not match that account's end value within 1%, or name an unknown account.
no_benchmarkmediumNo benchmark return was given; the outcome is n/a.
no_holdingsmediumNo holdings were given; there is no allocation or holdings detail.
allocation_driftmediumAn asset class is 5.0 pp or more away from its benchmark weight.
weights_not_100mediumThe benchmark weights do not add up to 100% (within 0.5).
large_flowmedium or lowA 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_unnamedlowThe benchmark return has no name.
ytd_benchmark_missinglowA year-to-date portfolio figure was given without the benchmark's.
no_fees_recordedlowNo fees in the period, so net equals gross.
period_over_a_yearlowThe period is over 400 days; the figures are cumulative, not annualised.
holdings_roundinglowHoldings and account values differ by 0.1% to 1%: pending settlement, accrued income or rounding.
returns_partiallowOnly some holdings carry a period return, so contributors and detractors cover only those.
class_not_in_benchmarklowAn asset class is held but has no benchmark weight.
no_benchmark_weightslowNo benchmark weights were given.
no_market_noteslowNo market notes were given; the commentary is left empty for the advisor.
no_planning_noteslowNo 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.