← Earnings Desk / API
Get a token

Driving Earnings Desk from your own code

Everything the web app does over HTTP, you can do. The base URL is https://api.skillsafe.ai/v1/app-api. Every call takes Authorization: Bearer <token> except the one that mints a token.

Earnings Desk is a reading tool for professionals working their own material. It is not investment advice, not a recommendation and not a research report. The prompt forbids the model from originating a rating, a price target or a buy/sell/hold call, and your integration should not present its output as one.

The envelope

Success is {"ok": true, "data": { ... }}. Failure is {"error": {"code": "...", "message": "...", "details": { ... }}} with a matching HTTP status. Read error.code, not the message, when you branch.

codestatuswhat to do
UNAUTHORIZED401No token, or a token this app does not accept. Mint a new one from the tokens page.
FORBIDDEN403The token is valid but not for this app, or the call needs a personal token and you sent a guest one.
VALIDATION_ERROR400The input object is malformed. error.details names the field.
INSUFFICIENT_CREDITS402The balance cannot cover min_credits. Call /estimate first and compare with /me.
RATE_LIMITED429Back off and retry. Do not tight-loop.
JOB_FAILED5xxThe run reached a terminal failure; error.message carries the reason.

The task field comes first

Earnings Desk is one app with four lanes over one work object, and task selects the lane. Every lane takes the same input object and returns the same envelope; only the meaning of the inner sections changes.

taskwhat it producesverdict valuesartifact_json.kindlines[]
scorecardthe beat/miss table, the quality-of-earnings checks, and where the beat came fromclean-beat · low-quality-beat · mixed · in-line · missearnings_scorecardrequired
guidanceevery guide, the move, and how much of a raise is only the beat flowing throughraised · reaffirmed · lowered · mixed · no-guideguidance_bridgerequired
notethe desk note, with every figure sourced and the gaps marked rather than filledready-to-send · needs-numbers · needs-reviewdesk_noteoptional
thesiseach pillar of the stated case tested, with silence marked unsettledstrengthened · intact · under-pressure · brokenthesis_updateoptional

Step 1 — a tiny client

# Every call in this guide uses these two values.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"      # Replace "YOUR_TOKEN" with the token from the tokens page.

# The envelope is the same on every endpoint:
#   success -> {"ok": true, "data": { ... }}
#   failure -> {"error": {"code": "...", "message": "...", "details": {...}}}
call() { curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" ${3:+-d "$3"}; }

Step 2 — get a token

The guest endpoint needs no authorization and its body key is slug. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token from the tokens page.

# A guest token needs NO Authorization header. The body key is "slug".
curl -s -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "earnings-desk"}'

# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest", ...}}
# A guest can call /me and /estimate. Running a lane is metered and needs a
# personal token - get one from https://earnings-desk.skillsafe.ai/tokens.html

Step 3 — who am I

curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"

# -> {"ok":true,"data":{"subject_type":"user","username":"...","credits":123456}}
# subject_type is "guest" or "user". Only a "user" can run a lane.

Step 4 — price the run (free)

/estimate creates no job and charges nothing. The request body is the input object — it is not wrapped in a {"input": ...} field. Price each lane separately: the four have different prompts and different output caps, so lane A's hold is not lane B's.

# /estimate is FREE. It creates no job and charges nothing.
# The body IS the input object - it is not wrapped in anything.
curl -s -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"task": "scorecard", "release": "...", "estimates": "...", "period": "Q3 2025", "reading": "balanced", "masking_on": true, "prescan": {"readable": true, "flags": []}}'

# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#      "markup_bps":1000,"hold_credits":3200,"min_credits":400,
#      "sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged. It prices the full output cap; the
# actual charge is usually far lower. Estimate each lane separately - the four
# lanes have different prompts and different caps.

The input object, field by field

fieldtypemeaning
taskstringThe lane. One of scorecard, guidance, note, thesis. Missing or unrecognised, the model picks the closest lane and names its choice in summary.
releasestringThe results release. Required. Clipped in the MIDDLE at 26,000 characters, with the cut announced in-band.
estimatesstringThe estimate table as text. Optional; without it nothing can be called a beat or a miss.
prior_guidancestringThe guide given last time. Optional; without it the bridge has nothing to move from.
thesisstringThe pillars of the case. Required in substance by the thesis lane.
notesstringAnything the numbers do not say.
periodstringThe period the prescan read, e.g. Q3 2025, or unknown.
readingstringbalanced, strict or fast.
masking_onbooleanWhether identities were replaced with placeholders in the browser. When true, the reply must never name a real company or person.
redactionarrayOne entry per masked identity: {kind, placeholder, length}. The literal value is never sent.
prescanobjectThe browser's own measurements: period, coverage, stats, compares[], guidance[], prior_guidance[], guidance_delta[] and flags[]. The model must answer every flags[].flag_id by id.
upstreamstringThe previous lane's output when a handoff button was used.

A minimal but complete request body:

{
  "task": "scorecard",
  "release": "Q3 2025 results\n\n(in millions, except per share data)\n\n| Line item | Q3 2025 | Q3 2024 |\n| --- | --- | --- |\n| Total revenue | 1,284.6 | 1,088.2 |\n| Operating income | 214.0 | 150.1 |\n| Adjusted EPS | 0.87 | 0.61 |",
  "estimates": "Metric,My estimate,Consensus\nRevenue,1250.0,1262.4\nAdjusted EPS,0.82,0.84",
  "prior_guidance": "",
  "thesis": "",
  "notes": "",
  "period": "Q3 2025",
  "reading": "balanced",
  "masking_on": true,
  "redaction": [],
  "prescan": {
    "readable": true,
    "period": "Q3 2025",
    "flags": []
  },
  "upstream": ""
}

Step 5 — run it and poll

Always send an Idempotency-Key. Hash the lane, the input and an attempt counter; a retry after a network blip must reuse the exact same key or it bills twice.

# Metered. Send an Idempotency-Key so a retry cannot double-bill.
KEY="earnings-desk:scorecard:$(printf %s "$RELEASE" | shasum | cut -c1-8):1"

JOB=$(curl -s -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
  | grep -q '"status":"succeeded"'; do sleep 2; done

curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"
# -> data.output holds the JSON envelope described below.

Step 6 — stream it instead

/run-stream returns text/event-stream with four event names: job, delta, done and error. A delta frame's data is {"text": "..."} and the text is cumulative — the whole output so far, not the increment.

# Server-sent events. Four event names: job, delta, done, error.
# A delta's data carries {"text": "<the whole output so far>"}.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"scorecard\",..."}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":118,"output":"{...}"}

The output envelope

The reply is a single JSON object. Every key is always present: an empty section is [], "" or {}, never null and never omitted. Parse it with a tolerant reader — the web app recovers a truncated stream by walking the bracket stack and rendering whatever sections completed.

fieldtypemeaning
lanestringThe lane the reply is for.
titlestringUnder 80 characters.
verdictstringOne of the lane's own list.
headlinestringOne sentence, under 160 characters.
summarystringTwo to four sentences.
checksarray{name, value, verdict, note}; verdict is good|weak|missing|risky|not-applicable.
findingsarray{id, severity, metric, quote, why, so_what}; severity is critical|high|medium|low.
linesarray{metric, label, actual, expected, surprise, driver, note}, all strings.
artifactstringThe lane's document, in Markdown.
artifact_jsonobjectCarries a kind naming the lane's schema.
coverage_checkarray{flag_id, status, note}; status is confirmed|cleared|not-applicable. Exactly one entry per prescan flag.
questionsarrayStrings. Non-empty in the thesis lane.
confidencestringhigh|medium|low.

One worked example per lane

task: "scorecard"

Send the release and the estimates. You get the beat/miss table in lines[], the quality-of-earnings questions in checks[], and artifact_json.beat_quality of above-the-line, below-the-line, mixed or not-determinable. Ask for not-determinable to be respected: with no estimates it is the honest answer.

{
  "task": "scorecard",
  "release": "...",
  "estimates": "Metric,Consensus\nRevenue,1262.4\nAdjusted EPS,0.84",
  "period": "Q3 2025",
  "reading": "balanced",
  "masking_on": true,
  "prescan": {
    "readable": true,
    "flags": []
  }
}

task: "guidance"

Send prior_guidance as well, or the bridge has nothing to move from. lines[] carries one row per guide and artifact_json.guides[].flow_through_only marks a raise that is only the quarter's beat flowing through.

{
  "task": "guidance",
  "release": "...",
  "prior_guidance": "For the fourth quarter the company expects revenue of $1,300 million to $1,320 million.",
  "period": "Q3 2025",
  "masking_on": true,
  "prescan": {
    "readable": true,
    "flags": []
  }
}

task: "note"

The artifact is the product. Figures the input does not carry appear as [not in the paste] rather than being estimated, and verdict is needs-numbers when that happens. checks[] is the sourcing table: each row names where in your input the claim came from.

{
  "task": "note",
  "release": "...",
  "estimates": "...",
  "period": "Q3 2025",
  "masking_on": true,
  "prescan": {
    "readable": true,
    "flags": []
  }
}

task: "thesis"

Send thesis as pillars, one per line. checks[] comes back with one row per pillar, and a pillar the release is silent on is missing, never good. questions[] is non-empty in this lane by contract.

{
  "task": "thesis",
  "release": "...",
  "thesis": "Pillar 1: growth stays above 15%.\nPillar 2: gross margin expands.",
  "period": "Q3 2025",
  "masking_on": true,
  "prescan": {
    "readable": true,
    "flags": []
  }
}

Answering the prescan

The web app sends its own measurements in prescan, and the prompt requires the model to answer every prescan.flags[].flag_id exactly once in coverage_check. If you build your own prescan, give each flag a stable flag_id and a confidence of high or unknown:

Sending flags with no confidence is safe — they are treated as high — but it gives up the distinction, and that distinction is what stops a report saying a company disclosed nothing when the user simply pasted an excerpt.

Rate limits and cost