← Preview Desk / API
Tokens

Drive Preview Desk from your own code

Everything the web page does is available over HTTP. Send a covered company's pre-earnings pack (the consensus-versus-your-numbers table, the reaction history, the options-implied move, the peer EPS, the last earnings call and your notes) with the facts the browser computes for it, and get the same setup back: a skew (upside, balanced, downside), a headline, a ranked list of the 3-6 metrics that matter with the bar each has to clear, exactly three scenarios (bull, base, bear) with revenue, EPS, a signed stock reaction and a probability, 3-5 catalysts, a trading setup and one response per prescan flag. Or send the same pack and get the written preview note: a title, a 4-6 bullet thesis, 2-4 verbatim management quotes, the estimates table, the metrics beyond EPS, themes, news from your notes and a valuation paragraph on LTM and NTM P/E. The natural use is a coverage pipeline: a script previews every name reporting next week, files the setup with the model, and writes up the ones worth a note.

One thing to be clear about before the first call: the model never does the mechanical half. The variant view (your number against consensus), y/y on consensus and on yours (n.m. on a zero or negative base), where consensus and your number sit in the quarter's guidance, the quarter implied by annual guidance less year-to-date, whether stated margins tie to the dollar rows, the beat rate and the average one-day move, the implied move against that history, LTM and NTM P/E, the numbering of the transcript and the notes, and the prescan flags are all worked out by previewkit.js, the same file the web page loads. The result is sent as pack, a JSON string. The model's job is judgement over that work. See building the pack below.

Two lanes: the task field

Every request names its lane in task, and one system prompt routes on it. There are two:

taskwhat it does
setupThe pre-earnings setup (earnings-preview): what will move the stock and how the print could go. A headline, a skew (upside, balanced, downside), an expectation, a ranked watch list of 3-6 metrics each with the estimates row ref and the bar to beat, exactly three scenarios (bull, base, bear) with revenue, EPS, key driver, what happens, the management signal, stock_reaction_pct and probability_pct (the three sum to 100), 3-5 catalysts typed metric, guidance or narrative, a trading_setup (implied move and average move copied from the pack, plus a read), one prescan_responses entry per prescan flag and a summary.
noteThe concise written earnings preview (earnings-preview-beta): a title naming the company, ticker and quarter, a headline, an expectation, 4-6 thesis bullets with refs, 2-4 quotes copied word for word from one transcript paragraph, an estimates_table with the pack's consensus, your numbers and the browser's y/y, 3-5 metrics beyond_eps, 3-5 themes, 0-5 news items drawn only from your notes, a valuation paragraph on the pack's LTM and NTM P/E, the prescan responses and a summary.

The lanes chain: a setup reply becomes the note's optional setup field (a JSON string of its skew, headline, expectation, watch list, scenarios and catalysts), and the note is then written from it, with the same skew and the same base case. See handing the setup on. A missing or unknown task is still answered, as the closest lane (a setup field means note), and the reply's lane names the lane that was used. Always send task and check lane in the reply.

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

The token is minted for this app (the guest endpoint takes {"slug":"preview-desk"} in its body), so no slug header is needed afterwards. Send your token 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 pack.

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.
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. Every field is a string: pack and setup must be JSON-encoded strings, not objects. 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.

A guest token can call /me and /estimate. Both lanes are metered, so a run needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://preview-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; both lanes need 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":"preview-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# 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"
SLUG="preview-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://preview-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 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.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

The input object is exactly what the app's form submits. The first field is task, the lane (see the two lanes):

taskwhat it does
setupThe setup: skew, expectation, a ranked watch list with bars, bull/base/bear scenarios, catalysts, the trading setup, prescan responses, summary.
noteThe written preview: title, thesis, verbatim quotes, estimates table, metrics beyond EPS, themes, news, valuation, prescan responses, summary. Send the setup in setup to write it from that setup.

A missing or unknown task is answered as the closest lane, and the reply's lane names it.

fieldtypemeaning
taskstring, required"setup" or "note".
companystring, requiredThe company with its ticker in brackets, "Draxmoor Industrial (DRXM)". The ticker in brackets is how the peer table knows which row is the subject. PreviewKit.buildInput sends "(not given)" when it is empty.
quarterstring, requiredThe fiscal quarter being previewed, as the browser reads it: "Q2 2026", "Q3 FY2026". The model uses it exactly as given and never infers a quarter from a calendar date. It is the same value as pack.quarter; when you left it blank, the browser takes the quarter after the one the transcript names.
packstring, requiredThe JSON-encoded output of PreviewKit.pack: the estimates rows with the browser's arithmetic, the reaction history and its stats, the implied move, the peers with LTM and NTM P/E, the guidance sentences from the call, the sector KPIs and the prescan flags. See building the pack.
transcriptstring, optionalThe most recent (prior-quarter) earnings call, one paragraph per block, each starting with its id and speaker, [T3] Marcus Lee (Chief Financial Officer): For the second quarter, ..., blocks joined by a blank line. PreviewKit.clipTranscript keeps it to 40,000 characters: a longer call loses its middle paragraphs (the prepared remarks with the guidance and the end of Q&A survive) and a marker line takes their place, [... T9-T30 (12,345 characters) not sent for length ...]. Paragraph ids never change, so refs stay valid. Left out when there is no transcript.
notesstring, optionalYour news and notes, one per line, [N1] 2026-06-18: Draxmoor won a $420M .... Leading bullets and numbering are stripped, each note is kept to 500 characters, at most 25 are read, and notes past 6,000 characters in total are not sent. The model's news comes only from here. Left out when empty.
questionstring, optionalWhat you want to know, up to 2,000 characters. Left out when empty. A longer question is cut on a word boundary and ends with [...cut for length].
setupstring, note only, optionalA JSON string carrying an earlier setup into the note: skew, headline, expectation, watch (at most six {rank, metric, ref, bar}), scenarios ({case, revenue, eps, key_driver, stock_reaction_pct, probability_pct}) and catalysts (at most five item strings). Recon.handoff builds it from a setup reply (see handing the setup on); buildInput only adds it when the lane is note.
retry_notestring, optionalLeave it out. The page sets it only on its one reformat retry after an unparseable reply: a plain instruction about the reply's shape.

Every value is a string. pack and setup hold JSON, but travel JSON-encoded; an object in either is a validation_error on a run. The app declares an input schema with task, company, quarter and pack required, but /estimate does no body validation: a malformed body (a bare string, pack as an object, no pack at all, a lane that does not exist) prices as happily as a good one. So send a JSON object, and validate on your side before you run: the body must be an object whose task is "setup" or "note", whose company and quarter are non-empty strings and whose pack is a string that parses to an object. The web app builds every input with PreviewKit.buildInput, which guarantees that shape, and passes it through PreviewKit.mustBeObject before pricing or running it.

Building the pack

previewkit.js is plain JavaScript with no dependencies, is served next to the page (previewkit.js, with recon.js for the reply) and exports itself to node. Download it, paste the pieces of your pack into text files, and let it build the body. PreviewKit.analyze(set) runs the whole free pass: it reads the estimates table (rows E1, E2 ...), the reaction history (H1 ...), the implied move, the peers (V1 ...), the transcript (T1 ...) and the notes (N1 ...), and raises the prescan flags (A.flags, ids P1, P2 ...). PreviewKit.buildInput(lane, A, {question, setup}) then produces the body, with pack set to JSON.stringify(PreviewKit.pack(A)). It is free and local; only the run is metered.

// make-body.js
//   node make-body.js pack-dir "your question"                 > body.json   (setup)
//   node make-body.js pack-dir "your question" setup.json      > body.json   (note, from a setup reply)
const fs = require("fs");
const path = require("path");
const PreviewKit = require("./previewkit.js");   // https://preview-desk.skillsafe.ai/previewkit.js
const Recon = require("./recon.js");             // https://preview-desk.skillsafe.ai/recon.js

const [dir, question = "", setupFile] = process.argv.slice(2);
const read = (f) => (fs.existsSync(path.join(dir, f)) ? fs.readFileSync(path.join(dir, f), "utf8") : "");

const set = {
  company: "Draxmoor Industrial (DRXM)",   // ticker in brackets
  quarter: "Q2 2026",                     // the fiscal quarter being previewed
  date: "2026-07-23",
  timing: "pre_market",                   // pre_market | after_close | unknown
  sector: "industrials",                  // tech | retail | industrials | financials | healthcare | other
  price: "118.60",                        // share price, for a straddle-priced implied move
  implied: "4.1%",                        // "4.1%" stated, or "$8.40" (a straddle, divided by the price)
  estimates: read("estimates.txt"),       // | Metric | Street | House | Prior year | Guidance |
  reactions: read("reactions.txt"),       // Quarter | EPS surprise | Stock move
  peers: read("peers.txt"),               // Ticker | Price | LTM EPS | NTM EPS
  transcript: read("transcript.txt"),     // the prior quarter's call, "Name -- Title: text" per block
  notes: read("notes.txt"),               // one note per line
};

const A = PreviewKit.analyze(set);        // rows E1.., prints H1.., peers V1.., T1.., N1.., flags P1..

const setup = setupFile
  ? Recon.handoff(Recon.normalize(Recon.parseResult(fs.readFileSync(setupFile, "utf8")), "setup"))
  : null;
const body = PreviewKit.buildInput(setupFile ? "note" : "setup", A, { question, setup });
process.stdout.write(JSON.stringify(PreviewKit.mustBeObject(body)));   // {task, company, quarter, pack:"{...}", transcript, notes, ...}

What each input to analyze may look like:

keyhow the browser reads it
estimatesA pasted table: pipes, tabs, semicolons or commas (a comma between a digit and three digits is a thousands separator). The header names the columns: Consensus (or Street, Mean, FactSet ...), Our estimate (or House, Model, Ours ...), Year ago (or Prior year, Actual), Whisper, Guidance as a range or Guidance low / Guidance high, FY guide low / FY guide high and YTD for the implied quarter. A ($M) in a header or metric scales the numbers; parentheses mean negative. With no header row the columns are read as metric, consensus, your estimate, year ago. At most 30 rows.
reactionsPast prints, one per line: quarter, EPS surprise and one-day move, with a header naming them. At most 24.
implied, priceA percentage is a stated implied move; a dollar figure is a straddle and is divided by price ($8.40 straddle / $182.40 = 4.61%).
peersTicker | Price | LTM EPS | NTM EPS. An EPS cell is the four-quarter sum or the quarters joined by + (0.52+0.58+0.63+0.66); fewer than four quarters gives a P/E of n/a, a non-positive sum n.m.. At most 15.
transcriptThe call as pasted. Blocks are split on blank lines (or on lines when there are none); a block that starts Name -- Title: or Name, Title: sets the speaker. The fiscal quarter the call names (for example Q1 2026) is read from the opening paragraphs; the preview covers the next one.
notesOne note per line; a date in the line (2026-06-18, 9/15, Sep 15) is picked up.

What pack carries once it is parsed:

keycontents
company, ticker, quarter, last_call_quarter, report_date, timingWhat you entered, the ticker read from the brackets, the quarter label ("Q2 2026"), and the fiscal quarter the transcript names ("Q1 2026", or null).
sector, sector_kpisThe sector label ("Industrials") and {expected, in_table, missing}: the operating metrics a preview in that sector usually watches (backlog, book-to-bill, price vs volume for industrials; ARR, net retention, RPO, customer count for tech) and which of them your table carries.
estimatesOne row per metric: {id, metric, kind, consensus, ours, year_ago} formatted as the pack shows them ("$1,480M", "14.2%", "$1.05", "n/a" when blank), kind being money, pct, eps or count; where given, whisper and guidance ("$1,450M-$1,510M"); and the browser's arithmetic: ours_vs_consensus, yoy_consensus and yoy_ours ("+6.1%", basis points for percentages, "+30 bp", and "n.m." on a zero or negative base), consensus_in_guidance and ours_in_guidance (below the range, in the lower half, at the midpoint, in the upper half, above the range, at the point guide), and implied_quarter with consensus_in_implied when FY guidance and YTD are given.
reactionsrows as {id, quarter, eps_surprise_pct, move_pct}, and stats: n, beat_rate_pct, beats, misses, avg_abs_move_pct, median_abs_move_pct, avg_move_on_beats_pct, avg_move_on_misses_pct, beat_and_fell (ids) and largest.
implied_move, implied_vs_avg_move, share_price{pct, how} or null; the implied move divided by the average absolute move; the price you entered.
peers{id, ticker, price, ltm_eps, ntm_eps, ltm_pe, ntm_pe, subject}, the EPS cells with the sum shown ("0.52 + 0.58 + 0.63 + 0.66 = 2.39") and the P/E as "76.3x", "n/a" or "n.m."; subject marks the company's own row.
guidance_sentencesUp to 16 {ref, text}: sentences in the call that give guidance with a number, each with its paragraph id.
transcript_ids, note_idsThe ranges sent, "T1-T4" and "N1-N2", or "none".
prescan_flagsWhat the free checks found, as {id, severity, category, message} with ids P1, P2 ... and severity critical, important or minor. Categories: missing (no consensus column, no revenue or EPS row, no transcript ...), guidance (consensus or yours outside the guide or the FY-implied quarter), variant (a variant view to defend), whisper, arithmetic (a stated margin that does not tie, a y/y on a negative base), table (a duplicate row), quarter (the preview quarter is not the one after the call's), market (implied move against history, beats that fell), peers (fewer than four quarters of EPS) and kpi (sector metrics missing from the table).
clippednull, or {transcript_not_sent, characters_not_sent} ("T9-T30") when the transcript lost its middle.

Worked inputs

The page's Draxmoor Industrial example (an industrial equipment maker previewing Q2 2026 off its Q1 2026 call) as a setup body, built by PreviewKit.buildInput with no question. The pack string is abbreviated here (three of the seven estimates rows and two of the five past prints shown; the stats are over all five); every other field, and every field name and shape, is exact. The prescan raises two flags: the stated year-ago operating margin does not tie to the dollar rows, and the table has no price-vs-volume row:

{
  "task": "setup",
  "company": "Draxmoor Industrial (DRXM)",
  "quarter": "Q2 2026",
  "pack": "{\"company\":\"Draxmoor Industrial (DRXM)\",\"ticker\":\"DRXM\",\"quarter\":\"Q2 2026\",\"last_call_quarter\":\"Q1 2026\",\"report_date\":\"2026-07-23\",\"timing\":\"pre_market\",\"sector\":\"Industrials\",\"sector_kpis\":{\"expected\":[\"Backlog\",\"Book-to-bill\",\"Price vs volume\"],\"in_table\":[\"Backlog\",\"Book-to-bill\"],\"missing\":[\"Price vs volume\"]},\"estimates\":[{\"id\":\"E1\",\"metric\":\"Revenue ($M)\",\"kind\":\"money\",\"consensus\":\"$1,480M\",\"ours\":\"$1,492M\",\"year_ago\":\"$1,395M\",\"guidance\":\"$1,450M-$1,510M\",\"ours_vs_consensus\":\"+0.8%\",\"yoy_consensus\":\"+6.1%\",\"yoy_ours\":\"+7.0%\",\"consensus_in_guidance\":\"at the midpoint\",\"ours_in_guidance\":\"in the upper half\"},{\"id\":\"E3\",\"metric\":\"Operating margin\",\"kind\":\"pct\",\"consensus\":\"14.2%\",\"ours\":\"14.2%\",\"year_ago\":\"13.9%\",\"ours_vs_consensus\":\"0 bp\",\"yoy_consensus\":\"+30 bp\",\"yoy_ours\":\"+30 bp\"},{\"id\":\"E4\",\"metric\":\"Adjusted EPS\",\"kind\":\"eps\",\"consensus\":\"$1.05\",\"ours\":\"$1.06\",\"year_ago\":\"$0.94\",\"guidance\":\"$1.02-$1.08\",\"ours_vs_consensus\":\"+1.0%\",\"yoy_consensus\":\"+11.7%\",\"yoy_ours\":\"+12.8%\",\"consensus_in_guidance\":\"at the midpoint\",\"ours_in_guidance\":\"in the upper half\"}],\"reactions\":{\"rows\":[{\"id\":\"H1\",\"quarter\":\"Q1 2026\",\"eps_surprise_pct\":1.9,\"move_pct\":3.8},{\"id\":\"H2\",\"quarter\":\"Q4 2025\",\"eps_surprise_pct\":0.8,\"move_pct\":-2.9}],\"stats\":{\"n\":5,\"with_move\":5,\"with_surprise\":5,\"beat_rate_pct\":80,\"beats\":4,\"misses\":1,\"avg_abs_move_pct\":3.68,\"median_abs_move_pct\":3.8,\"avg_move_on_beats_pct\":2.05,\"avg_move_on_misses_pct\":-4.4,\"beat_and_fell\":[\"H2\"],\"largest\":{\"id\":\"H3\",\"quarter\":\"Q3 2025\",\"move_pct\":5.1}}},\"implied_move\":{\"pct\":4.1,\"how\":\"4.10% (stated)\"},\"implied_vs_avg_move\":1.11,\"share_price\":118.6,\"peers\":[],\"guidance_sentences\":[{\"ref\":\"T3\",\"text\":\"For the second quarter, we expect revenue of $1.45 billion to $1.51 billion and adjusted EPS of $1.02 to $1.08.\"}],\"transcript_ids\":\"T1-T4\",\"note_ids\":\"N1-N2\",\"prescan_flags\":[{\"id\":\"P1\",\"severity\":\"important\",\"category\":\"arithmetic\",\"message\":\"Operating margin (year ago) is stated as 13.9%, but Operating income ($M) / Revenue ($M) = $188.3M / $1,395M = 13.50%.\"},{\"id\":\"P2\",\"severity\":\"minor\",\"category\":\"kpi\",\"message\":\"Industrials previews usually watch Price vs volume - none of these is in your table.\"}],\"clipped\":null}",
  "transcript": "[T1] Operator: Good morning and welcome to Draxmoor Industrial's Q1 2026 earnings call.\n\n[T2] Sofia Brandt (Chief Executive Officer): Orders grew 11% and book-to-bill was 1.12, driven by grid and data center power equipment. Backlog reached a record $6.7 billion.\n\n[T3] Marcus Lee (Chief Financial Officer): For the second quarter, we expect revenue of $1.45 billion to $1.51 billion and adjusted EPS of $1.02 to $1.08. Price realization should offset steel inflation through the year.\n\n[T4] Operator: That concludes the call.",
  "notes": "[N1] 2026-06-18: Draxmoor won a $420M multi-year grid-equipment order from a US utility\n[N2] 2026-07-01: Steel prices up about 8% quarter to date"
}

The written note for the page's Tessavik Software example (vertical SaaS, Q3 FY2026), built from that example's setup reply through Recon.handoff, as a note body. The transcript, notes, question and setup strings are exact; the pack is abbreviated the same way (three of eight estimates rows, two of eight prints, two of four peers and four of the eight prescan flags shown):

{
  "task": "note",
  "company": "Tessavik Software (TSVK)",
  "quarter": "Q3 FY2026",
  "pack": "{\"company\":\"Tessavik Software (TSVK)\",\"ticker\":\"TSVK\",\"quarter\":\"Q3 FY2026\",\"last_call_quarter\":\"Q2 FY2026\",\"report_date\":\"2026-10-29\",\"timing\":\"after_close\",\"sector\":\"Tech / SaaS\",\"sector_kpis\":{\"expected\":[\"ARR\",\"Net retention\",\"RPO\",\"Customer count\"],\"in_table\":[\"ARR\",\"Net retention\"],\"missing\":[\"RPO\",\"Customer count\"]},\"estimates\":[{\"id\":\"E1\",\"metric\":\"Revenue ($M)\",\"kind\":\"money\",\"consensus\":\"$612M\",\"ours\":\"$621.5M\",\"year_ago\":\"$528.4M\",\"guidance\":\"$600M-$610M\",\"ours_vs_consensus\":\"+1.6%\",\"yoy_consensus\":\"+15.8%\",\"yoy_ours\":\"+17.6%\",\"consensus_in_guidance\":\"above the range\",\"ours_in_guidance\":\"above the range\"},{\"id\":\"E5\",\"metric\":\"Diluted EPS\",\"kind\":\"eps\",\"consensus\":\"$0.71\",\"ours\":\"$0.75\",\"year_ago\":\"$0.58\",\"guidance\":\"$0.68-$0.70\",\"ours_vs_consensus\":\"+5.6%\",\"yoy_consensus\":\"+22.4%\",\"yoy_ours\":\"+29.3%\",\"consensus_in_guidance\":\"above the range\",\"ours_in_guidance\":\"above the range\"},{\"id\":\"E7\",\"metric\":\"Net revenue retention\",\"kind\":\"pct\",\"consensus\":\"116%\",\"ours\":\"117%\",\"year_ago\":\"118%\",\"ours_vs_consensus\":\"+100 bp\",\"yoy_consensus\":\"-200 bp\",\"yoy_ours\":\"-100 bp\"}],\"reactions\":{\"rows\":[{\"id\":\"H1\",\"quarter\":\"Q2 FY2026\",\"eps_surprise_pct\":6.2,\"move_pct\":5.4},{\"id\":\"H2\",\"quarter\":\"Q1 FY2026\",\"eps_surprise_pct\":4.9,\"move_pct\":7.9}],\"stats\":{\"n\":8,\"with_move\":8,\"with_surprise\":8,\"beat_rate_pct\":88,\"beats\":7,\"misses\":1,\"avg_abs_move_pct\":5.81,\"median_abs_move_pct\":5.2,\"avg_move_on_beats_pct\":5.3,\"avg_move_on_misses_pct\":-9.4,\"beat_and_fell\":[],\"largest\":{\"id\":\"H3\",\"quarter\":\"Q4 FY2025\",\"move_pct\":11.2}}},\"implied_move\":{\"pct\":4.61,\"how\":\"$8.40 straddle / $182.40 = 4.61%\"},\"implied_vs_avg_move\":0.79,\"share_price\":182.4,\"peers\":[{\"id\":\"V1\",\"ticker\":\"TSVK\",\"price\":182.4,\"ltm_eps\":\"0.52 + 0.58 + 0.63 + 0.66 = 2.39\",\"ntm_eps\":\"0.71 + 0.74 + 0.79 + 0.83 = 3.07\",\"ltm_pe\":\"76.3x\",\"ntm_pe\":\"59.4x\",\"subject\":true},{\"id\":\"V2\",\"ticker\":\"NMBS\",\"price\":96.1,\"ltm_eps\":\"1.02 + 1.05 + 1.10 + 1.12 = 4.29\",\"ntm_eps\":\"1.15 + 1.19 + 1.24 + 1.30 = 4.88\",\"ltm_pe\":\"22.4x\",\"ntm_pe\":\"19.7x\",\"subject\":false}],\"guidance_sentences\":[{\"ref\":\"T3\",\"text\":\"For the third quarter, we expect revenue in the range of $600 million to $610 million and non-GAAP operating income of $104 million to $108 million, or diluted EPS of $0.68 to $0.70.\"},{\"ref\":\"T3\",\"text\":\"For the full fiscal year we now expect revenue of $2.37 billion to $2.39 billion.\"},{\"ref\":\"T8\",\"text\":\"We raised our free cash flow margin target to 26% for the year.\"}],\"transcript_ids\":\"T1-T9\",\"note_ids\":\"N1-N3\",\"prescan_flags\":[{\"id\":\"P1\",\"severity\":\"important\",\"category\":\"guidance\",\"message\":\"E1 Revenue ($M): consensus $612M is above the range of the company's guidance ($600M-$610M) - the Street already expects a beat of the guide.\"},{\"id\":\"P3\",\"severity\":\"important\",\"category\":\"guidance\",\"message\":\"E5 Diluted EPS: consensus $0.71 is above the range of the company's guidance ($0.68-$0.70) - the Street already expects a beat of the guide.\"},{\"id\":\"P4\",\"severity\":\"minor\",\"category\":\"variant\",\"message\":\"E5 Diluted EPS: your $0.75 is +5.6% vs consensus $0.71 - a variant view the preview has to defend.\"},{\"id\":\"P6\",\"severity\":\"minor\",\"category\":\"market\",\"message\":\"Options imply 4.61%, only 0.79x the average one-day move of 5.81% - the market may be underpricing the print.\"}],\"clipped\":null}",
  "transcript": "[T1] Operator: Good afternoon, and welcome to the Tessavik Software second quarter fiscal 2026 earnings conference call. All participants are in listen-only mode.\n\n[T2] Maren Tessdal (Chief Executive Officer): Thanks, everyone, for joining. We delivered revenue of $588.3 million, up 17% year over year, and ARR crossed $2.45 billion. Enterprise was the standout again, and our AI assistant attach rate reached 22% of new bookings, and we are still early.\n\n[T3] Devan Marlowe (Chief Financial Officer): Gross margin was 79.8%, and non-GAAP operating income was $102.6 million. For the third quarter, we expect revenue in the range of $600 million to $610 million and non-GAAP operating income of $104 million to $108 million, or diluted EPS of $0.68 to $0.70. For the full fiscal year we now expect revenue of $2.37 billion to $2.39 billion.\n\n[T4] Devan Marlowe (Chief Financial Officer): On retention, net revenue retention was 117%, down a point, as we saw some seat compression in our SMB cohort. We expect NRR to stabilize in the second half of the year.\n\n[T5] Ilse Varrow (Quarrystone Securities): Maren, on the SMB seat compression, is there any sign of it moving up-market, and how are you thinking about pricing?\n\n[T6] Maren Tessdal (Chief Executive Officer): We're not seeing it spread to enterprise. Enterprise net retention is still above 120 percent, and we have not changed list prices.\n\n[T7] Tom Becker (Penwick & Lyle): Devan, can you talk about cash flow and hiring for the back half?\n\n[T8] Devan Marlowe (Chief Financial Officer): We raised our free cash flow margin target to 26% for the year. Hiring stays focused on quota-carrying reps in enterprise.\n\n[T9] Operator: That concludes today's question-and-answer session.",
  "notes": "[N1] 2026-09-08: Tessavik agreed to buy Lumenfold Analytics for $140M in cash, expected to close in Q4 FY2026\n[N2] 2026-09-15: NMBS raised its enterprise list prices by 8%\n[N3] Our channel checks: enterprise renewals tracking ahead of plan; SMB seat counts stabilised in August",
  "question": "We are above the Street on EPS. Is the setup good enough to lean into the print?",
  "setup": "{\"skew\":\"upside\",\"headline\":\"TSVK sets up to the upside: our $0.75 EPS is +5.6% above a $0.71 Street number, every past beat has been rewarded (+5.30% average, none fell), and options imply only 4.61%, 0.79x the 5.81% average move, while our checks show enterprise renewals ahead of plan and SMB seats stabilised.\",\"expectation\":\"We expect a beat on every line - $621.5M revenue and $0.75 EPS against $612M and $0.71 consensus - with net revenue retention holding 117% as SMB stabilises, and a full-year revenue raise that the market has not paid for in the options.\",\"watch\":[{\"rank\":1,\"metric\":\"Diluted EPS\",\"ref\":\"E5\",\"bar\":\"$0.75\"},{\"rank\":2,\"metric\":\"Net revenue retention\",\"ref\":\"E7\",\"bar\":\"116%\"},{\"rank\":3,\"metric\":\"ARR\",\"ref\":\"E6\",\"bar\":\"$2,540M\"},{\"rank\":4,\"metric\":\"Revenue\",\"ref\":\"E1\",\"bar\":\"$612M\"},{\"rank\":5,\"metric\":\"Operating income\",\"ref\":\"E4\",\"bar\":\"$110.2M\"},{\"rank\":6,\"metric\":\"Full-year revenue guidance\",\"ref\":\"T3\",\"bar\":\"a raise above the top of the current full-year range, excluding Lumenfold\"}],\"scenarios\":[{\"case\":\"bull\",\"revenue\":\"$624.0M\",\"eps\":\"$0.77\",\"key_driver\":\"Retention steps back up and ARR growth carries into revenue\",\"stock_reaction_pct\":10,\"probability_pct\":30},{\"case\":\"base\",\"revenue\":\"$621.5M\",\"eps\":\"$0.75\",\"key_driver\":\"A typical-size beat with retention holding 117%\",\"stock_reaction_pct\":5,\"probability_pct\":50},{\"case\":\"bear\",\"revenue\":\"$612M\",\"eps\":\"$0.71\",\"key_driver\":\"An in-line print against a Street that already expects a beat of the guide\",\"stock_reaction_pct\":-6,\"probability_pct\":20}],\"catalysts\":[\"EPS against our $0.75, not the $0.71 Street number\",\"Net revenue retention against the 116% consensus and management's second-half stabilization promise\",\"Full-year revenue guide raise and the split of Lumenfold\",\"Enterprise pricing power after NMBS raised enterprise list prices 8%\",\"Free cash flow against the raised 26% full-year margin target\"]}"
}

Now price it. /estimate is free: it creates no job and charges nothing, and returns hold_credits, min_credits, model, model_alias (gpt-terra) and markup_bps. hold_credits is a reservation against the full output cap, not the price: you are charged for what the run actually uses, reported afterwards as charged_credits. Price each lane separately; a note body carries the setup and prices differently from a setup body over the same pack. The body you send to /estimate is the input object itself, and it must be a JSON object: a JSON string of the body is priced too, and tells you nothing.

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("setup","note") and all(isinstance(b.get(k),str) and b[k].strip() for k in ("company","quarter","pack")) and isinstance(json.loads(b["pack"]),dict) and isinstance(b.get("setup",""),str)'
INPUT=$(cat body.json)
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"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 normally much lower.

5. Run it, then poll

A run is metered, so it needs a personal token from signing in; a guest token gets a 403 here. POST /run 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, and put the lane in it. The web app sends "preview-desk:" + task + ":" + hash + ":a" + attempt, for example preview-desk:setup:88f38807-1145:a1 for the Draxmoor setup body above and preview-desk:note:580da603-2a3f:a1 for the Tessavik note body. A retried request with the same key returns the same job instead of billing a second run, and because the lane is part of the key, a setup and a note over the same pack never collide. Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a changed body. The web app's hash is PreviewKit.hashInput, a djb2 hash of the input JSON plus its length in hex, taken over the input without any retry_note; any stable content hash works, and the samples below use the first 16 hex digits of a SHA-256.

If the reply cannot be parsed as one JSON object, the web app retries exactly once: it adds a retry_note field to the same input and sends it with the attempt suffix bumped to :a2, so the reformat retry is a distinct, separately billed run. The note reads: Your previous reply was not the single valid JSON object the instructions require (<the parse error>). Reply again with ONLY the JSON object for task 'setup' - no prose, no code fences; every key present (empty arrays where there is nothing to say). (with 'note' for the note lane). The key keeps the hash of the original input; only the suffix changes. Do the same from code.

# Always send an Idempotency-Key derived from the lane and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="preview-desk:$LANE:$(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\":\"setup\",\"headline\":\"Draxmoor's Q2 2026 is priced for a small beat ...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream is the same call over server-sent events, with the same personal token and the same lane-bearing Idempotency-Key. Each delta event carries {"text": "..."}, a chunk of the reply, and the final done event carries status, charged_credits and truncated. A browser client may receive progress ticks rather than text deltas; the finished job from step 5 always has 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\":\"setup\",\"headline\":\"Draxmoor's Q2 2026 is priced"}
# 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 strips any code fence, takes everything from the first { to the last }, parses it (Recon.parseResult) and normalizes it with Recon.normalize(obj, task) (recon.js, which also exports itself to node and loads ./previewkit.js itself). The lane is read from lane (or task); when both are missing it is guessed (scenarios or watch means setup, quotes, estimates_table or thesis means note), and a lane other than the one you sent is surfaced as lane_mismatch. Prescan ids are upper-cased and an unknown prescan call becomes confirmed. For setup, an unknown skew becomes balanced; a watch item sent as a bare string becomes {metric}, a missing rank is its position, items with no metric are dropped and the list is sorted by rank; scenarios are read from case (or name, scenario), the first of each case is kept and they come back in bull, base, bear order, with stock_reaction_pct and probability_pct read as numbers ("+4.5%" is 4.5, anything unreadable null); an unknown catalyst type becomes metric; the trading setup's moves are numbers or null. For note, a thesis point or theme sent as a bare string becomes an object, refs strings are split on commas, semicolons, slashes and spaces into upper-cased arrays ("E5, T3" becomes ["E5","T3"]), surrounding quote marks are stripped from quotes, supports is a whole number or null, table rows with neither ref nor metric and news with no headline are dropped, and a valuation sent as a plain string becomes {text, refs: []}. Missing arrays become empty, and present lists the sections the reply actually carried. A setup reply with no headline, no scenarios and no watch list, or a note reply with no title, no thesis and no estimates table, counts as unparseable and triggers the one retry_note retry.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r.get("lane"), "-", r["headline"])
if r.get("lane") == "note":
    print(r["title"])
    for e in r["estimates_table"]:
        print(e["ref"], e["metric"], e["consensus"], e["ours"], e["yoy"])
    for q in r["quotes"]:
        print(q["ref"], q["speaker"], "|", q["text"])
else:
    print("skew:", r["skew"])
    for w in r["watch"]:
        print(w["rank"], w["metric"], w["ref"], "bar", w["bar"])
    for s in r["scenarios"]:
        print(s["case"], s["revenue"], s["eps"], s["stock_reaction_pct"], s["probability_pct"])
EOF

Invariants worth asserting

The page holds every reply to the pack with Recon.reconcile(r, ctx), where ctx is {analysis: A, transcript_text, setup}: the analysis from PreviewKit.analyze, the transcript paragraphs' text joined by newlines, and for a note the parsed setup field it was written from. It returns the statements it checked, the disagreements it found and softer notes. What it checks:

Handing the setup on

The two lanes chain. Recon.handoff(setupReply) turns a normalized setup into the setup field of a note: {skew, headline, expectation, watch, scenarios, catalysts}, with at most six watch items reduced to {rank, metric, ref, bar}, the scenarios reduced to {case, revenue, eps, key_driver, stock_reaction_pct, probability_pct} and at most five catalysts as their item text. The pack is unchanged, so the note's refs are the setup's refs. buildInput JSON-encodes it into the body:

const PreviewKit = require("./previewkit.js");
const Recon = require("./recon.js");
const setupReply = Recon.normalize(Recon.parseResult(replyText), "setup");
const ctx = { analysis: A, transcript_text: A.transcript.paras.map((p) => p.text).join("\n") };
const check = Recon.reconcile(setupReply, ctx);
console.log(check.statements, "statements,", check.disagreements, "disagreements");
const setup = Recon.handoff(setupReply);                 // {skew, headline, expectation, watch, scenarios, catalysts}
const noteBody = PreviewKit.buildInput("note", A, { question, setup });   // noteBody.setup === JSON.stringify(setup)

The output contract

setup

{
  "lane": "setup",
  "headline": "one sentence the PM reads first",
  "skew": "upside" | "balanced" | "downside",
  "expectation": "1-2 sentences",
  "watch": [{"rank": 1, "metric": "", "ref": "E5", "bar": "", "why": ""}],
  "scenarios": [
    {"case": "bull", "revenue": "", "eps": "", "key_driver": "", "what_happens": "",
     "management_signal": "", "stock_reaction_pct": 0, "probability_pct": 0},
    {"case": "base", ...},
    {"case": "bear", ...}
  ],
  "catalysts": [{"item": "", "type": "metric" | "guidance" | "narrative", "why": "", "ref": ""}],
  "trading_setup": {"implied_move_pct": null, "avg_abs_move_pct": null, "read": ""},
  "prescan_responses": [{"id": "P1", "call": "confirmed" | "dismissed", "reason": ""}],
  "summary": "3-5 sentences: the positioning note"
}

3-6 watch items, ranked; each bar is a value its row carries (consensus, whisper, your number or a guidance bound), and a metric the pack has no row for cites its transcript paragraph or note and gives the bar in words with no figure. Exactly three scenarios: bull at or above base at or above bear on revenue and EPS, the base case on consensus or your number unless it says why not, stock_reaction_pct signed and calibrated to the implied move and the reaction history, and the three probabilities summing to 100. 3-5 catalysts. The trading setup copies the implied and average moves from the pack (null when it has none). Every number is in the input or is arithmetic on it that is shown ("$2.37B - $1.76B YTD = $610M implied"); the fiscal quarter is used exactly as given; P/E is LTM or NTM. The setup never tells anyone to buy or sell.

note

{
  "lane": "note",
  "title": "company, ticker, the quarter as given, and the question the print must answer",
  "headline": "one sentence",
  "expectation": "1-2 sentences",
  "thesis": [{"point": "", "refs": "E5, T3"}],
  "quotes": [{"text": "", "speaker": "", "ref": "T2", "supports": 1}],
  "estimates_table": [{"ref": "E1", "metric": "", "consensus": "", "ours": "", "yoy": ""}],
  "beyond_eps": [{"metric": "", "expectation": "", "why": "", "refs": "E6"}],
  "themes": [{"theme": "", "refs": "T4"}],
  "news": [{"date": "", "headline": "", "impact": "", "ref": "N1"}],
  "valuation": {"text": "", "refs": "V1, V2"},
  "prescan_responses": [{"id": "P1", "call": "confirmed" | "dismissed", "reason": ""}],
  "summary": "2-4 sentences: the bottom line"
}

4-6 thesis bullets, each with refs. 2-4 quotes, each copied word for word from one transcript paragraph (no ellipses, no joined sentences), none when there is no transcript; supports is the 1-based thesis bullet. The estimates table has at least revenue and EPS where the pack has them, then margins and 2-3 company KPIs, with consensus and ours exactly as the pack gives them and yoy exactly as its yoy_consensus (or "n.m."). 3-5 metrics beyond EPS, 3-5 themes, 0-5 news items only from your notes (date is "" when the note gives none), and a valuation paragraph on the pack's LTM and NTM P/E, or "Not in the pack" when there are no peers. With a setup field, the note keeps its skew and base case. Every array is present, empty when there is nothing to say. The app prints "Analysis is AI-generated - please confirm all outputs" on the note itself.

Worked replies

Abbreviated from the saved runs the page replays for its examples (arrays shortened; each shown entry is verbatim). The setup reply to the Draxmoor body above:

{
  "lane": "setup",
  "headline": "Draxmoor's Q2 2026 is priced for a small beat: our $1.06 EPS is one cent over the $1.05 consensus, which sits at the guidance midpoint, so the reaction turns on orders and on whether price still covers an 8% steel move.",
  "skew": "balanced",
  "expectation": "We expect revenue of $1,492M and adjusted EPS of $1.06, both in the upper half of the $1,450M-$1,510M and $1.02-$1.08 guides and just over consensus ($1,480M, $1.05). The variant view is thin (+0.8% revenue, +1.0% EPS), so book-to-bill and backlog decide the tone more than the headline.",
  "watch": [
    {
      "rank": 1,
      "metric": "Book-to-bill",
      "ref": "E6",
      "bar": "1.08",
      "why": "Q1 ran at 1.12 on grid and data center power equipment (T2); consensus 1.08 already assumes a slowdown, and our 1.1 plus the $420M utility order won 2026-06-18 (N1) argue for clearing it. A print below 1.0 would challenge the growth story that backs the multiple."
    },
    {
      "rank": 2,
      "metric": "Backlog ($B)",
      "ref": "E5",
      "bar": "$6.9B",
      "why": "Backlog was a record $6.7B at Q1 (T2); consensus $6.9B vs our $7B. For a long-cycle equipment maker this is the visibility metric the buy side prices beyond this quarter."
    },
    {
      "rank": 3,
      "metric": "Adjusted EPS",
      "ref": "E4",
      "bar": "$1.05",
      "why": "Consensus sits at the midpoint of the $1.02-$1.08 guide (T3); our $1.06 is +1.0%. Past beats of 0.8%-2.6% (H1-H3, H5) produced moves from -2.9% to +5.1%, so a one-cent beat alone does not set direction."
    }
  ],
  "scenarios": [
    {
      "case": "bull",
      "revenue": "$1,510M",
      "eps": "$1.08",
      "key_driver": "Grid and data center orders stay strong and price runs ahead of steel",
      "what_happens": "Revenue lands at the top of the $1,450M-$1,510M guide, book-to-bill holds near the Q1 1.12 with the $420M utility order booked, backlog clears $7B and margin beats 14.2%.",
      "management_signal": "Management reiterates or raises the full-year outlook and says price realization more than offset steel in the quarter.",
      "stock_reaction_pct": 4.5,
      "probability_pct": 25
    },
    {
      "case": "base",
      "revenue": "$1,492M",
      "eps": "$1.06",
      "key_driver": "Execution on the record backlog in line with guidance",
      "what_happens": "Revenue and EPS in the upper half of guidance, book-to-bill around 1.1, backlog near $7B, margin at 14.2% as price offsets steel.",
      "management_signal": "Management repeats that price realization offsets steel inflation through the year and describes orders as steady.",
      "stock_reaction_pct": 1.5,
      "probability_pct": 50
    },
    {
      "case": "bear",
      "revenue": "$1,450M",
      "eps": "$1.02",
      "key_driver": "Steel inflation outruns price and orders normalise",
      "what_happens": "Revenue and EPS at the bottom of guidance, book-to-bill slips toward the year-ago 1.02, and margin misses 14.2% as the 8% steel move lands before pricing catches up.",
      "management_signal": "Management talks about timing lags on price, steel headwinds or order digestion after a strong Q1.",
      "stock_reaction_pct": -4.5,
      "probability_pct": 25
    }
  ],
  "catalysts": [
    {
      "item": "Book-to-bill against the 1.08 consensus",
      "type": "metric",
      "why": "Orders drove the Q1 narrative (T2) and are what the market pays for in a long-cycle name; the $420M order (N1) is the upside lever.",
      "ref": "E6"
    },
    {
      "item": "Price versus steel commentary and margin against 14.2%",
      "type": "narrative",
      "why": "Steel up about 8% quarter to date (N2) tests the claim that price offsets steel (T3); the pack has no price vs volume split, so the call commentary is the only read (P2).",
      "ref": "E3"
    }
  ],
  "trading_setup": {
    "implied_move_pct": 4.1,
    "avg_abs_move_pct": 3.68,
    "read": "Options imply 4.1%, 1.11 times the 3.68% average absolute move (median 3.8%), a modest premium. Beats have averaged +2.05% and the one miss (H4) fell 4.4%, but a 0.8% beat still fell 2.9% (H2), so the tape rewards beats of 1.9%-2.6% (H1, H3) and punishes a thin one; with our beat at about 1%, the downside on a miss is larger than the upside on an in-line beat unless orders surprise."
  },
  "prescan_responses": [
    {
      "id": "P1",
      "call": "confirmed",
      "reason": "The year-ago margin does not tie: $188.3M / $1,395M = 13.50%, not the stated 13.9%. Current-quarter figures do tie ($210.2M / $1,480M = 14.20%), so the y/y expansion at the 14.2% consensus margin is about +70 bp, not the +30 bp the row shows. Fix the year-ago input before the note; it changes how margin progress reads."
    },
    {
      "id": "P2",
      "call": "confirmed",
      "reason": "Price vs volume matters for this print: management says price should offset steel inflation (T3) and steel is up about 8% quarter to date (N2). The pack has no price or volume split, so it is not in the pack; listen for it on the call."
    }
  ],
  "summary": "A near-consensus quarter: our $1.06 and $1,492M are one cent and 0.8% above consensus, both inside guidance. The stock's direction will come from book-to-bill against 1.08 and backlog against $6.9B, where the Q1 1.12 and the $420M utility order give us a modest edge, offset by steel up about 8% quarter to date testing price realization. Options at 4.1% versus a 3.68% average move price a little more than history, and a thin beat has fallen before (H2), so we call the skew balanced. Fix the year-ago margin (P1) before the note."
}

The full reply has six watch items (book-to-bill, backlog, EPS, operating margin, revenue, free cash flow), the three scenarios shown and four catalysts; Recon.reconcile checks 44 statements in it and finds no disagreement. The note reply to the Tessavik note body above:

{
  "lane": "note",
  "title": "Tessavik Software (TSVK) Q3 FY2026 preview: does net retention confirm the SMB trough while enterprise carries the beat?",
  "headline": "We are above the Street at $0.75 EPS versus $0.71, beats have averaged +5.30% the next day with none falling, and options imply only 4.61% - an upside setup if net retention holds 117% and the full-year guide goes up organically.",
  "expectation": "We expect a beat on every line - $621.5M revenue and $0.75 EPS against $612M and $0.71 consensus - with net revenue retention holding 117% as SMB stabilises, and a full-year revenue raise that the market has not paid for in the options.",
  "thesis": [
    {
      "point": "EPS: ours $0.75 vs consensus $0.71 (+5.6%), driven by operating income of $116M vs $110.2M (+5.3%). Consensus already sits above the $0.68-$0.70 guide, and our beat is inside the 3.0%-8.1% range of the last seven beats.",
      "refs": "E5, E4, H3, H4"
    },
    {
      "point": "Key debate is net revenue retention: consensus 116% (-200 bp y/y) vs our 117%. Management guided stabilization in the second half, and our August checks show SMB seat counts stabilised with enterprise renewals ahead of plan.",
      "refs": "E7, T4, N3"
    },
    {
      "point": "Guidance needs an organic raise of the $2.37B-$2.39B full-year revenue range at least equal to the Q3 beat, with Lumenfold Analytics ($140M cash, closing Q4 FY2026) kept separate.",
      "refs": "T3, N1"
    }
  ],
  "quotes": [
    {
      "text": "We expect NRR to stabilize in the second half of the year.",
      "speaker": "Devan Marlowe (Chief Financial Officer)",
      "ref": "T4",
      "supports": 2
    },
    {
      "text": "For the full fiscal year we now expect revenue of $2.37 billion to $2.39 billion.",
      "speaker": "Devan Marlowe (Chief Financial Officer)",
      "ref": "T3",
      "supports": 3
    }
  ],
  "estimates_table": [
    {
      "ref": "E1",
      "metric": "Revenue ($M)",
      "consensus": "$612M",
      "ours": "$621.5M",
      "yoy": "+15.8%"
    },
    {
      "ref": "E5",
      "metric": "Diluted EPS",
      "consensus": "$0.71",
      "ours": "$0.75",
      "yoy": "+22.4%"
    },
    {
      "ref": "E3",
      "metric": "Gross margin",
      "consensus": "80%",
      "ours": "80.2%",
      "yoy": "+70 bp"
    }
  ],
  "beyond_eps": [
    {
      "metric": "Net revenue retention",
      "expectation": "Consensus 116%, ours 117%; management expects NRR to stabilize in the second half after 117% last quarter.",
      "why": "A flat print turns the narrative from SMB compression to stabilisation and defends our variant.",
      "refs": "E7, T4"
    },
    {
      "metric": "ARR",
      "expectation": "Consensus $2,540M (+16.5% y/y), ours $2,575M (+18.1% y/y).",
      "why": "The forward revenue indicator for a subscription business; RPO and customer count are not in the pack, so ARR carries the forward read alone.",
      "refs": "E6, T2"
    }
  ],
  "themes": [
    {
      "theme": "Enterprise pricing power: NMBS raised enterprise list prices 8% while TSVK has not changed list prices, leaving room to price up or take share in enterprise.",
      "refs": "N2, T6"
    },
    {
      "theme": "Retention stabilization is management's own promise; our August checks show SMB seats stabilised, so a flat 117% is the bar for the recovery narrative.",
      "refs": "T4, N3"
    }
  ],
  "news": [
    {
      "date": "2026-09-08",
      "headline": "Tessavik agrees to buy Lumenfold Analytics for $140M in cash, closing expected Q4 FY2026",
      "impact": "No Q3 FY2026 contribution, but it complicates the full-year guide; watch for an organic versus acquired split.",
      "ref": "N1"
    },
    {
      "date": "2026-09-15",
      "headline": "NMBS raises enterprise list prices by 8%",
      "impact": "Eases competitive pricing pressure in enterprise and supports TSVK renewal and expansion economics; expect pricing questions on the call.",
      "ref": "N2"
    }
  ],
  "valuation": {
    "text": "TSVK trades at 76.3x LTM and 59.4x NTM P/E (V1), a premium to every peer in the pack: NMBS at 22.4x LTM and 19.7x NTM (V2), KSTL at 25.7x NTM with LTM n/a on three quarters of EPS (V3), and ORIL at 52.5x LTM and 45.3x NTM (V4). Against the nearest peer, ORIL, the NTM premium is 59.4x / 45.3x = 1.31x, so the upside case depends on the beat being durable in ARR and retention.",
    "refs": "V1, V2, V3, V4"
  },
  "prescan_responses": [
    {
      "id": "P1",
      "call": "confirmed",
      "reason": "Consensus $612M is above the $600M-$610M guide, so a beat of the guide is priced; our $621.5M needs to clear consensus, not the guide."
    },
    {
      "id": "P4",
      "call": "confirmed",
      "reason": "Our $0.75 (+5.6%) is backed by operating income of $116M (+5.3% vs consensus) and sits inside the 3.0%-8.1% range of past beats, with enterprise renewals ahead of plan (N3)."
    },
    {
      "id": "P7",
      "call": "dismissed",
      "reason": "KSTL's LTM P/E is n/a with three quarters, but its NTM P/E of 25.7x is available and the comparison does not drive this print."
    }
  ],
  "summary": "We are above the Street on EPS and net retention, and the notes back both: enterprise renewals ahead of plan, SMB seats stabilised, and a peer raising enterprise prices. Beats have averaged +5.30% with none falling, yet options imply only 4.61%. Upside setup; the risk is an in-line print against a Street already above guidance, which the one miss (-9.4%) shows is punished."
}

The full reply has five thesis bullets, four quotes (all four verbatim), seven estimates-table rows, four metrics beyond EPS, four themes, three news items (one per note) and eight prescan responses (seven confirmed, one dismissed); Recon.reconcile checks 97 statements in it and finds no disagreement.

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 watch list and scenarios may be complete while the catalysts and summary are missing, or the thesis and quotes while the valuation is missing. The web page closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how many it recovered, out of nine for setup (headline, skew, expectation, watch, scenarios, catalysts, trading setup, prescan responses, summary) and twelve for note (title, headline, expectation, thesis, quotes, estimates table, beyond EPS, themes, news, valuation, prescan responses, summary). A stream that ends early is recovered the same way. From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.