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:
| task | what it does |
|---|---|
setup | The 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. |
note | The 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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
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"}}
# Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "preview-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "preview-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"preview-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"preview-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "preview-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "preview-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://preview-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"preview-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "preview-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://preview-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "preview-desk";
const TOKEN = "YOUR_TOKEN"; // from https://preview-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "preview-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://preview-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class PreviewDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "preview-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "preview-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://preview-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "preview-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class PreviewDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "preview-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me 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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await PreviewDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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):
| task | what it does |
|---|---|
setup | The setup: skew, expectation, a ranked watch list with bars, bull/base/bear scenarios, catalysts, the trading setup, prescan responses, summary. |
note | The 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.
| field | type | meaning |
|---|---|---|
task | string, required | "setup" or "note". |
company | string, required | The 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. |
quarter | string, required | The 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. |
pack | string, required | The 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. |
transcript | string, optional | The 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. |
notes | string, optional | Your 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. |
question | string, optional | What 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]. |
setup | string, note only, optional | A 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_note | string, optional | Leave 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:
| key | how the browser reads it |
|---|---|
estimates | A 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. |
reactions | Past prints, one per line: quarter, EPS surprise and one-day move, with a header naming them. At most 24. |
implied, price | A percentage is a stated implied move; a dollar figure is a straddle and is divided by price ($8.40 straddle / $182.40 = 4.61%). |
peers | Ticker | 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. |
transcript | The 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. |
notes | One 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:
| key | contents |
|---|---|
company, ticker, quarter, last_call_quarter, report_date, timing | What 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_kpis | The 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. |
estimates | One 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. |
reactions | rows 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_sentences | Up to 16 {ref, text}: sentences in the call that give guidance with a number, each with its paragraph id. |
transcript_ids, note_ids | The ranges sent, "T1-T4" and "N1-N2", or "none". |
prescan_flags | What 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). |
clipped | null, 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.
INPUT = json.load(open("body.json")) # task, company, quarter, pack (+ transcript, notes, question, setup)
def must_be_body(body):
"""/estimate will not check any of this for you."""
if not isinstance(body, dict):
raise ValueError("run input must be a JSON object")
if body.get("task") not in ("setup", "note"):
raise ValueError('task must be "setup" or "note"')
for k in ("company", "quarter", "pack"):
if not isinstance(body.get(k), str) or not body[k].strip():
raise ValueError(f"run input needs {k} as a non-empty string")
if not isinstance(json.loads(body["pack"]), dict):
raise ValueError("pack must be a JSON string holding an object")
for k in ("transcript", "notes", "question", "setup"):
if k in body and not isinstance(body[k], str):
raise ValueError(f"{k} must be a string (setup JSON-encoded), not an object")
return body
est = call("estimate", must_be_body(INPUT))
print(INPUT["task"], est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation against the full output
# cap, not the price of the run.
import { readFileSync } from "node:fs";
function mustBeBody(b) {
// /estimate does no body validation, so check the shape here.
if (!b || typeof b !== "object" || Array.isArray(b)) throw new Error("run input must be a JSON object");
if (b.task !== "setup" && b.task !== "note") throw new Error('task must be "setup" or "note"');
for (const k of ["company", "quarter", "pack"]) {
if (typeof b[k] !== "string" || !b[k].trim()) throw new Error(`run input needs ${k} as a non-empty string`);
}
const pack = JSON.parse(b.pack);
if (!pack || typeof pack !== "object" || Array.isArray(pack)) throw new Error("pack must be a JSON string holding an object");
for (const k of ["transcript", "notes", "question", "setup"]) if (k in b && typeof b[k] !== "string") throw new Error(`${k} must be a string`);
return b;
}
const INPUT = mustBeBody(JSON.parse(readFileSync("body.json", "utf8")));
const est = await call("estimate", INPUT);
console.log(INPUT.task, est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
if err := json.Unmarshal(raw, &input); err != nil {
panic("run input must be a JSON object")
}
// estimate does no body validation, so check the shape here.
lane, _ := input["task"].(string)
company, _ := input["company"].(string)
quarter, _ := input["quarter"].(string)
packStr, _ := input["pack"].(string)
var pack map[string]any
if (lane != "setup" && lane != "note") || strings.TrimSpace(company) == "" || strings.TrimSpace(quarter) == "" ||
json.Unmarshal([]byte(packStr), &pack) != nil {
panic(`run input needs task "setup" or "note", company and quarter as strings and pack as a JSON string`)
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(lane, string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
// estimate does no body validation. With Jackson, check the shape before you run:
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var body = mapper.readTree(input);
String lane = body.path("task").asText("");
if (!body.isObject() || !(lane.equals("setup") || lane.equals("note"))
|| body.path("company").asText("").isBlank() || body.path("quarter").asText("").isBlank()
|| !body.path("pack").isTextual() || !mapper.readTree(body.path("pack").asText()).isObject()) {
throw new IllegalArgumentException("run input needs task setup|note, company, quarter and pack as strings");
}
System.out.println(call("estimate", input));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":...,"min_credits":...,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
unless INPUT.is_a?(Hash) && %w[setup note].include?(INPUT["task"]) &&
%w[company quarter pack].all? { |k| INPUT[k].is_a?(String) && !INPUT[k].strip.empty? } &&
JSON.parse(INPUT["pack"]).is_a?(Hash)
raise "run input needs task setup|note, company and quarter as strings and pack as a JSON string"
end
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
$ok = is_array($input) && in_array($input["task"] ?? null, ["setup", "note"], true);
foreach (["company", "quarter", "pack"] as $k) {
$ok = $ok && is_string($input[$k] ?? null) && trim($input[$k]) !== "";
}
if (!$ok || !is_array(json_decode($input["pack"], true))) {
throw new RuntimeException("run input needs task setup|note, company and quarter as strings and pack as a JSON string");
}
$est = call("estimate", $input);
echo $input["task"], " ", $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
// estimate would not tell you any of this:
bool Str(string k) => input.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString());
if (input.ValueKind != JsonValueKind.Object
|| !input.TryGetProperty("task", out var t) || t.GetString() is not ("setup" or "note")
|| !Str("company") || !Str("quarter") || !Str("pack")
|| JsonSerializer.Deserialize<JsonElement>(input.GetProperty("pack").GetString()!).ValueKind != JsonValueKind.Object)
throw new Exception("run input needs task setup|note, company and quarter as strings and pack as a JSON string");
var est = await PreviewDesk.Call("estimate", input);
Console.WriteLine($"{input.GetProperty("task")} {est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
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
import hashlib, time
lane = INPUT["task"] # "setup" or "note"
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"preview-desk:{lane}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = json.loads(job["output"]["output"])
print(reply["lane"], reply.get("skew") or reply.get("title"), reply["headline"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const lane = INPUT.task; // "setup" or "note"
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `preview-desk:${lane}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = JSON.parse(job.output.output);
console.log(reply.lane, reply.skew ?? reply.title, reply.headline, job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("preview-desk:%s:%x:a1", lane, sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
// lane was read and checked in step 4: "setup" or "note".
String key = "preview-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
lane = INPUT["task"]
key = "preview-desk:#{lane}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
reply = JSON.parse(job["output"]["output"])
puts reply["lane"], reply["headline"]
<?php
$key = "preview-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$reply = json_decode($job["output"]["output"], true);
echo $reply["lane"], " ", $reply["headline"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var lane = input.GetProperty("task").GetString();
var key = $"preview-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await PreviewDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var reply = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine($"{reply.GetProperty("lane")} {reply.GetProperty("headline")}");
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
console.log(done, raw.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
data.output.output is a string holding one JSON object. The web app 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
import re
SKEWS = ("upside", "balanced", "downside")
CASES = ("bull", "base", "bear")
def num_or_none(v):
try:
return float(re.sub(r"[%+,\s]", "", str(v))) if v not in (None, "") else None
except ValueError:
return None
def split_refs(v):
s = ",".join(v) if isinstance(v, list) else str(v or "")
return [x for x in re.split(r"[,;/\s]+", s.upper()) if x]
def parse_reply(text, sent_lane):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
lane = r.get("lane") or r.get("task")
if lane not in ("setup", "note"):
lane = ("setup" if r.get("scenarios") or r.get("watch") else
"note" if r.get("quotes") or r.get("estimates_table") or r.get("thesis") else sent_lane)
r["lane"], r["lane_mismatch"] = lane, lane != sent_lane
for p in r.setdefault("prescan_responses", []) or []:
p["id"] = str(p.get("id", "")).upper()
if p.get("call") not in ("confirmed", "dismissed"):
p["call"] = "confirmed"
if lane == "setup":
if r.get("skew") not in SKEWS:
r["skew"] = "balanced"
r["watch"] = sorted((w for w in r.get("watch") or [] if w.get("metric")),
key=lambda w: int(w.get("rank") or 99))
by_case = {}
for s in r.get("scenarios") or []:
c = str(s.get("case") or s.get("name") or "").lower()
if c in CASES and c not in by_case:
s["stock_reaction_pct"] = num_or_none(s.get("stock_reaction_pct"))
s["probability_pct"] = num_or_none(s.get("probability_pct"))
by_case[c] = s
r["scenarios"] = [by_case[c] for c in CASES if c in by_case]
r["catalysts"] = r.get("catalysts") or []
else:
for k in ("thesis", "quotes", "estimates_table", "beyond_eps", "themes", "news"):
r[k] = r.get(k) or []
for item in r["thesis"] + r["beyond_eps"] + r["themes"]:
item["refs"] = split_refs(item.get("refs"))
v = r.get("valuation")
r["valuation"] = v if isinstance(v, dict) else {"text": str(v or ""), "refs": []}
return r
r = parse_reply(job["output"]["output"], INPUT["task"])
if r["lane"] == "setup":
print(r["skew"], [(w["ref"], w["bar"]) for w in r["watch"]],
sum(s["probability_pct"] or 0 for s in r["scenarios"]))
else:
print(r["title"], len(r["quotes"]), "quotes,", len(r["estimates_table"]), "table rows")
// recon.js does all of this, exactly as the page does:
import { createRequire } from "node:module";
const Recon = createRequire(import.meta.url)("./recon.js"); // https://preview-desk.skillsafe.ai/recon.js
const r = Recon.normalize(Recon.parseResult(job.output.output), INPUT.task);
if (r.lane_mismatch) console.warn("the model answered the", r.lane, "lane");
if (r.lane === "setup") {
console.log(r.skew, r.watch.map((w) => `${w.ref} ${w.bar}`), r.scenarios.map((s) => `${s.case} ${s.eps} ${s.stock_reaction_pct}%`));
} else {
console.log(r.title, r.quotes.length, "quotes", r.estimates_table.map((e) => e.ref));
}
type Reply struct {
Lane string `json:"lane"`
Headline string `json:"headline"`
Skew string `json:"skew"`
Watch []struct {
Rank int `json:"rank"`
Metric string `json:"metric"`
Ref string `json:"ref"`
Bar string `json:"bar"`
} `json:"watch"`
Scenarios []struct {
Case string `json:"case"`
Revenue string `json:"revenue"`
EPS string `json:"eps"`
StockReaction *float64 `json:"stock_reaction_pct"`
Probability *float64 `json:"probability_pct"`
} `json:"scenarios"`
Title string `json:"title"`
Quotes []struct {
Text string `json:"text"`
Speaker string `json:"speaker"`
Ref string `json:"ref"`
} `json:"quotes"`
EstimatesTable []struct {
Ref string `json:"ref"`
Metric string `json:"metric"`
Consensus string `json:"consensus"`
Ours string `json:"ours"`
YoY string `json:"yoy"`
} `json:"estimates_table"`
Summary string `json:"summary"`
}
text := jobOutput // data.output.output from step 5
var r Reply
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
fmt.Println(r.Lane, r.Skew, len(r.Watch), len(r.Scenarios), r.Title, len(r.Quotes), len(r.EstimatesTable))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
if ("note".equals(r.path("lane").asText())) {
System.out.println(r.path("title").asText() + " / " + r.get("quotes").size() + " quotes, " + r.get("estimates_table").size() + " table rows");
} else {
System.out.println(r.path("skew").asText() + " / " + r.get("watch").size() + " watch items, " + r.get("scenarios").size() + " scenarios");
}
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
if r["lane"] == "note"
puts r["title"], r["quotes"].map { |q| "#{q['ref']} #{q['text']}" }
else
puts r["skew"], r["scenarios"].map { |s| "#{s['case']} #{s['eps']} #{s['stock_reaction_pct']}%" }.inspect
end
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
if (($r["lane"] ?? "") === "note") {
echo $r["title"], " / ", count($r["quotes"]), " quotes, ", count($r["estimates_table"]), " table rows", PHP_EOL;
} else {
echo $r["skew"], " / ", count($r["watch"]), " watch items, ", count($r["scenarios"]), " scenarios", PHP_EOL;
}
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
if (r.GetProperty("lane").GetString() == "note")
Console.WriteLine($"{r.GetProperty("title")} / {r.GetProperty("quotes").GetArrayLength()} quotes");
else
Console.WriteLine($"{r.GetProperty("skew")} / {r.GetProperty("scenarios").GetArrayLength()} scenarios");
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:
- Both lanes: every
refnames an id that exists in this pack (Erows,Tparagraphs,Hprints,Nnotes,Vpeers,Pflags);prescan_responsesanswers everypack.prescan_flagsid exactly once and names no other; a dismissed critical flag deserves a second look. - Both lanes: every dollar figure and multiple in the prose is a figure in the pack, one the browser computed, or the result of arithmetic shown on known figures (
$188.3M / $1,395M = 13.50%); a percentage it cannot trace is a softer note. - Setup: bull, base and bear are all present; revenue and EPS are ordered bull at or above base at or above bear; the bull stock reaction is above the bear's; the probabilities sum to 100 (within 2); a reaction beyond 2.5 times the implied move, or a base-case EPS outside both consensus and your number, is flagged softly.
- Setup:
trading_setup.implied_move_pctandavg_abs_move_pctmatchpack.implied_move.pctandpack.reactions.stats.avg_abs_move_pctwithin 0.1, and arenullwhen the pack has none. - Setup: 3-6 watch items and 3-5 catalysts; every
baron an estimates row is a number that row carries (consensus, yours, whisper, a guidance bound, year ago or the FY-implied quarter); an upside skew with your EPS more than 3% under consensus, or a downside skew more than 3% over, should say why. - Note: every quote is a verbatim span of the transcript (case, whitespace and quote marks aside) and sits in the paragraph it cites, attributed to that paragraph's speaker; 2-4 quotes when there is a transcript, none when there is not.
- Note: every
estimates_tablerow cites a real row, carries that row's consensus and your number, and itsyoyis the pack'syoy_consensus(within 5 bp or 0.15 points;n.m.where the pack says so); the revenue and EPS rows are both in the table. - Note: no valuation ratio is called "trailing" or "forward"; a P/E names LTM or NTM; every multiple in the valuation paragraph is a P/E the browser computed or shown arithmetic on them; no emoji.
- Note: every news item cites one of your notes (
N1...); the title names the quarter being previewed; 4-6 thesis bullets, 3-5 metrics beyond EPS, 3-5 themes; each quote'ssupportsis a real thesis bullet; when written from a setup, at least one of the setup's top three watch refs is cited in the thesis or the metrics beyond EPS.
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.