← Odds Desk / API
Tokens

Drive Odds Desk from your own code

Everything the web page does is available over HTTP: post one pasted basket of prediction-market legs, name one of four lanes in task, and get the same structured worksheet back as one JSON object. The natural uses are the ones a browser tab is bad at — re-running the sizing worksheet over a whole book every morning, holding a nightly risk review against yesterday's numbers, or wiring the read lane into the step where a basket is first written down, so the resolution rules get read before anyone thinks about stake.

Say the important thing first: Odds Desk is a non-advisory worksheet. It documents the consequences of the numbers the caller supplies and never recommends a position. It does not predict outcomes, does not fetch market data, and does not substitute a price for a belief. If you send a leg with no stated fair probability, the reply says the leg is not sizeable — it will not invent a number to fill the hole. Build your product on that boundary rather than against it.

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": { ... } } }

Every call sends exactly two headers: Authorization: Bearer <token> and Content-Type: application/json. Nothing else is required and nothing else is read. There is no app-slug header. The slug appears in exactly one place in the whole API — the JSON body of POST /guest, as {"slug": "odds-desk"} — because the token you get back is already scoped to this app, and every later call is identified by the token alone.

The endpoints in play: POST /guest, GET /me, POST /estimate, POST /run, GET /jobs/{job_id} and POST /run-stream. The request body of /estimate, /run and /run-stream is the input object itself, posted bare. There is no wrapper key around it: the object whose first field is task is the whole body.

The task field

This comes before everything else because it decides what you get back. Odds Desk is four worksheets over one basket, not one worksheet with options. task selects the lane, the lane fixes which leg keys are populated and which named checks come back, and the lanes are never blended. The envelope around the answer is identical in all four cases, so one parser handles all of them; what changes is the per-leg keys and the checks list.

taskthe lanewhat it returns
read The inspection lane What each leg actually resolves on, before anyone thinks about size. Per-leg resolves_on, ambiguity, implied_read and movers[] — dated, checkable items that would move the market, each naming where it would be checked. Six named checks. This is a reading of the paste, not research: there is no browser behind it, so its value is in exposing what the question as written does and does not settle.
size The decision lane The judgement around arithmetic you already have. Per-leg stake_note, suggested_band, cap_status and concentration_note. Seven checks. Bands are phrased as consequences of your own inputs, never as a single correct number, and a negative-edge leg's band is exactly zero. Kelly is treated as a ceiling, not a target.
plan The production lane The execution worksheet a person works from by hand. Per-leg entry_condition (with a price limit in the same notation you pasted), invalidation, exit and monitor, plus an actions[] calendar that covers every resolution date in the basket. Seven checks. Nothing in it executes without the user acting.
risk The verification lane Run last, and usually run on the plan. Per-leg risk_type, data_quality and settlement_risk across compliance, data quality, privacy, settlement and execution. Eight checks, the last of which is a go / hold / revise gate with its condition attached. Eligibility is flagged, never assured.

task is required. A missing or unrecognised value does not error: the closest lane is chosen, that lane's contract is produced in full, and the first sentence of exec_summary says which lane it picked. That is a worse outcome than sending the field, because your code cannot branch on a sentence. Send it.

One worked minimal input per lane. The bare object is the request body of /estimate, /run and /run-stream — note there is no input key anywhere:

read

{
  "task": "read",
  "basket": "market | venue | side | price | resolves | source\nFed cuts at the December 2026 FOMC | Kalshi | YES | 62c | 2026-12-10 | FOMC statement\nCPI YoY above 3.0% for November 2026 | Kalshi | YES | 0.48 | 2026-12-10 | BLS CPI release\nIncumbent wins the 2026 GA runoff | Polymarket | YES | +150 |  | ",
  "currency": "USD",
  "context": "First pass. I have not decided stakes yet, I want to know what these actually settle on."
}

size

{
  "task": "size",
  "basket": "market | venue | side | price | stake | fair | resolves | source | theme | liquidity\nFed cuts at the December 2026 FOMC | Kalshi | YES | 62c | 3000 | 71% | 2026-12-10 | FOMC statement | rates | 12000\nFed cuts at the December 2026 FOMC | Polymarket | NO | 41c | 1200 | 33% | 2026-12-10 | FOMC statement | rates | 40000\nCPI YoY above 3.0% for November 2026 | Kalshi | YES | 0.48 | 2500 | 44% | 2026-12-10 | BLS CPI release | rates | 6000\nIncumbent wins the 2026 GA runoff | Polymarket | YES | +150 | 1800 |  |  |  | politics | 900",
  "bankroll": "25k",
  "currency": "USD",
  "kelly_fraction": "half",
  "per_leg_cap_pct": "5",
  "theme_cap_pct": "15",
  "context": "Personal account, no leverage. I am comfortable holding to settlement."
}

plan

{
  "task": "plan",
  "basket": "market: Fed cuts at the December 2026 FOMC\nvenue: Kalshi\nside: YES\nprice: 62c\nstake: 3000\nfair: 71%\nresolves: 2026-12-10\nsource: FOMC statement\ntheme: rates\nliquidity: 12000\n\nmarket: CPI YoY above 3.0% for November 2026\nvenue: Kalshi\nside: YES\nprice: 0.48\nstake: 2500\nfair: 44%\nresolves: 2026-12-10\nsource: BLS CPI release\ntheme: rates",
  "bankroll": "25000",
  "currency": "USD",
  "kelly_fraction": "half",
  "per_leg_cap_pct": "5",
  "theme_cap_pct": "15",
  "context": "Funded on Kalshi only. Polymarket access is not set up yet.",
  "handoff": {
    "from_lane": "size",
    "verdict": "The rates theme carries 26% of bankroll against a 15% theme cap, so the group has to shrink before anything is entered.",
    "notes": [
      "L3 is a negative-edge leg at the stated fair value; the size lane put its band at $0.",
      "L1 and L3 settle on the same day off the same policy path."
    ]
  }
}

risk

{
  "task": "risk",
  "basket": "market | venue | side | price | stake | fair | resolves | source | theme\nFed cuts at the December 2026 FOMC | Kalshi | YES | 62c | 3000 | 71% | 2026-12-10 | FOMC statement | rates\nIncumbent wins the 2026 GA runoff | Polymarket | YES | +150 | 1800 |  |  | politics",
  "bankroll": "25000",
  "currency": "USD",
  "kelly_fraction": "half",
  "per_leg_cap_pct": "5",
  "theme_cap_pct": "15",
  "context": "An internal agent places the orders from a queue I approve each morning. Per-order cap is $2,000. No daily cap is configured yet.",
  "handoff": {
    "from_lane": "plan",
    "verdict": "Two legs are enterable as written; the GA runoff leg has no resolution date and cannot be planned.",
    "notes": [
      "The plan lane put the GA runoff leg at cannot-be-entered pending a stated resolution rule.",
      "Entry on L1 is worked in tranches because the stake is a quarter of quoted depth."
    ]
  }
}

The handoff object in the last two is how the lanes chain: run plan, then feed its verdict and a few of its lines into a risk run as {from_lane, verdict, notes[]}. It is treated as the user's own prior work — built on, and contradicted openly in findings if it disagrees with what the basket says, rather than silently overridden.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. The Authorization header is the only thing that identifies the caller, so a 401 means that header, not a missing slug. Get a fresh token from the token page.
payment_required402The balance is below min_credits for this input. Call /estimate first — it is free — and top up. Remember the hold differs per lane, so a balance that clears read may not clear risk.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Mint a token for this app, or sign in for a personal one.
not_found404Unknown job_id, or an unknown slug in the /guest body.
conflict409The same Idempotency-Key was replayed with a different body. Because the body is the input object itself, changing one character of basket — or switching task from plan to risk — is a different body. Derive the key from the input and bump an attempt suffix when the input really changed.
validation_error422A required field is missing or is the wrong type. basket is the one that is usually missing: it is the whole evidence base, and an empty string is not a basket. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry with a delay; do not tight-loop a poll.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the same Idempotency-Key so you are not billed twice for one run.

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 there needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

From code, POST /guest mints an anonymous token. This is the one and only place the app slug appears, and it goes in the JSON body as {"slug": "odds-desk"}. The token that comes back is already scoped to this app, so no later call needs to say which app it is talking to.

A guest token can call /me and /estimate. Running a lane is metered, so it needs a personal token from signing in — a guest run comes back as 403 forbidden.

# The token page is the shortest path. It shows the token this browser already
# holds and hands you a ready-made shell export:
#
#   https://odds-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. This is the ONLY call in
# the whole API that mentions the app slug, and it goes in the JSON body:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "odds-desk"}'
# {"ok":true,"data":{"token":"sk_guest_...","guest_id":"g_...","subject_type":"guest"}}
#
# A guest token can call /me and /estimate. Running a lane is metered, so it
# needs a personal token from signing in on the token page.

2. A tiny client

One helper covers the whole API, because there is only one envelope and only two headers. It adds Authorization, adds Content-Type when there is a body, optionally adds an Idempotency-Key, unwraps data and raises on error. Write it once and the rest of this page is four calls.

# Every call is the same three things: the base URL, your bearer token, and a
# JSON body. Two headers, no more. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://odds-desk.skillsafe.ai/tokens.html

# call <path> [json-body] [idempotency-key]
call() {
  if [ -n "$2" ]; then
    if [ -n "$3" ]; then
      curl -sS -X POST "$BASE/$1" \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        -H "Idempotency-Key: $3" \
        -d "$2"
    else
      curl -sS -X POST "$BASE/$1" \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        -d "$2"
    fi
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

# Unwrap the envelope and fail loudly on the error branch.
unwrap() {
  python3 -c '
import sys, json
p = json.load(sys.stdin)
if not p.get("ok"):
    e = p.get("error", {})
    sys.exit("%s: %s" % (e.get("code"), e.get("message")))
json.dump(p["data"], sys.stdout)
'
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. 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 halfway through a batch.

call me | unwrap
# {"subject_type":"user","username":"you","credits":51234}
#
# subject_type is "guest" or "user". A guest can price a run but not start one.

4. Price the run — free

POST /estimate takes the same body /run takes, creates no job and charges nothing. The input object is exactly what the app's own form submits:

fieldtypemeaning
taskstring, requiredread, size, plan or risk. The lane. See the section above — it decides the whole shape of the answer.
basketstring, requiredThe pasted basket text, as a person would type it. Either a table with a header row — pipe, tab, comma or semicolon delimited, and markdown tables with their --- separator row are fine — or a run of key: value blocks separated by blank lines. Recognised columns: market, venue, side, price, stake, fair, resolves, source, theme, liquidity, notes. This is the entire evidence base: a leg that is not in here does not exist for the run.
bankrollstringThe account size the percentages are taken against, as typed: "25000" or "25k" both parse. Leave it out and the sizing lane has no denominator, which comes back as a finding rather than an error.
currencystring"USD". Every money figure in the reply carries this unit.
kelly_fractionstringfull, half, third or quarter. The fraction of full Kelly the bands are expressed at. Your number, never a default of the app's choosing.
per_leg_cap_pctstringThe per-leg cap as a percentage of bankroll, e.g. "5". Drives cap_status on every leg.
theme_cap_pctstringThe cap on any one theme, e.g. "15". Legs that share a theme resolve off the same world, so this is the number that catches the correlated-bet-as-diversification mistake.
contextstring, optionalFree text about the account, jurisdiction or mandate: which venues are funded, whether an agent places the orders, what the account is not allowed to hold. Read, never argued with, and recorded in assumptions.
handoffobject, optional{from_lane, verdict, notes[]}. Set it when one lane runs on another lane's output — the usual chain is size then plan then risk. Treated as the caller's own prior work.
engine_factsobjectThe arithmetic, sent as ground truth. See below — this is the field that makes the answer accountable.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

How a quote is written

Prices are read in whatever notation the paste uses, per leg, and the reply answers in the same notation. All five of these are accepted for the same leg:

notationexamplereads as
cents62c62% implied
decimal probability0.6262% implied
percent62%62% implied
American odds+150 / -20040% / 66.7% implied
fractional3/240% implied

The convention that decides how every number reads: a leg's price is the cost of the side named in side, and fair is your own probability for that same side. A NO leg quoted at 45c with a fair value of 60% is a stated 15-point edge on NO, not on YES. If a leg's numbers only make sense under the other reading, that is exactly what findings is for — it will say so rather than quietly reinterpret you.

engine_facts, honestly

In the browser, engine_facts is computed for free before the run by a local parser and pricer: implied probabilities, edges in points, expected values, full and fractional Kelly, cap status, theme totals, the resolution calendar, and a flags[] array of deterministic findings each carrying a unique key. An API caller may compute and send their own, or omit the field entirely — the run still works, and the model reads basket either way.

What makes it worth sending is the reconciliation contract. Two rules hold when the field is present: no engine number is ever contradicted (a disagreement shows up in findings with the arithmetic, not as a quietly different figure), and every flags[].key you send comes back exactly once in coverage_check, in the order you sent them. That turns a fact your own tooling already established into something the reply is held to. An entry with addressed: false is a legitimate answer — the flag deliberately set aside with the reason in note — and is a different thing from silence. A key that never appears at all is a failed run, not a passing one.

The shape, in full:

{
  "format": "pipe table",
  "currency": "USD",
  "bankroll": 25000,
  "kelly_fraction": "half",
  "per_leg_cap_pct": 5,
  "theme_cap_pct": 15,
  "totals": {
    "leg_count": 4,
    "priced_legs": 4,
    "legs_with_fair_value": 3,
    "total_stake": 8500,
    "total_profit_if_all_win": 6042.15,
    "total_expected_value": 331.2,
    "expected_value_pct_of_stake": 3.9,
    "kelly_stake_total": 2604.17,
    "at_risk_pct_of_bankroll": 34,
    "worst_single_theme_loss": 6700,
    "worst_single_theme": "rates"
  },
  "legs": [
    {
      "ref": "L1",
      "market": "Fed cuts at the December 2026 FOMC",
      "venue": "Kalshi",
      "side": "YES",
      "theme": "rates",
      "quote": "62c",
      "implied_pct": 62,
      "fair_pct": 71,
      "edge_pts": 9,
      "stake": 3000,
      "stake_pct_of_bankroll": 12,
      "profit_if_win": 1838.71,
      "expected_value": 435,
      "kelly_full_pct": 23.68,
      "kelly_stake_at_fraction": 2960.53,
      "cap_status": "over",
      "resolves": "2026-12-10",
      "days_to_resolution": 113,
      "resolution_source": "FOMC statement",
      "liquidity": 12000,
      "notes": ""
    }
  ],
  "legs_omitted": 0,
  "themes": [
    { "theme": "rates", "legs": 3, "stake": 6700, "refs": ["L1", "L2", "L3"] },
    { "theme": "politics", "legs": 1, "stake": 1800, "refs": ["L4"] }
  ],
  "calendar": [
    { "month": "2026-12", "legs": 3, "stake": 6700 },
    { "month": "undated", "legs": 1, "stake": 1800 }
  ],
  "flags": [
    { "key": "CAP-BREACH@L1", "id": "CAP-BREACH", "severity": "high", "legs": ["L1"],
      "label": "Stake breaches the per-leg cap - $3,000 is 12% of bankroll against a 5% cap" },
    { "key": "NEG-EDGE@L3", "id": "NEG-EDGE", "severity": "critical", "legs": ["L3"],
      "label": "Negative edge as stated - fair 44% against a 48% quote is -4.0 points" },
    { "key": "FAIR-MISSING@L4", "id": "FAIR-MISSING", "severity": "high", "legs": ["L4"],
      "label": "No stated fair probability - no edge, no expected value, no Kelly size" },
    { "key": "THEME-CONC@L1+L2+L3", "id": "THEME-CONC", "severity": "high",
      "legs": ["L1", "L2", "L3"],
      "label": "Theme exposure breaches the theme cap - rates carries $6,700, 26.8% against 15%" }
  ],
  "flags_omitted": 0
}

flags[].key is the id plus the legs it fired on (NEG-EDGE@L3, THEME-CONC@L1+L2+L3), with a #2 suffix if the same id fires twice on the same legs. The key, not the id, is what coverage_check is keyed on, because the same id fires once per leg and a bare id could not be reconciled exactly once. These are the ids worth raising by hand if you compute your own facts:

idseverityfires when
PRICE-BADcriticalThe quote could not be read in any of the five notations.
NEG-EDGEcriticalThe stated fair value is below the quote, so the leg is negative-edge on the caller's own numbers.
KELLY-NEGcriticalThe Kelly fraction computes to zero or below.
BANKROLL-OVERcriticalTotal stake exceeds the stated bankroll.
RESOLVE-PASTcriticalThe resolution date has already passed.
STAKE-MISSINGhighNo planned stake on a leg.
FAIR-MISSINGhighNo stated fair probability, so the leg has no edge, no expected value and no Kelly size.
OVER-KELLYhighThe planned stake exceeds the chosen Kelly fraction.
CAP-BREACHhighA leg breaches per_leg_cap_pct.
THEME-CONChighA theme breaches theme_cap_pct.
LIQ-SHORThighThe stake is larger than the quoted depth in liquidity.
RESOLVE-MISSINGhighNo resolution date on a leg.
SOURCE-MISSINGhighNo named resolution source.
ROW-UNREADhighA pasted row was not read as a leg at all.
FAIR-EQ-PRICEmediumThe stated fair value equals the market, so there is nothing to size.
DUP-LEGmediumThe same market and side appears twice; the exposure is double-counted.
BOTH-SIDESmediumBoth sides of one market are held, netting the directional view out.
OVERROUNDmediumBoth sides cost more than 100 together — that gap is the venue's take.
ARB-LOOKmediumBoth sides cost less than 100 together. Re-check both quotes are live, same market, same resolution before believing it.
SOURCE-VAGUEmediumThe resolution source is not a nameable authority.
SIDE-MISSINGmediumNo side stated, so the leg is read as YES.
BANKROLL-MISSINGmediumNo bankroll given, so nothing can be expressed as a share of it.
THIN-EDGElowThe edge sits inside plausible fees and slippage.
LONG-DATEDlowCapital is tied up beyond a year.
SINGLE-LEGlowA basket of one is concentration by construction.
VENUE-MISSINGlowNo venue named on a leg.

What /estimate returns is the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. hold_credits is a reservation, not a price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower, often a small fraction of the hold. Budget against hold_credits, report against charged_credits.

The hold differs per lane, because the lanes do not produce the same amount of output: risk carries eight checks and three keys per leg, read carries six checks and a movers[] array. So estimate the lane you are about to run. A quote taken for read and then spent on risk is how a batch job discovers 402 payment_required at leg fourteen.

# The body of /estimate is the INPUT OBJECT ITSELF. There is no wrapper key:
# no "input", no "fields" -- the object starting with "task" is the whole body.
INPUT='{"task": "size", "basket": "market | venue | side | price | stake | fair | resolves | source | theme | liquidity\nFed cuts at the December 2026 FOMC | Kalshi | YES | 62c | 3000 | 71% | 2026-12-10 | FOMC statement | rates | 12000\nFed cuts at the December 2026 FOMC | Polymarket | NO | 41c | 1200 | 33% | 2026-12-10 | FOMC statement | rates | 40000\nCPI YoY above 3.0% for November 2026 | Kalshi | YES | 0.48 | 2500 | 44% | 2026-12-10 | BLS CPI release | rates | 6000\nIncumbent wins the 2026 GA runoff | Polymarket | YES | +150 | 1800 |  |  |  | politics | 900", "bankroll": "25000", "currency": "USD", "kelly_fraction": "half", "per_leg_cap_pct": "5", "theme_cap_pct": "15", "context": "Personal account, no leverage. Comfortable holding to settlement.", "engine_facts": {"format": "pipe table", "currency": "USD", "bankroll": 25000, "kelly_fraction": "half", "per_leg_cap_pct": 5, "theme_cap_pct": 15, "totals": {"leg_count": 4, "priced_legs": 4, "legs_with_fair_value": 3, "total_stake": 8500, "total_expected_value": 331.2, "kelly_stake_total": 2604.17, "at_risk_pct_of_bankroll": 34, "worst_single_theme_loss": 6700, "worst_single_theme": "rates"}, "legs": [{"ref": "L1", "market": "Fed cuts at the December 2026 FOMC", "venue": "Kalshi", "side": "YES", "theme": "rates", "quote": "62c", "implied_pct": 62, "fair_pct": 71, "edge_pts": 9, "stake": 3000, "stake_pct_of_bankroll": 12, "profit_if_win": 1838.71, "expected_value": 435, "kelly_full_pct": 23.68, "kelly_stake_at_fraction": 2960.53, "cap_status": "over", "resolves": "2026-12-10", "days_to_resolution": 113, "resolution_source": "FOMC statement", "liquidity": 12000, "notes": ""}], "legs_omitted": 3, "themes": [{"theme": "rates", "legs": 3, "stake": 6700, "refs": ["L1", "L2", "L3"]}, {"theme": "politics", "legs": 1, "stake": 1800, "refs": ["L4"]}], "calendar": [{"month": "2026-12", "legs": 3, "stake": 6700}, {"month": "undated", "legs": 1, "stake": 1800}], "flags": [{"key": "CAP-BREACH@L1", "id": "CAP-BREACH", "severity": "high", "legs": ["L1"], "label": "Stake breaches the per-leg cap - $3,000 is 12% of bankroll against a 5% cap"}, {"key": "NEG-EDGE@L3", "id": "NEG-EDGE", "severity": "critical", "legs": ["L3"], "label": "Negative edge as stated - fair 44% against a 48% quote is -4.0 points"}, {"key": "FAIR-MISSING@L4", "id": "FAIR-MISSING", "severity": "high", "legs": ["L4"], "label": "No stated fair probability - the leg has no edge, no EV and no Kelly size"}, {"key": "THEME-CONC@L1+L2+L3", "id": "THEME-CONC", "severity": "high", "legs": ["L1", "L2", "L3"], "label": "Theme exposure breaches the theme cap - rates carries $6,700, 26.8% against a 15% cap"}], "flags_omitted": 0}}'

call estimate "$INPUT" | unwrap
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#  "hold_credits":2480,"min_credits":340,"sponsor_enabled":false}
#
# estimate is FREE: no job, no charge. hold_credits is a RESERVATION against the
# full output cap, not the price -- charged_credits after settlement is normally
# a fraction of it. And the hold differs per lane, so estimate the lane you are
# about to run: pricing "read" tells you nothing useful about "risk".

5. Run it, then poll

POST /run takes the same bare input object and returns a job_id; poll GET /jobs/{job_id} until status is succeeded or failed. The worksheet 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 header. It is not formally required by the endpoint and it is required in practice: derive it from the input the way the web app does, a content hash plus an attempt counter (odds-desk:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a retry safe after a network blip. Replaying a key with a different body is a 409 conflict — and because the body is the input object itself, switching task from plan to risk over the same basket is a different body. Bump the attempt suffix whenever the input actually changed.

Poll with a delay of a second or two. A tight loop earns a 429 rate_limited and gets you the answer no sooner.

# Derive the key from the input so a retry after a network blip replays the same
# job instead of billing a second run. Same body + same key = same job.
KEY="odds-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(call run "$INPUT" "$KEY" | unwrap \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["job_id"])')

# Poll until the job reaches a terminal status. Do not tight-loop: 429 is real.
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

# The terminal job looks like this -- the worksheet is a STRING at
# data.output.output, so it needs unwrapping twice:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"size\",\"basket_name\":\"Dec 2026 rates ...\"}"},
#   "charged_credits":612,"truncated":false}}
printf '%s' "$OUT" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events, with the same bare input object as the body and the same Idempotency-Key discipline. Each delta event carries {"text": "..."}, a chunk of the worksheet JSON; a job event arrives first with the job_id; the final done event carries status, charged_credits and truncated. An error event carries {code, message, job_id} instead.

One thing to know before you write the reader: on an idempotent replay the endpoint answers with a plain JSON envelope rather than a stream, because there is nothing left to generate. Check the response content-type for text/event-stream before you start parsing frames, and fall back to reading data if it is missing.

The practical tip for a progress display: do not try to parse the partial JSON. Watch for key names arriving in the accumulating text. The appearance of "legs", then "checks", then "actions", then "coverage_check", then "summary" is the order the worksheet is written in, and substring matching on the quoted key name is enough to advance a stage label. It costs nothing and never throws.

# Server-sent events. Same bare input object as the body, same Idempotency-Key
# discipline; the only additions are the Accept header and -N to stop buffering.
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\":\"size\",\"basket_name\":\"Dec 2026 rates"}
# event: delta  {"text":" and one runoff\",\"posture\":\"caution\","}
# event: done   {"job_id":"job_...","status":"succeeded","charged_credits":612,
#                "truncated":false}
#
# On an idempotent replay the endpoint answers with plain JSON instead of a
# stream -- check the response content-type before you parse events.

7. Parse the result

data.output.output is a string holding one JSON object, so you unwrap twice: once for the envelope, once for the reply. Take everything from the first { to the last } before parsing — that one slice is what makes a caller robust against a stray code fence or a trailing newline, and it is the same thing the web app does.

Here is an abbreviated size worksheet for the basket above, structurally complete. Two of the four legs are shown; a real reply carries one entry per leg in engine_facts.legs, with the same ref in the same order.

{
  "lane": "size",
  "basket_name": "December 2026 rates, plus one runoff",
  "posture": "blocked",
  "verdict": "The rates theme carries $6,700 against a $3,750 theme cap, so the group has to shrink before any single leg's size is the question.",
  "exec_summary": "Kelly is a ceiling here, not a target. On the stated fair values, half Kelly allows $2,961 on L1 against a planned $3,000, but the per-leg cap of 5% of a $25,000 bankroll is $1,250, so the cap binds first. L1, L2 and L3 all settle on the same December policy path and are one bet at $6,700, which is 26.8% of bankroll against a stated 15% theme cap. L3 is negative-edge on the stated 44% against a 48% quote, and L4 carries no fair value at all, so neither is sizeable as written.",
  "legs": [
    {
      "ref": "L1",
      "market": "Fed cuts at the December 2026 FOMC",
      "headline": "Cap binds well below the Kelly allowance",
      "detail": "Planned $3,000 against a half-Kelly figure of $2,961 on the stated 71% fair value, a ratio of 1.01. The binding constraint is not Kelly but the stated 5% per-leg cap, which is $1,250. Quoted depth is $12,000, so a $1,250 fill is comfortable.",
      "severity": "high",
      "stake_note": "Planned $3,000 against half Kelly of $2,961 (1.01x) and a per-leg cap of $1,250.",
      "suggested_band": "between $0 and $1,250, which is the stated per-leg cap; half Kelly on your stated 71% would have allowed $2,961",
      "cap_status": "over",
      "concentration_note": "Shares the rates theme and the 2026-12-10 date with L2 and L3; the three cannot be sized independently."
    },
    {
      "ref": "L3",
      "market": "CPI YoY above 3.0% for November 2026",
      "headline": "Negative edge on the stated fair value",
      "detail": "The stated fair value of 44% sits below the 48% quote, a -4.0 point edge on the caller's own numbers. Expected value on a $2,500 stake is negative. There is no Kelly fraction to take a share of.",
      "severity": "critical",
      "stake_note": "Planned $2,500 against a Kelly stake of $0 at any fraction.",
      "suggested_band": "$0 - the stated fair value is below the quote",
      "cap_status": "within",
      "concentration_note": "Also a rates leg resolving on 2026-12-10, so it adds to the theme total it cannot justify."
    }
  ],
  "findings": [
    {
      "id": "F-001",
      "severity": "critical",
      "title": "Rates theme is 26.8% against a 15% cap",
      "detail": "L1, L2 and L3 carry $6,700 of the $25,000 bankroll and all three resolve off the same December policy path on the same day. The stated theme cap is $3,750.",
      "legs": ["L1", "L2", "L3"],
      "mitigation": "Cut the theme total to $3,750 or below, or restate the theme cap and say why the correlation is acceptable."
    },
    {
      "id": "F-002",
      "severity": "high",
      "title": "L4 has no stated fair value",
      "detail": "Without a fair probability there is no edge, no expected value and no Kelly size, so the $1,800 planned stake does not follow from anything in the input.",
      "legs": ["L4"],
      "mitigation": "State a fair probability for the YES side of the runoff market, or remove the leg."
    }
  ],
  "checks": [
    { "check": "Bankroll, currency and Kelly fraction are all stated", "status": "pass",
      "evidence": "$25,000, USD, half Kelly", "requirement": "All three present in the input." },
    { "check": "Every leg carries a stated fair probability", "status": "fail",
      "evidence": "L4 has no fair value", "requirement": "A stated probability for the side named on every leg." },
    { "check": "Each Kelly figure follows from the stated edge at the stated fraction", "status": "pass",
      "evidence": "L1: 23.68% full Kelly, $2,961 at half on $25,000", "requirement": "Engine figures reproduced without contradiction." },
    { "check": "No leg exceeds the per-leg cap", "status": "fail",
      "evidence": "L1 at 12% and L3 at 10% against a 5% cap", "requirement": "Every leg at or under $1,250." },
    { "check": "No theme exceeds the theme cap", "status": "fail",
      "evidence": "rates at $6,700, 26.8%", "requirement": "Theme totals at or under $3,750." },
    { "check": "Total stake sits inside the bankroll", "status": "pass",
      "evidence": "$8,500 of $25,000", "requirement": "Total stake under bankroll." },
    { "check": "Every negative-edge leg is sized at zero", "status": "fail",
      "evidence": "L3 planned at $2,500 on a -4.0 point edge", "requirement": "A $0 band on any leg whose fair value is below its quote." }
  ],
  "actions": [
    { "id": "A-001", "stage": "before-entry",
      "action": "Reduce the rates theme total to $3,750 or below, or restate the theme cap with the reason.",
      "trigger": "Before any leg in the rates theme is entered.",
      "evidence": "THEME-CONC@L1+L2+L3" },
    { "id": "A-002", "stage": "before-entry",
      "action": "State a fair probability for L4 or drop the leg.",
      "trigger": "Before L4 is entered.", "evidence": "FAIR-MISSING@L4" },
    { "id": "A-003", "stage": "at-entry",
      "action": "Work each fill against the stated per-leg cap of $1,250 rather than the planned stake.",
      "trigger": "At order entry on L1 and L3.", "evidence": "CAP-BREACH@L1" }
  ],
  "coverage_check": [
    { "key": "CAP-BREACH@L1", "addressed": true,
      "note": "L1's band is the $1,250 cap; A-003 carries it into entry." },
    { "key": "NEG-EDGE@L3", "addressed": true,
      "note": "L3's band is $0 and check 7 fails on it." },
    { "key": "FAIR-MISSING@L4", "addressed": true, "note": "F-002 and A-002." },
    { "key": "THEME-CONC@L1+L2+L3", "addressed": true,
      "note": "F-001 and A-001; it is also what sets the posture to blocked." }
  ],
  "assumptions": [
    "The bankroll of $25,000 is the whole account available to this basket, not a sleeve of a larger one.",
    "L1 and L2 are the same underlying market at two venues rather than two different questions."
  ],
  "open_questions": [
    "Is the 15% theme cap meant to apply to the resolution date as well as the theme label?",
    "Is L2 held as a hedge on L1 or as an independent view?"
  ],
  "evidence_gaps": [
    "Whether the quoted depth on L4 is still $900, which would be checked on the venue's order book at entry.",
    "Whether the CPI market resolves on the first print or on a revision, which would be checked in the venue's own rules page."
  ],
  "summary": "Nothing here is a recommendation to take or avoid any of these positions. On the numbers as typed, the theme total and two unsizeable legs are what stand between this basket and a coherent set of sizes; the per-leg arithmetic is secondary to that."
}

Then the assertions worth keeping in your own code, because they are the contract and not style:

One thing not to build: a check that the reply recommends something. It will not. Every band is phrased as a consequence of the numbers you sent, every action is conditional on your own decision to proceed, and eligibility is flagged rather than assured. If your product needs a recommendation, that decision belongs to your user, not to this API.

# The worksheet is a JSON STRING inside the envelope, so unwrap twice.
SHEET=$(printf '%s' "$OUT" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$SHEET" | python3 -c '
import sys, json
s = json.load(sys.stdin)

# Fixed check counts per lane -- assert them, they are the contract.
expected = {"read": 6, "size": 7, "plan": 7, "risk": 8}[s["lane"]]
assert len(s["checks"]) == expected, "wrong number of checks for this lane"

print(s["lane"], s["posture"], "-", s["verdict"])
print(s["exec_summary"])

for leg in s["legs"]:
    print(leg["ref"], leg["severity"], leg["headline"])

for f in s["findings"]:
    print(f["id"], f["severity"], f["title"], "legs:", ",".join(f["legs"]))

for c in s["checks"]:
    print(("[%s]" % c["status"]).ljust(10), c["check"])

for a in s["actions"]:
    print(a["id"], a["stage"], a["action"])

# Every engine flag key comes back exactly once, in order.
print(len(s["coverage_check"]), "coverage entries")
'

The output contract

One JSON object, and the same envelope for all four lanes — so one parser covers every lane and the only per-lane branching is which leg keys you read and how many checks you expect.

keytypemeaning
lanestringThe lane that answered: read, size, plan or risk. Compare it to the task you sent.
basket_namestringA short name for this basket, drawn from what the legs have in common. Useful as a title and as a grouping key across runs.
postureenumclear, caution or blocked. blocked when a critical engine flag or a critical finding stands in the way of entering the basket as written; caution when it is workable but something material is unresolved; clear only when nothing above low is outstanding for this lane. The one value a gate should branch on.
verdictstringOne sentence naming the single thing that decides the posture.
exec_summarystringTwo to four sentences a desk head could read and act on without the rest.
legsobject[]One entry per leg in engine_facts.legs, same ref and same order. Common keys: ref, market, headline (six to twelve words), detail (two to four sentences), severity. Then the lane's own keys — see the table below.
findingsobject[]{id, severity, title, detail, legs[], mitigation}. Ids are F-001, F-002, sequential and zero-padded to three digits. legs[] holds the refs it applies to; mitigation is the concrete change that closes it. May be [].
checksobject[]{check, status, evidence, requirement}. Six entries for read, seven for size, seven for plan, eight for risk — always in the lane's fixed order. evidence is the specific thing in the basket that decided it; requirement is what would make it pass.
actionsobject[]{id, stage, action, trigger, evidence}. Three to twelve entries, ids A-001 upward, ordered by stage. trigger is the observable condition or date that starts it; evidence names the leg refs or the engine flag key it came from.
coverage_checkobject[]{key, addressed, note}. One entry per engine_facts.flags[].key, exactly once, in the order sent. addressed: false means deliberately set aside, with the reason in note.
assumptionsstring[]What had to be assumed because the paste did not say. An explicit constraint in context is recorded here rather than argued with.
open_questionsstring[]Questions only the caller can answer, whose answers would change the worksheet.
evidence_gapsstring[]What would need to be looked up, and where it would be checked. This is where the absence of a browser is made explicit instead of papered over.
summarystringOne closing paragraph, introducing no new facts.

All eight array fields are always present, even when empty. Numbers carry their unit: points for probability differences, the input currency for money, days for time.

The lane-specific leg keys

lanekeys on each legs[] entrywhat they hold
read resolves_on, ambiguity, implied_read, movers[] resolves_on is the settlement condition in plain words; if the paste does not say, it says exactly that and the leg's severity is at least high. ambiguity is the specific reading two reasonable people would disagree on — a threshold with no operator, a date with no timezone, "announced" versus "completed". implied_read puts the quote's implied probability next to your stated fair value with the gap in points, attributing the fair value to you. movers[] is two to five dated, checkable items, each naming where it would be checked; never a news claim and never a prediction.
size stake_note, suggested_band, cap_status, concentration_note stake_note is the planned stake against the Kelly stake with the currency and the ratio. suggested_band is a range phrased as a consequence of your inputs, never a single correct number; a negative-edge leg's band is exactly zero, and a leg with no stated fair probability gets no band at all. cap_status is within, at or over. concentration_note names what the leg shares with the others — theme, venue, resolution date, or the same underlying event under a different question.
plan entry_condition, invalidation, exit, monitor entry_condition is the observable condition plus a price limit in the same notation you pasted — cents if you wrote cents — and it says the fill is worked in tranches when the stake exceeds quoted depth. invalidation is the dated or measurable event that means the stated thesis is wrong. exit names how the position ends: held to settlement, closed at a stated level, or reduced on a stated date. monitor is what is checked, when, and where.
risk risk_type, data_quality, settlement_risk risk_type is the dominant category: settlement, data-quality, liquidity, concentration, compliance, operational or none. data_quality is what is wrong or unverifiable about the leg's inputs. settlement_risk is how the leg could resolve in a way you do not expect — an ambiguous rule, a revisable statistic, a source that can be delayed, a market that can void.

The enums

fieldvaluesnotes
postureclear, caution, blockedSet for this lane: a basket can be clear for read and blocked for size, because the lanes are outstanding on different things.
legs[].severitynone, low, medium, high, criticalnone is a real value and means the leg is fine in this lane, not that the leg was skipped.
findings[].severitycritical, high, medium, lowNo none here: a finding exists because something is wrong.
checks[].statuspass, fail, partial, unknownunknown is a legitimate answer and is preferred over a guess — a paste with no jurisdiction stated cannot prove eligibility. partial means the practice is present but incomplete.
actions[].stagebefore-entry, at-entry, while-open, at-resolutionThe array is ordered by stage, so it renders as a timeline without sorting.
legs[].cap_statuswithin, at, oversize lane only; copied from engine_facts.legs[].cap_status when you sent one.

The per-lane checks, in order

These are fixed. Render by index, diff by index, and assert the count — a wrong count is a malformed reply, not a variant.

read (6)
 1. Every leg names what it resolves on
 2. The resolution source is an authority that can be checked on the day
 3. Each market question has a single reading
 4. Every resolution date is stated and still in the future
 5. The stated fair value is distinguishable from the market price
 6. Nothing in this read came from outside the paste

size (7)
 1. Bankroll, currency and Kelly fraction are all stated
 2. Every leg carries a stated fair probability
 3. Each Kelly figure follows from the stated edge at the stated fraction
 4. No leg exceeds the per-leg cap
 5. No theme exceeds the theme cap
 6. Total stake sits inside the bankroll
 7. Every negative-edge leg is sized at zero

plan (7)
 1. Order prerequisites are named for every venue in the basket
 2. Every enterable leg has an entry condition with an explicit price limit
 3. Every leg has an invalidation trigger
 4. Exit or settlement handling is stated per leg
 5. The monitoring calendar covers every resolution date in the basket
 6. Quoted depth is respected by the stated entry method
 7. Nothing in the plan executes without the user acting

risk (8)
 1. Jurisdiction and eligibility are addressed or explicitly unknown
 2. Resolution-source integrity and manipulation surface are assessed per leg
 3. Input data quality is assessed for every leg
 4. Privacy of the pasted material is assessed
 5. Venue and counterparty concentration is quantified
 6. Execution and settlement risk is stated per leg
 7. Automation authority limits are reviewed or recorded as not applicable
 8. A go / hold / revise gate is stated with its condition

The worksheet never echoes a secret. If the paste carries an account id, an API key or a position tied to a named person, the risk lane names the field and says to remove it — the value itself does not appear in detail, evidence or summary.

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 on the finished job and on the streaming done event. What you hold then is a prefix of the worksheet, not the worksheet. The realistic failure looks like this: legs is complete, checks is complete, and coverage_check, evidence_gaps and summary are missing or cut mid-string.

Check the flag before you treat a reply as complete, and note that a truncated reply fails the coverage contract by construction — the flags that would have been reconciled at the end are simply not there. Do not report that as a modelling failure.

Render what parsed, and tell the user it was cut short. That is the whole rule. Show the legs and the checks you actually have, label the worksheet as incomplete, and say what is missing. Do not present a clipped answer as a complete one: on this app that is not a cosmetic problem, because a sizing worksheet missing its theme reconciliation reads exactly like a sizing worksheet that found no theme problem.

The right recovery is a retry, not a repair. Resubmit with a retry_note asking for fewer, denser findings and shorter detail fields, and with the attempt suffix on the Idempotency-Key incremented so the new body is not read as a replay of the old key. Repairing truncated JSON by appending closing braces produces something that parses and is not what the model meant.