Drive Catalyst Desk from your own code
Everything the web page does is available over HTTP. Send a coverage universe's upcoming catalysts (earnings dates, investor days, regulatory decisions, trial readouts, lock-ups, conferences, macro releases) and your book of positions, already cleaned the way the browser cleans them, and get the same weekly preview back: a headline, this week's key events with the one thing to watch in each, next week's heads-up, an implication for every exposed position with a pre-positioning and a risk step, the impact ratings the model would change, the dates to verify, the gaps in the list, one response per prescan flag and a plain-text note ready to email. The natural use is a Monday pipeline: a script pulls the coverage calendar and the book, rolls the as-of date forward, and mails the preview before the open.
One thing to be clear about before the first call: the model never does the mechanical
half. Normalising every date (ISO, US slashes, month names, weekday prefixes, "week of",
months and quarters, TBD), dropping duplicate rows, typing each event (Earnings, Corporate, Industry,
Macro) with a sub-type, giving it an H/M/L impact that accounts for the position it touches, placing
it in a week window relative to the as-of date, matching it to a position, and raising the flags
(two earnings dates for one name, unconfirmed and approximate dates, weekend dates, weekday typos,
crowded days, a held name reporting on FOMC day, binary events on large positions, positions with no
catalyst) are all worked out by catkit.js, the same file the web page loads. The
results are sent as windows, events, book and
flags, each a JSON string. The model's job is judgement over that work. See
building the input below.
The task field
Every request names its task in task. Catalyst Desk has exactly one paid task:
| task | what it does |
|---|---|
preview | The weekly catalyst preview (catalyst-calendar): a headline, the this_week events that matter for the book (every H event in the window, plus M events that touch a position) with why, focus and the positions each can move, next_week heads-ups, one position_implications entry per position with an H or binary event (exposure binary, elevated or routine, a pre-positioning step and a risk step), impact_changes, verify, gaps, one prescan_responses entry per flag and an email-ready summary. |
Always send "task": "preview". A missing or different task is still answered as the
preview and the reply's task says "preview"; if a reply ever names another
task, Recon.normalize marks it lane_mismatch. Everything that is free on
the page (the cleaned calendar, the flags, the .ics, CSV and Markdown exports) is
catkit.js running locally and never touches the API.
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":"catalyst-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 calendar.
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: horizon_days is "28", not 28, and windows, events, book and flags must be JSON-encoded strings, not objects or arrays. 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. The preview is
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://catalyst-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; a preview run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"catalyst-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://catalyst-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": "catalyst-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://catalyst-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: "catalyst-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://catalyst-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":"catalyst-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://catalyst-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\":\"catalyst-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://catalyst-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: "catalyst-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://catalyst-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" => "catalyst-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://catalyst-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\":\"catalyst-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="catalyst-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://catalyst-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 = "catalyst-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://catalyst-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 = "catalyst-desk";
const TOKEN = "YOUR_TOKEN"; // from https://catalyst-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 = "catalyst-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://catalyst-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 CatalystDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "catalyst-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 = "catalyst-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://catalyst-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 = "catalyst-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 CatalystDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "catalyst-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 CatalystDesk.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, always "preview" (see the task field).
Seven fields are required and three are optional:
| field | type | meaning |
|---|---|---|
task | string, required | "preview". |
as_of | string, required | The date the preview is written, YYYY-MM-DD ("2026-10-26"). Every window is counted from it. CatKit.analyze falls back to today, with a warning, when it is not a valid date. |
horizon_days | string, required | How far ahead the list runs, as a string: "28". The browser clamps it to 7-120 days (28 when unreadable) before sending it. |
windows | string, required | A JSON-encoded object: {"this_week":{"from","to"},"next_week":{"from","to"},"horizon_end"}. The week being previewed starts at as_of on a weekday (the next Monday on a weekend) and ends that Sunday; next week is the Monday-Sunday after; horizon_end is as_of plus the horizon. |
events | string, required | A JSON-encoded array of the cleaned events, in calendar order, then the undated ones. Each is {id, date, day, time, ticker, event, type, sub, impact, impact_source, confirmed, window, position, binary} plus approx and notes when present: id E1, E2 ... (numbered in paste order, so gaps mean a row was dropped or left out); date YYYY-MM-DD or "TBD"; day "Mon" ... or ""; time BMO, AMC, a clock time or ""; ticker upper-cased, MACRO for market-wide; type Earnings, Corporate, Industry or Macro; sub a label such as "earnings", "regulatory decision", "trial readout", "lock-up expiry", "macro (major)"; impact H, M or L; impact_source yours (you set it; the model may not change it) or browser; confirmed false for estimated or approximate dates; window this_week, next_week, later or unscheduled (rows before as_of or past the horizon are never sent); position a book id or ""; binary true for a yes/no outcome the stock gaps on; approx week, month, quarter or tbd. At most 120 events are sent (CatKit.clipEvents): this week and next week stay whole, later weeks keep H, then M, then L. |
book | string, required | A JSON-encoded array of positions, {id, ticker, side} plus weight_pct (negative for a short) and thesis when given; ids P1, P2 ...; side long, short or neutral. Send "[]" when there is no book. |
flags | string, required | A JSON-encoded array of the prescan findings, {id, code, severity, text}, ids F1, F2 ..., severity bad, warn or info. Codes: duplicate, conflicting_dates, past, beyond, unconfirmed, approximate, weekend, weekday_mismatch, crowded_day, macro_overlap, binary_large, no_catalyst, no_earnings_date, unparsed. "[]" when nothing was found. |
question | string, optional | What you want to know, up to 1,500 characters; the model answers it in the headline or the note's first paragraph. Left out when empty. A longer question is cut on a word boundary and ends with [question cut here: N more characters not sent]. |
clip_note | string, optional | Set by buildInput only when more than 120 events were in range: "N later lower-impact events were not sent (the page shows them): E131, E140.". Left out otherwise. |
retry_note | string, optional | Leave it out. The page sets it only on its one reformat retry after an unparseable reply (see step 5). |
Every value is a string. windows, events, book
and flags hold JSON but travel JSON-encoded, and horizon_days is a numeral in
a string; an object, an array or a bare number is a validation_error on a run. The app
declares an input schema with the first seven fields required, but /estimate does no
body validation: a malformed body prices as happily as a good one. So validate on your side before
you run: the body must be an object whose task is "preview", whose
as_of is a YYYY-MM-DD date, whose horizon_days is a string of
digits, whose windows parses to an object and whose events,
book and flags parse to arrays, with at least one event. The web app builds
every input with CatKit.buildInput, which guarantees that shape, runs only when at least
one event is in range, and passes the body through CatKit.mustBeObject before pricing or
running it.
Building the input
catkit.js is plain JavaScript with no dependencies and no network, is served next to the
page (catkit.js, with recon.js for the reply) and
exports itself to node through module.exports. Download it and let it build the body
exactly as the page does, rather than re-implementing the date reader, the impact rules and the
flags. CatKit.analyze({as_of, horizon, events, book}) runs the whole free pass over the
two pastes as text: events is a table (pipes, tabs, semicolons or commas, with a header
naming Date, Time, Ticker, Event, Type, Impact, Notes) or loose lines that start with a date;
book is Ticker | Side | Weight | Thesis or loose lines such as
FRSC long 4% holiday comps ahead of a nervous Street. CatKit.buildInput(A,
{question}) then produces the body. It is free and local; only the run is metered.
// make-body.js
// node make-body.js events.txt book.txt 2026-10-26 28 "your question" > body.json
const fs = require("fs");
const CatKit = require("./catkit.js"); // https://catalyst-desk.skillsafe.ai/catkit.js
const [eventsFile, bookFile, asOf, horizon = "28", question = ""] = process.argv.slice(2);
const read = (f) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "");
const A = CatKit.analyze({ as_of: asOf, horizon, events: read(eventsFile), book: read(bookFile) });
A.warnings.forEach((w) => console.error("note:", w));
if (A.calendar.length + A.unscheduled.length === 0) throw new Error("no event falls in the horizon - nothing to preview");
const body = CatKit.mustBeObject(CatKit.buildInput(A, { question }));
console.error("Idempotency-Key catalyst-desk:preview:" + CatKit.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body)); // {task, as_of, horizon_days, windows:"{...}", events:"[...]", book:"[...]", flags:"[...]", question?}
To reproduce the worked input below, load the page's own example instead of files:
const ex = require("./example.js").byId("busy"); then
CatKit.buildInput(CatKit.analyze({as_of: ex.as_of, horizon: ex.horizon, events: ex.events, book: ex.book}), {question: ex.question})
(example.js also exports itself to node).
Worked input
The page's "busy" example: a long/short healthcare-and-software book walking into FOMC week on
2026-10-26 with a 28-day horizon, seven positions and 20 pasted rows. The browser drops a duplicate
HLVR row (E5), leaves out one past row (E18) and one beyond the horizon (E19), and sends 17 events and
12 flags. This is the real output of CatKit.buildInput for it; the events
string is abbreviated here (5 of the 17 events shown, E1, E4, E3, E7 and E20, each verbatim), and
every other field, and every field name and shape, is exact:
{
"task": "preview",
"as_of": "2026-10-26",
"horizon_days": "28",
"windows": "{\"this_week\":{\"from\":\"2026-10-26\",\"to\":\"2026-11-01\"},\"next_week\":{\"from\":\"2026-11-02\",\"to\":\"2026-11-08\"},\"horizon_end\":\"2026-11-23\"}",
"events": "[{\"id\":\"E1\",\"date\":\"2026-10-26\",\"day\":\"Mon\",\"time\":\"BMO\",\"ticker\":\"ORVL\",\"event\":\"Q3 earnings\",\"type\":\"Earnings\",\"sub\":\"earnings\",\"impact\":\"M\",\"impact_source\":\"browser\",\"confirmed\":true,\"window\":\"this_week\",\"position\":\"P3\",\"binary\":false,\"notes\":\"consensus EPS $1.12, ours $1.04; focus: backlog conversion\"},{\"id\":\"E4\",\"date\":\"2026-10-28\",\"day\":\"Wed\",\"time\":\"14:00 ET\",\"ticker\":\"MACRO\",\"event\":\"FOMC rate decision\",\"type\":\"Macro\",\"sub\":\"macro (major)\",\"impact\":\"H\",\"impact_source\":\"browser\",\"confirmed\":true,\"window\":\"this_week\",\"position\":\"\",\"binary\":false,\"notes\":\"market prices a 25bp cut\"},{\"id\":\"E3\",\"date\":\"2026-10-28\",\"day\":\"Wed\",\"time\":\"AMC\",\"ticker\":\"HLVR\",\"event\":\"Q3 FY2026 earnings\",\"type\":\"Earnings\",\"sub\":\"earnings\",\"impact\":\"H\",\"impact_source\":\"browser\",\"confirmed\":true,\"window\":\"this_week\",\"position\":\"P1\",\"binary\":false,\"notes\":\"consensus EPS $0.71, ours $0.75; focus: net revenue retention\"},{\"id\":\"E7\",\"date\":\"2026-10-30\",\"day\":\"Fri\",\"time\":\"\",\"ticker\":\"NVRA\",\"event\":\"PDUFA date for NVR-201\",\"type\":\"Corporate\",\"sub\":\"regulatory decision\",\"impact\":\"H\",\"impact_source\":\"browser\",\"confirmed\":true,\"window\":\"this_week\",\"position\":\"P2\",\"binary\":true,\"notes\":\"label breadth is the swing factor\"},{\"id\":\"E20\",\"date\":\"TBD\",\"day\":\"\",\"time\":\"\",\"ticker\":\"QXTM\",\"event\":\"Phase 3 topline data readout for QN-7\",\"type\":\"Corporate\",\"sub\":\"trial readout\",\"impact\":\"H\",\"impact_source\":\"browser\",\"confirmed\":false,\"window\":\"unscheduled\",\"position\":\"P6\",\"binary\":true,\"approx\":\"tbd\",\"notes\":\"company guides to 'fourth quarter'\"}]",
"book": "[{\"id\":\"P1\",\"ticker\":\"HLVR\",\"side\":\"long\",\"weight_pct\":6,\"thesis\":\"net revenue retention re-accelerates through FY2027\"},{\"id\":\"P2\",\"ticker\":\"NVRA\",\"side\":\"long\",\"weight_pct\":5.5,\"thesis\":\"NVR-201 approval with a broad label is underappreciated\"},{\"id\":\"P3\",\"ticker\":\"ORVL\",\"side\":\"short\",\"weight_pct\":-3,\"thesis\":\"backlog is peaking and conversion is slowing\"},{\"id\":\"P4\",\"ticker\":\"PLMQ\",\"side\":\"long\",\"weight_pct\":2.5,\"thesis\":\"margin recovery as input costs roll off\"},{\"id\":\"P5\",\"ticker\":\"TRNX\",\"side\":\"short\",\"weight_pct\":-4,\"thesis\":\"pricing pressure in the core segment\"},{\"id\":\"P6\",\"ticker\":\"QXTM\",\"side\":\"long\",\"weight_pct\":1.5,\"thesis\":\"cheap optionality on the QN-7 readout\"},{\"id\":\"P7\",\"ticker\":\"DLTA\",\"side\":\"long\",\"weight_pct\":3,\"thesis\":\"multi-year share gains in dental consumables\"}]",
"flags": "[{\"id\":\"F1\",\"code\":\"duplicate\",\"severity\":\"warn\",\"text\":\"1 duplicate row dropped from the calendar: E5 repeats E3.\"},{\"id\":\"F2\",\"code\":\"conflicting_dates\",\"severity\":\"bad\",\"text\":\"TRNX has two earnings dates 7 days apart - E13 2026-11-10 and E16 2026-11-17. Confirm with investor relations before positioning.\"},{\"id\":\"F3\",\"code\":\"past\",\"severity\":\"info\",\"text\":\"1 row is before the as-of date and left out: E18. Archive them with the actual outcome.\"},{\"id\":\"F4\",\"code\":\"beyond\",\"severity\":\"info\",\"text\":\"1 row falls after the 28-day horizon (2026-11-23) and is left out: E19.\"},{\"id\":\"F5\",\"code\":\"unconfirmed\",\"severity\":\"warn\",\"text\":\"1 date is marked estimated or unconfirmed: E10 PLMQ. Earnings dates shift - verify against investor relations closer to the date.\"},{\"id\":\"F6\",\"code\":\"approximate\",\"severity\":\"warn\",\"text\":\"2 rows have no exact date (TBD, a week, a month or a quarter): E15 NVRA week of 2026-11-16, E20 QXTM TBD.\"},{\"id\":\"F7\",\"code\":\"weekend\",\"severity\":\"warn\",\"text\":\"1 date falls on a weekend, which is unusual for this kind of event - check for a typo: E12 SBRK Sat 11/07.\"},{\"id\":\"F8\",\"code\":\"weekday_mismatch\",\"severity\":\"bad\",\"text\":\"1 row names a weekday that does not match its date: E8 says Thu but 2026-10-30 is a Fri.\"},{\"id\":\"F9\",\"code\":\"macro_overlap\",\"severity\":\"warn\",\"text\":\"HLVR reports (E3) on the same day as FOMC rate decision (E4) - a macro move can swamp the print.\"},{\"id\":\"F10\",\"code\":\"binary_large\",\"severity\":\"bad\",\"text\":\"NVRA has a binary regulatory decision (E7, 2026-10-30) on a 5.5% long - decide the pre-event size deliberately.\"},{\"id\":\"F11\",\"code\":\"no_catalyst\",\"severity\":\"warn\",\"text\":\"1 position has no catalyst in the next 28 days: P7 DLTA. Either the list is missing its dates or the name is conspicuously quiet.\"},{\"id\":\"F12\",\"code\":\"no_earnings_date\",\"severity\":\"info\",\"text\":\"3 held positions have no earnings date anywhere in the list: P2 NVRA, P6 QXTM, P7 DLTA.\"}]",
"question": "What do we need to decide before Wednesday, and is the NVRA size right into the PDUFA?"
}
CatKit.hashInput of the full body is 323b76fc71k, so the page's first
attempt for it goes out with Idempotency-Key: catalyst-desk:preview:323b76fc71k:a1.
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. A longer calendar or a bigger book prices higher. 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,re;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task")=="preview" and re.fullmatch(r"\d{4}-\d{2}-\d{2}",b.get("as_of","")) and str(b.get("horizon_days","")).isdigit() and isinstance(b["horizon_days"],str) and isinstance(json.loads(b["windows"]),dict) and all(isinstance(json.loads(b[k]),list) for k in ("events","book","flags")) and json.loads(b["events"])'
INPUT=$(cat body.json)
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.
import re
INPUT = json.load(open("body.json")) # task, as_of, horizon_days, windows, events, book, flags (+ question, clip_note)
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") != "preview":
raise ValueError('task must be "preview"')
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(body.get("as_of", ""))):
raise ValueError("as_of must be a YYYY-MM-DD string")
if not isinstance(body.get("horizon_days"), str) or not body["horizon_days"].isdigit():
raise ValueError('horizon_days must be a string such as "28"')
for k, kind in (("windows", dict), ("events", list), ("book", list), ("flags", list)):
if not isinstance(body.get(k), str) or not isinstance(json.loads(body[k]), kind):
raise ValueError(f"{k} must be a JSON-encoded {kind.__name__}, sent as a string")
if not json.loads(body["events"]):
raise ValueError("no event in range - nothing to preview")
for k in ("question", "clip_note", "retry_note"):
if k in body and not isinstance(body[k], str):
raise ValueError(f"{k} must be a string")
return body
est = call("estimate", must_be_body(INPUT))
print(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 !== "preview") throw new Error('task must be "preview"');
if (!/^\d{4}-\d{2}-\d{2}$/.test(b.as_of || "")) throw new Error("as_of must be a YYYY-MM-DD string");
if (typeof b.horizon_days !== "string" || !/^\d+$/.test(b.horizon_days)) throw new Error('horizon_days must be a string such as "28"');
const w = JSON.parse(b.windows);
if (!w || typeof w !== "object" || Array.isArray(w)) throw new Error("windows must be a JSON string holding an object");
for (const k of ["events", "book", "flags"]) {
if (typeof b[k] !== "string" || !Array.isArray(JSON.parse(b[k]))) throw new Error(`${k} must be a JSON string holding an array`);
}
if (!JSON.parse(b.events).length) throw new Error("no event in range - nothing to preview");
return b;
}
const INPUT = mustBeBody(JSON.parse(readFileSync("body.json", "utf8")));
const est = await call("estimate", INPUT);
console.log(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.
str := func(k string) string { s, _ := input[k].(string); return s }
var windows map[string]any
var events, book, flags []any
if str("task") != "preview" || len(str("as_of")) != 10 || strings.Trim(str("horizon_days"), "0123456789") != "" || str("horizon_days") == "" ||
json.Unmarshal([]byte(str("windows")), &windows) != nil ||
json.Unmarshal([]byte(str("events")), &events) != nil || len(events) == 0 ||
json.Unmarshal([]byte(str("book")), &book) != nil || json.Unmarshal([]byte(str("flags")), &flags) != nil {
panic(`run input needs task "preview", as_of, horizon_days as a string, and windows/events/book/flags as JSON strings`)
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(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);
if (!body.isObject() || !"preview".equals(body.path("task").asText(""))
|| !body.path("as_of").asText("").matches("\\d{4}-\\d{2}-\\d{2}")
|| !body.path("horizon_days").isTextual() || !body.path("horizon_days").asText().matches("\\d+")
|| !body.path("windows").isTextual() || !mapper.readTree(body.path("windows").asText()).isObject()
|| !body.path("events").isTextual() || mapper.readTree(body.path("events").asText()).size() == 0
|| !body.path("book").isTextual() || !mapper.readTree(body.path("book").asText()).isArray()
|| !body.path("flags").isTextual() || !mapper.readTree(body.path("flags").asText()).isArray()) {
throw new IllegalArgumentException("run input needs task preview, as_of, horizon_days and windows/events/book/flags 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) && INPUT["task"] == "preview" &&
INPUT["as_of"].to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/) &&
INPUT["horizon_days"].is_a?(String) && INPUT["horizon_days"].match?(/\A\d+\z/) &&
INPUT["windows"].is_a?(String) && JSON.parse(INPUT["windows"]).is_a?(Hash) &&
%w[events book flags].all? { |k| INPUT[k].is_a?(String) && JSON.parse(INPUT[k]).is_a?(Array) } &&
!JSON.parse(INPUT["events"]).empty?
raise "run input needs task preview, as_of, horizon_days and windows/events/book/flags as JSON strings"
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) && ($input["task"] ?? null) === "preview"
&& preg_match('/^\d{4}-\d{2}-\d{2}$/', $input["as_of"] ?? "")
&& is_string($input["horizon_days"] ?? null) && ctype_digit($input["horizon_days"]);
foreach (["windows", "events", "book", "flags"] as $k) {
$ok = $ok && is_string($input[$k] ?? null) && is_array(json_decode($input[$k], true));
}
if (!$ok || count(json_decode($input["events"], true)) === 0) {
throw new RuntimeException("run input needs task preview, as_of, horizon_days and windows/events/book/flags as JSON strings");
}
$est = call("estimate", $input);
echo $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:
string? S(string k) => input.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
JsonValueKind Kind(string k) => S(k) is string s ? JsonSerializer.Deserialize<JsonElement>(s).ValueKind : JsonValueKind.Undefined;
if (input.ValueKind != JsonValueKind.Object || S("task") != "preview"
|| !System.Text.RegularExpressions.Regex.IsMatch(S("as_of") ?? "", @"^\d{4}-\d{2}-\d{2}$")
|| !System.Text.RegularExpressions.Regex.IsMatch(S("horizon_days") ?? "", @"^\d+$")
|| Kind("windows") != JsonValueKind.Object || Kind("events") != JsonValueKind.Array
|| Kind("book") != JsonValueKind.Array || Kind("flags") != JsonValueKind.Array
|| JsonSerializer.Deserialize<JsonElement>(S("events")!).GetArrayLength() == 0)
throw new Exception("run input needs task preview, as_of, horizon_days and windows/events/book/flags as JSON strings");
var est = await CatalystDesk.Call("estimate", input);
Console.WriteLine($"{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 task in it. The web app
sends "catalyst-desk:preview:" + hash + ":a" + attempt, for example
catalyst-desk:preview:323b76fc71k:a1 for the busy body above. A retried request with the
same key returns the same job instead of billing a second run. Replaying a key with a
different body is a 409, so bump the attempt suffix when you resend a changed body. The web
app's hash is CatKit.hashInput: FNV-1a over the input serialised with its keys sorted,
as eight hex digits, followed by that serialisation's length in base 36. It is 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 you want keys identical to the page's, call
CatKit.hashInput(body) in node (make-body.js above prints it).
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 'preview' - no prose, no code fences;
every key present (empty arrays where there is nothing to say). 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 task and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="catalyst-desk:preview:$(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":"{\"task\":\"preview\",\"headline\":\"Before Wednesday we need a HLVR plan ...\", ...}"},
# "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
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"catalyst-desk:preview:{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["headline"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `catalyst-desk:preview:${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.headline, job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("catalyst-desk:preview:%x:a1", 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"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "catalyst-desk:preview:" + 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"
key = "catalyst-desk:preview:#{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["headline"], "charged #{job['charged_credits']} truncated #{job['truncated']}"
<?php
$key = "catalyst-desk:preview:" . 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["headline"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "catalyst-desk:preview:" + 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 CatalystDesk.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("headline"));
6. Or stream it
POST /run-stream is the same call over server-sent events, with the same personal token
and the same Idempotency-Key. A job event names the job first; each
delta event carries {"text": "..."}, a chunk of the reply; tick
events are progress only and carry no text; and the final done event carries
status, charged_credits and truncated. A browser client
receives ticks rather than text deltas (the web page advances its progress stages on elapsed time and
takes the reply from the finished job), so a browser integration should take the reply from the
done payload's output.output when it is there (the page does exactly
this, falling back to the deltas) or poll GET jobs/{job_id} from step 5, which always
has the whole reply. The samples below keep only delta text and the done
payload and ignore every other event.
# Server-sent events. `delta` events carry chunks of the reply; `tick` events are
# progress only; `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":"{\"task\":\"preview\",\"headline\":\"Before Wednesday we need"}
# event: tick {...}
# 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 and
requires an object (Recon.parseResult), then coerces it to the render contract with
Recon.normalize(obj) (recon.js, which also exports itself to
node). Normalizing keeps only objects inside each list and makes every missing list an empty array
and every missing string ""; upper-cases every event_id,
position_id, flag_id and impact, from and
to; accepts positions and event_ids as arrays or as a
comma- or space-separated string; lower-cases exposure; defaults task to
"preview" and sets lane_mismatch when the reply names another task; and
lists in present the nine sections the reply actually carried. A reply that has no JSON
object, or does not parse, triggers the one retry_note retry from step 5.
# 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("task"), "-", r["headline"])
for x in r["this_week"]:
print("this week", x["event_id"], x["impact"], x["positions"], "|", x["focus"])
for x in r["next_week"]:
print("next week", x["event_id"], "|", x["heads_up"])
for p in r["position_implications"]:
print(p["position_id"], p["exposure"], p["event_ids"], "|", p["pre_positioning"])
print(len(r["verify"]), "to verify,", len(r["gaps"]), "gaps,", len(r["prescan_responses"]), "flag responses")
print(r["summary"])
EOF
LISTS = ("this_week", "next_week", "position_implications", "impact_changes", "verify", "gaps", "prescan_responses")
SECTIONS = ("headline",) + LISTS + ("summary",)
def ids(v):
if isinstance(v, list):
return [str(x).strip().upper() for x in v if str(x).strip()]
return [x.upper() for x in str(v or "").replace(",", " ").split()]
def parse_reply(text):
t = text.strip()
if t.startswith("```"):
t = t.strip("`").removeprefix("json")
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
if not isinstance(r, dict):
raise ValueError("the reply is not a JSON object")
r["present"] = [k for k in SECTIONS if r.get(k) is not None]
r["lane_mismatch"] = bool(r.get("task")) and r.get("task") != "preview"
for k in LISTS:
r[k] = [x for x in (r.get(k) or []) if isinstance(x, dict)]
for x in r["this_week"]:
x["event_id"], x["impact"], x["positions"] = str(x.get("event_id", "")).upper(), str(x.get("impact", "")).upper(), ids(x.get("positions"))
for x in r["position_implications"]:
x["position_id"], x["event_ids"] = str(x.get("position_id", "")).upper(), ids(x.get("event_ids"))
x["exposure"] = str(x.get("exposure", "")).lower()
for x in r["prescan_responses"]:
x["flag_id"] = str(x.get("flag_id", "")).upper()
r["headline"], r["summary"] = str(r.get("headline") or ""), str(r.get("summary") or "")
return r
r = parse_reply(job["output"]["output"])
sent = {e["id"]: e for e in json.loads(INPUT["events"])}
flags = [f["id"] for f in json.loads(INPUT["flags"])]
changed = {c["event_id"]: c["to"] for c in r["impact_changes"]}
high = [i for i, e in sent.items() if e["window"] == "this_week" and changed.get(i, e["impact"]) == "H"]
listed = {x["event_id"] for x in r["this_week"]}
print("missing high-impact:", [i for i in high if i not in listed])
print("flags answered:", sorted({x["flag_id"] for x in r["prescan_responses"]}) == sorted(flags))
// 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://catalyst-desk.skillsafe.ai/recon.js
const r = Recon.normalize(Recon.parseResult(job.output.output));
if (r.lane_mismatch) console.warn("the reply names task", r.task);
const check = Recon.reconcile(r, { input: INPUT }); // INPUT = the body you sent
console.log(check.statements, "statements,", check.disagreements, "disagreements,", check.warnings, "warnings");
check.items.forEach((i) => console.log(i.level, i.text));
console.log(r.this_week.map((x) => `${x.event_id} ${x.impact} ${x.positions}`), r.position_implications.map((p) => `${p.position_id} ${p.exposure}`));
type Reply struct {
Task string `json:"task"`
Headline string `json:"headline"`
ThisWeek []struct {
EventID string `json:"event_id"`
Impact string `json:"impact"`
Why string `json:"why"`
Focus string `json:"focus"`
Positions []string `json:"positions"`
} `json:"this_week"`
NextWeek []struct {
EventID string `json:"event_id"`
HeadsUp string `json:"heads_up"`
} `json:"next_week"`
PositionImplications []struct {
PositionID string `json:"position_id"`
EventIDs []string `json:"event_ids"`
Exposure string `json:"exposure"`
Implication string `json:"implication"`
PrePositioning string `json:"pre_positioning"`
Risk string `json:"risk"`
} `json:"position_implications"`
ImpactChanges []struct {
EventID string `json:"event_id"`
From string `json:"from"`
To string `json:"to"`
Reason string `json:"reason"`
} `json:"impact_changes"`
Verify []struct {
EventID string `json:"event_id"`
Reason string `json:"reason"`
} `json:"verify"`
Gaps []struct {
PositionID string `json:"position_id"`
Note string `json:"note"`
} `json:"gaps"`
PrescanResponses []struct {
FlagID string `json:"flag_id"`
Response string `json:"response"`
} `json:"prescan_responses"`
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.Headline, len(r.ThisWeek), len(r.NextWeek), len(r.PositionImplications), len(r.PrescanResponses))
// 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);
System.out.println(r.path("headline").asText());
for (var x : r.path("this_week")) System.out.println(x.path("event_id").asText() + " " + x.path("impact").asText() + " " + x.path("focus").asText());
for (var p : r.path("position_implications")) System.out.println(p.path("position_id").asText() + " " + p.path("exposure").asText());
System.out.println(r.path("prescan_responses").size() + " flag responses");
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
puts r["headline"]
r["this_week"].each { |x| puts "#{x['event_id']} #{x['impact']} #{x['positions'].inspect} #{x['focus']}" }
r["position_implications"].each { |p| puts "#{p['position_id']} #{p['exposure']} #{p['pre_positioning']}" }
puts "#{r['prescan_responses'].size} flag responses"
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $r["headline"], PHP_EOL;
foreach ($r["this_week"] as $x) echo $x["event_id"], " ", $x["impact"], " ", $x["focus"], PHP_EOL;
foreach ($r["position_implications"] as $p) echo $p["position_id"], " ", $p["exposure"], PHP_EOL;
echo count($r["prescan_responses"]), " flag responses", 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);
Console.WriteLine(r.GetProperty("headline"));
foreach (var x in r.GetProperty("this_week").EnumerateArray())
Console.WriteLine($"{x.GetProperty("event_id")} {x.GetProperty("impact")} {x.GetProperty("focus")}");
Console.WriteLine($"{r.GetProperty("position_implications").GetArrayLength()} positions, {r.GetProperty("prescan_responses").GetArrayLength()} flag responses");
Invariants worth asserting
The page holds every reply to the calendar it sent with Recon.reconcile(r, {input}),
where input is the body you sent (it re-parses events, book
and flags from it). It returns statements checked,
disagreements (the preview, not the calendar, is wrong), softer warnings,
the items behind them, flag coverage and how many of this week's H events
were listed. What it checks:
- Every
event_idis an event that was sent, everyposition_idand every id inpositionsis a position inbook, and everyflag_idis a flag the browser raised. impact_changes:tois H, M or L;fromequals the event's currentimpact; and no event whoseimpact_sourceisyoursis changed.this_week: each event'swindowisthis_week; itsimpactequals the event's impact, or thetoof the reply's own change for it; every event in the window that is H (after changes) is listed. Softer: an event linked to a position holding a different ticker when it is not Macro or Industry, and an event that touches a position the entry does not link.next_week: each event'swindowisnext_week.position_implications:exposureisbinary,elevatedorroutine, and it isbinaryexactly when one of the cited events hasbinary: true; every position with an H (after changes) or binary event anywhere in the sent events has an entry. Softer: citing another company's non-market-wide event, and an emptypre_positioningorrisk.verifyandgaps(softer): every unconfirmed H or M event is on the verify list, and every position named in ano_catalystflag is in the gaps.prescan_responses: every flag id is answered with a non-empty response.- Every
$amount and percentage in the prose (headline, summary, every why, focus, heads-up, implication, step, reason, note and response) is a number that appears inevents,book,flags,questionorwindows; every ticker-like word is a ticker in the list or the book, an id, or a common acronym (FOMC, CPI, PDUFA, BMO, EPS ...). - Softer: an empty
headlineorsummary.
The output contract
{
"task": "preview",
"headline": "one sentence: the single most important thing about this week for this book",
"this_week": [
{"event_id": "E3", "impact": "H", "why": "", "focus": "", "positions": ["P1"]}
],
"next_week": [
{"event_id": "E9", "heads_up": ""}
],
"position_implications": [
{"position_id": "P1", "event_ids": ["E3", "E4"], "exposure": "binary" | "elevated" | "routine",
"implication": "", "pre_positioning": "", "risk": ""}
],
"impact_changes": [{"event_id": "E12", "from": "L", "to": "M", "reason": ""}],
"verify": [{"event_id": "E10", "reason": ""}],
"gaps": [{"position_id": "P7", "note": ""}],
"prescan_responses": [{"flag_id": "F1", "response": ""}],
"summary": "the weekly preview note, ready to email: 3-6 short plain-text paragraphs"
}
| key | type | meaning |
|---|---|---|
task | string | Always "preview". |
headline | string | One sentence: the single most important thing about this week for this book. It answers question when one was sent, and says so when this week is empty. |
this_week | array | Events whose window is this_week, in date order: every H event, plus M events that touch a position (L only if the question asks). event_id; impact (the event's, or the to of an impact change); why it matters for the named stocks; focus, the one metric, number or outcome to watch; positions, the book ids it can move (the matching ticker, plus positions a Macro or Industry event plausibly moves), [] when none. May be empty. |
next_week | array | Events whose window is next_week that need preparation now, H and M first: event_id and heads_up, what to prepare. May be empty. |
position_implications | array | One entry for every position with an H or binary event in any window (others only when something genuinely moves them): position_id; event_ids, its own events plus relevant Macro or Industry ones; exposure binary (a cited event is binary), elevated (an H event that is not binary) or routine; implication, respecting the side and saying whether the event tests the thesis; pre_positioning, a concrete step (trim, hedge, hold, add, pair) or "no change" with the reason; risk, the risk-management step. Never a price target or an invented probability. |
impact_changes | array | Only where the book or the notes make the browser's rating wrong: event_id, from (the current impact), to (H, M or L), reason. Never for an impact you set yourself. Usually short or empty. |
verify | array | Every unconfirmed H or M event, plus any date a flag questions (duplicates, two dates for one name, weekday mismatch, weekend dates): event_id and reason, what to confirm and where (investor relations, the exchange or regulator's calendar, a conference agenda). |
gaps | array | Every position the flags say has no catalyst in the horizon, and held names with no earnings date where one would be expected: position_id and note, what to add or check. |
prescan_responses | array | Exactly one entry per flag id, in order: flag_id and response, what the flag means for the preview and what to do. |
summary | string | The weekly preview email in 3-6 short plain-text paragraphs: this week's key events (day, stock, why, focus), next week's heads-up, and position implications with pre-positioning and risk steps ahead of binary events. No Markdown tables, no # headings, no emoji; stocks by ticker; days as the input dates them. |
Every key is present, with [] for an empty list. The ids are the contract: every
event_id, position_id and flag_id comes from the input, and a
$ or % figure in the prose appears in the input; if a consensus or estimate
is not in an event's notes, the preview says what to watch instead of stating one. After
Recon.normalize the object also carries lane ("preview"),
lane_mismatch and present.
Worked reply
Abbreviated from the saved run the page replays for its busy example, the reply to the worked input above (arrays shortened; each shown entry, and the headline and summary, verbatim):
{
"task": "preview",
"headline": "Before Wednesday we need a HLVR plan that survives the FOMC decision landing hours before its print, and the NVRA 5.5% long needs a deliberate cut or hedge before Friday's PDUFA, because a narrow label hurts the thesis almost as much as a rejection.",
"this_week": [
{
"event_id": "E1",
"impact": "H",
"why": "ORVL is our short and this print tests the thesis head-on: we are below the street ($1.04 vs consensus $1.12) on the view that backlog is peaking and conversion is slowing.",
"focus": "Backlog conversion: a slowdown confirms the short; a pickup in conversion is the squeeze risk.",
"positions": [
"P3"
]
},
{
"event_id": "E4",
"impact": "H",
"why": "The market prices a 25bp cut, so the surprise is in the tone. It lands the same afternoon HLVR reports, so a rates move can swamp the HLVR print and reprice long-duration growth names across the book.",
"focus": "Whether the 25bp cut is delivered and what the guidance says about further easing.",
"positions": [
"P1"
]
},
{
"event_id": "E7",
"impact": "H",
"why": "A binary regulatory decision on a 5.5% long. The thesis needs approval with a broad label, so an approval with a narrow label can still break it.",
"focus": "Label breadth for NVR-201, not just approve or reject.",
"positions": [
"P2"
]
}
],
"next_week": [
{
"event_id": "E11",
"heads_up": "The jobs report is the next macro print after the FOMC; if the Fed's guidance leaves the path open, it will be read as the deciding data point for growth longs like HLVR. Decide by Thursday whether any post-earnings HLVR add waits for it."
}
],
"position_implications": [
{
"position_id": "P2",
"event_ids": [
"E7",
"E15"
],
"exposure": "binary",
"implication": "The PDUFA is a gap event in both directions. The thesis is a broad label, so only a broad approval validates it; a narrow label or a rejection both break it. The conference later in the horizon is where management would frame the launch.",
"pre_positioning": "Decide the pre-event size deliberately: trim toward the loss we are willing to take on a full gap down, or keep the size only with an options hedge in place.",
"risk": "Treat the whole position as at risk on the gap; use an options hedge rather than a stop, since a stop does not protect against an overnight gap on a regulatory decision."
},
{
"position_id": "P6",
"event_ids": [
"E20"
],
"exposure": "binary",
"implication": "The QN-7 Phase 3 readout is the whole thesis and has no date beyond company guidance to 'fourth quarter', so it can land any day without warning.",
"pre_positioning": "No change: the small size is the hedge for a binary with no date, which fits the cheap-optionality thesis.",
"risk": "Keep the size at the level we can lose entirely on a failed readout and do not add while the date is unknown."
}
],
"impact_changes": [
{
"event_id": "E1",
"from": "M",
"to": "H",
"reason": "ORVL is a short whose thesis is exactly the stated focus (backlog conversion), and our estimate sits below consensus, so this print is a thesis test for the book, not a routine report."
}
],
"verify": [
{
"event_id": "E8",
"reason": "The row says Thu but 2026-10-30 is a Fri; confirm the PLMQ fireside day on the Baird conference agenda."
},
{
"event_id": "E16",
"reason": "The second TRNX date comes from the IR website, which is probably the right one; confirm it with investor relations before positioning."
}
],
"gaps": [
{
"position_id": "P7",
"note": "DLTA has no catalyst in the 28-day horizon and no earnings date; add its Q3 earnings date from investor relations, or treat the silence as a sign the list is incomplete."
}
],
"prescan_responses": [
{
"flag_id": "F2",
"response": "Do not position TRNX for either date until investor relations confirms; the IR-sourced E16 is probably more reliable than our model calendar's E13."
},
{
"flag_id": "F9",
"response": "This is the key decision before Wednesday: do not add HLVR ahead of the FOMC, and read the print net of the macro move."
},
{
"flag_id": "F10",
"response": "Agreed: the NVRA 5.5% long is the largest binary in the book. Set the pre-event size deliberately or hedge with options before Friday."
}
],
"summary": "Before Wednesday: HLVR, our largest long, reports AMC on Wed 2026-10-28, hours after the 14:00 ET FOMC decision. Hold into it, but do not add ahead of the Fed. On NVRA, the 5.5% long runs into a binary PDUFA on Fri 2026-10-30, and the thesis needs a broad label, so a narrow approval hurts almost as much as a rejection. Set the pre-event size deliberately: trim toward the gap loss we can accept, or keep the size only with an options hedge.\n\nThis week: ORVL reports Mon 2026-10-26 BMO. It is our short, we are at $1.04 against consensus $1.12, and backlog conversion is the thesis test; a conversion beat is the squeeze risk, so have a stop or call hedge ready. The FOMC on Wed is priced for a 25bp cut, so the tone matters more than the decision. HLVR Wed AMC: we are at $0.75 against consensus $0.71, but net revenue retention is the number that tests the thesis. NVRA PDUFA Fri: watch label breadth.\n\nNext week: the jobs report on Fri 2026-11-06 is the major macro print. PLMQ reports Wed 2026-11-04 AMC but the date is unconfirmed; confirm with investor relations and use Friday's CFO fireside at Baird to read the margin tone. The ISM manufacturing PMI on Mon 2026-11-02 gives industrial read-through first.\n\nHousekeeping: TRNX has two earnings dates, 2026-11-10 and 2026-11-17; do not position the short until investor relations confirms. QXTM's QN-7 readout has no date beyond 'fourth quarter', so keep the small size as the hedge. DLTA has no catalyst in the horizon, and NVRA, QXTM and DLTA have no earnings dates in the list; add them."
}
The full reply lists four events this week (ORVL, FOMC, HLVR, NVRA: every H event in the window, with
ORVL raised from M to H in impact_changes because the print is the short's thesis test),
three next-week heads-ups, six position implications (two binary, two
elevated, two routine), eight dates to verify, three gaps and twelve prescan
responses, one per flag. Recon.reconcile checks 87 statements in it and finds no
disagreement and no warning.
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 headline, this week and the position implications
may be complete while the verify list, the flag responses and the summary are missing. The web page
closes the cut-off JSON (Recon.closeJson), shows the sections that arrived (the
present list, out of nine: headline, this week, next week, position implications, impact
changes, verify, gaps, prescan responses, summary) and tells you to top up and run again. 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.
hold_credits from /estimate is what is reserved against the full
output cap before the run starts; it is not the price. What you pay is
charged_credits on the finished job (or the done event), settled on what the
run actually used and normally far below the hold. A truncated run is charged only for the prefix it
produced.