Driving PortCo Desk from your own code
Everything the page does, you can do over HTTP: price a lane for free, run it, stream it, and read
back the same structured analysis the browser renders. The whole free prescan — the table
reader, every variance, every derivable ratio, MOIC, the IRR and the sensitivity grid — runs
in the browser and is not an API: what you send as prescan_facts is your own
arithmetic, and the model is instructed to treat it as ground truth.
https://api.skillsafe.ai/v1/app-api
Every path below is relative to that. https://deploy.skillsafe.ai is the same host
under a different name.
Two headers, and no slug header
Every authenticated call carries exactly two headers, plus Idempotency-Key on a run:
Authorization: Bearer aut_...
Content-Type: application/json
Idempotency-Key: portco-desk:monitor:<hash>:a1 # on /run and /run-stream only
There is no app-slug header. The slug appears in exactly one place in this whole
API — the body of POST /guest. Sending an invented slug header changes nothing:
the call still returns 200 and the app is still resolved from your token, which is why the mistake
survives testing. Do not add one.
The response envelope
Every response, success or failure, has the same outer shape:
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}
Read data on success and error.code on failure. Note the one place this
nests twice: a finished run's data.output.output is a string holding the
analysis JSON, so you parse that string to get the envelope described below.
Error codes
| HTTP | error.code | What it means and what to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The body was not a JSON object, or a field was the wrong type. Note what this does not catch: a body wrapped in an input key is accepted, not rejected — see the warning above. |
| 401 | UNAUTHORIZED | No Authorization header, or the token is expired or revoked. Mint a fresh one; a guest token is short-lived. |
| 402 | PAYMENT_REQUIRED | The balance is below min_credits for this lane. Compare /me against estimate.hold_credits before you run — a 402 after submitting is a defect in your client. |
| 403 | FORBIDDEN | A guest token tried to run a metered lane on an app that does not sponsor guest runs. Sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id, or the app slug in a /guest body does not exist. |
| 409 | CONFLICT | An Idempotency-Key was reused with a different body. Reuse a key only for a retry of the same input. |
| 429 | RATE_LIMITED | Back off and retry. Never tight-loop; the poll interval in these samples is one second for that reason. |
| 503 | UNAVAILABLE | The model provider is unavailable. Retry with the same Idempotency-Key — that is exactly what it is for. |
The four lanes
One system prompt, one model, four contracts. The app routes on an explicit task
field, so set task first — it is the only field that changes
what comes back.
task | Name | The question it answers | Extra block | Source skill |
|---|---|---|---|---|
| monitor | Monitor | Where is the company against the plan it was underwritten on, and what is driving each gap? | drivers | @anthropics/portfolio-monitoring |
| unit | Unit economics | Do the unit economics support the plan, and which ratio is load-bearing? | metrics | @anthropics/unit-economics |
| plan | Value creation | What closes the gap, and what happens in the first hundred days? | levers | @anthropics/value-creation-plan |
| returns | Exit case | On this trajectory, what does the exit look like, and does it clear the hurdle? | cases | @anthropics/returns-analysis |
The intended pipeline is monitor → unit → plan
→ returns, with each result's conclusions passed forward in
carryover. Every lane also stands alone: calling returns first returns a
complete answer.
The input contract
One JSON object, and the object is the request body. There is no
input wrapper. These fields are taken from the app's own submit path, not from intent.
A body shaped {"input": {…}} is accepted, not
rejected. Sending one to /estimate returns 200 with a plausible
hold_credits, because the platform passes your object straight through as the input
— so the model receives a payload whose only key is input, never sees
task at all, and answers whichever lane it guesses. On /run that is a
billed run against a payload the prompt cannot read.
Verified against this app on 2026-08-19: the unwrapped body priced at 1,833 credits and the
wrapped one at 1,719, both 200 ok:true with error: null. There is no error to catch, so the only
defence is sending the object itself. The same is true of an invented app-slug header: a bogus
value returns 200 and changes nothing.
| Field | Type | Meaning |
|---|---|---|
| task | string, required | The lane: monitor, unit, plan or returns. Document and set this first — it is the only field that changes the output contract. An unrecognised value is not an error: the model picks the closest lane and names the one it chose. |
| pack | string, required | The reporting pack: a metrics table of actuals against plan, usually with a prior-year column. Pipe, tab, semicolon, comma or aligned-space columns all read. The primary input for every lane. |
| deal | string | Deal terms as key: value lines — entry equity, entry EBITDA, entry multiple, entry net debt, hold years, years elapsed, exit multiple, exit EBITDA, exit net debt, target IRR, target MOIC, covenant. The returns lane needs them. |
| stage | string | buyout | growth | carve-out | platform | venture | unknown. Changes what “good” looks like, not what is reported. |
| emphasis | string | general | revenue | margin | cash | exit. Changes the ordering of findings, never hides one. |
| context | string | Free text about the period. The model attributes it (“the cover note states…”) rather than asserting it, and never lets it override a pack row. |
| carryover | string | A digest of the previous lane's conclusions, for the monitor → unit → plan → returns pipeline. Empty on a first run. |
| prescan_facts | object | Your own arithmetic, handed over as ground truth: stats, variances, derived, returns, sensitivity and flags. Send at least the empty shape; every flags[].id you send must come back in coverage_check. |
| retry_note | string | Only on a reformat retry. A correction directive about the model's own previous reply — never user content, and never quoted back. |
The output contract
One JSON object, identical in every lane apart from one extra block. This is exactly what the page's own parser reads:
{
"lane": "monitor",
"title": "Q3 FY26: the base is shrinking while the headline holds",
"company": "Lumen Analytics",
"period": "Q3 FY26",
"verdict": "at-risk",
"headline": "Revenue missed by 7.1% and the gap sits entirely in new business, not renewals.",
"summary": "Three to five sentences of context.",
"checks": [
{
"name": "plan comparability",
"status": "pass",
"note": "One sentence."
}
],
"findings": [
{
"id": "PD-001",
"title": "New business is running at two thirds of plan",
"severity": "high",
"area": "revenue",
"metric": "New ARR",
"detail": "What the figures show and why it matters.",
"so_what": "The consequence if nothing changes, in the pack's units.",
"action": "The next action, and the role that owns it."
}
],
"coverage_check": [
{
"prescan_id": "PC-001",
"status": "confirmed",
"note": "why"
}
],
"data_gaps": [
"A line item the pack does not carry, and what it would unlock."
],
"next_steps": [
"Ordered, concrete, at most six."
],
"disclaimer": "This is an analysis of the pasted pack, not investment advice, a valuation or an audit."
}
verdict is on-track only when nothing critical or
high is open; watch means the plan is still reachable;
at-risk means it will be missed unless something changes.
findings[].severity is critical | high |
medium | low and findings[].area is one of
revenue, margin, cash, customers,
operations, capital, reporting, exit.
checks comes back as exactly the requested lane's eight named checks,
in the lane's order, each pass | warn | fail |
unknown. Render against that roster rather than against what arrived: a check the
model skipped should show as missing, not shorten your table.
monitor — Monitor
- plan comparability
- revenue against plan
- revenue composition
- margin and cost quality
- cash and working capital
- customer and retention signals
- leverage and covenant headroom
- reporting integrity
unit — Unit economics
- revenue quality and recurrence
- gross margin and its waterfall
- customer acquisition cost
- payback against the hold
- lifetime value and LTV to CAC
- gross and net revenue retention
- cohort direction
- growth efficiency and burn
plan — Value creation
- gap to plan is quantified
- revenue levers
- cost levers
- capital and working-capital levers
- operational and reporting levers
- sizing is traceable to the pack
- sequencing and dependencies
- accountability and measurement
returns — Exit case
- entry basis is stated
- exit assumptions are explicit
- EBITDA trajectory is supported by the pack
- multiple assumption is called out
- leverage and cash conversion
- value-creation bridge attribution
- hold period sensitivity
- hurdle comparison
Worked examples
Pick a language once and every block on this page follows. The choice is remembered in this browser.
1. A tiny client
Two headers, the {data}/{error} envelope, and one place to add Idempotency-Key. Everything after this reuses it.
# There is no helper to write in a shell: every call below is one curl.
# Two headers, and only two, on every authenticated call:
#
# Authorization: Bearer $TOKEN
# Content-Type: application/json
#
# There is NO app-slug header. The slug identifies the app through the host you
# are calling for the guest mint, and nowhere else.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN=YOUR_TOKEN
import json, time, urllib.request, urllib.error
BASE = "https://api.skillsafe.ai/v1/app-api"
class PortcoDesk:
"""Minimal client. Two headers, no slug header, body is the input object."""
def __init__(self, token=None):
self.token = token
def call(self, method, path, body=None, extra_headers=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
if self.token:
req.add_header("Authorization", "Bearer " + self.token)
for k, v in (extra_headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as res:
payload = json.loads(res.read().decode())
except urllib.error.HTTPError as err:
payload = json.loads(err.read().decode() or "{}")
raise RuntimeError(
"%s %s: %s" % (err.code,
(payload.get("error") or {}).get("code", "?"),
(payload.get("error") or {}).get("message", ""))) from None
# Every response is {"ok": true, "data": ...} or {"ok": false, "error": ...}
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
class PortcoDesk {
// Minimal client. Two headers, no slug header, body is the input object.
constructor(token) { this.token = token || null; }
async call(method, path, body, extraHeaders) {
const headers = { "Content-Type": "application/json", ...(extraHeaders || {}) };
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const e = json.error || {};
throw new Error(`${res.status} ${e.code || "?"}: ${e.message || ""}`);
}
// Every response is { ok: true, data } or { ok: false, error }
return json.data;
}
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
type Client struct {
Token string
HTTP *http.Client
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// Call makes one request. Two headers, no slug header; the body is the input object.
func (c *Client) Call(method, path string, body any, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
for k, v := range extra {
req.Header.Set(k, v)
}
httpc := c.HTTP
if httpc == nil {
httpc = http.DefaultClient
}
res, err := httpc.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 || res.StatusCode >= 400 {
if env.Error != nil {
return nil, fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
}
return nil, fmt.Errorf("%d: request failed", res.StatusCode)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
// Minimal client. Two headers, no slug header, body is the input object.
public final class PortcoDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
private final HttpClient http = HttpClient.newHttpClient();
private String token;
public PortcoDesk(String token) { this.token = token; }
public String call(String method, String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null) b.header("Authorization", "Bearer " + token);
if (extra != null) extra.forEach(b::header);
b.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException(res.statusCode() + ": " + res.body());
}
// Every response is {"ok":true,"data":...} — parse with the JSON library you already use.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
# Minimal client. Two headers, no slug header, body is the input object.
class PortcoDesk
def initialize(token = nil)
@token = token
end
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{@token}" if @token
extra.each { |k, v| req[k] = v }
req.body = JSON.generate(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body) rescue {}
unless res.code.to_i < 400
e = payload["error"] || {}
raise "#{res.code} #{e["code"]}: #{e["message"]}"
end
payload["data"]
end
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Minimal client. Two headers, no slug header, body is the input object.
final class PortcoDesk
{
private ?string $token;
public function __construct(?string $token = null)
{
$this->token = $token;
}
public function call(string $method, string $path, ?array $body = null, array $extra = [])
{
$headers = ["Content-Type: application/json"];
if ($this->token !== null) {
$headers[] = "Authorization: Bearer " . $this->token;
}
foreach ($extra as $k => $v) {
$headers[] = "$k: $v";
}
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$payload = json_decode($raw, true) ?: [];
if ($status >= 400) {
$e = $payload["error"] ?? [];
throw new RuntimeException("$status " . ($e["code"] ?? "?") . ": " . ($e["message"] ?? ""));
}
return $payload["data"];
}
}
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
// Minimal client. Two headers, no slug header, body is the input object.
public sealed class PortcoDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
readonly HttpClient _http = new HttpClient();
string _token;
public PortcoDesk(string token = null) => _token = token;
public async Task<JsonElement> CallAsync(HttpMethod method, string path,
object body = null, (string, string)? extra = null)
{
using var req = new HttpRequestMessage(method, Base + path);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
if (_token != null)
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
if (extra.HasValue)
req.Headers.TryAddWithoutValidation(extra.Value.Item1, extra.Value.Item2);
using var res = await _http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text);
if ((int)res.StatusCode >= 400)
throw new Exception($"{(int)res.StatusCode}: {text}");
// Every response is { "ok": true, "data": ... }
return doc.RootElement.GetProperty("data").Clone();
}
}
2. Get a token
A guest token is enough for /me and /estimate. Running a lane is metered and this app does not sponsor guest runs, so it needs a personal token — the token panel hands you one without opening DevTools.
# A guest token is enough for /me and /estimate. It is NOT enough to run a lane:
# running is metered and this app does not sponsor guest runs.
#
# This is the one and only place the slug appears - in the BODY. There is no
# X-App-Slug header; sending one changes nothing (the call still returns 200).
curl -s -X POST "$BASE/guest" \
-H 'Content-Type: application/json' \
-d '{"slug":"portco-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","subject_type":"guest"}}
#
# For a PERSONAL token - the only kind that can run a lane - open
# https://portco-desk.skillsafe.ai/tokens.html in a browser, sign in, and press
# "Copy shell export".
ss = PortcoDesk()
# The slug goes in the BODY of /guest. That is the only request that carries it,
# and there is no slug header anywhere in this API.
guest = ss.call("POST", "/guest", {"slug": "portco-desk"})
ss.token = guest["token"]
print(guest["subject_type"], guest["guest_id"])
# A guest can call /me and /estimate. Running a lane needs a PERSONAL token:
# get one from https://portco-desk.skillsafe.ai/tokens.html and pass it in
# directly instead: ss = PortcoDesk("YOUR_TOKEN")
const ss = new PortcoDesk();
// The slug goes in the BODY of /guest - the only request that carries it. There is
// no slug header in this API.
const guest = await ss.call("POST", "/guest", { slug: "portco-desk" });
ss.token = guest.token;
console.log(guest.subject_type, guest.guest_id);
// A guest can call /me and /estimate. Running a lane needs a PERSONAL token from
// https://portco-desk.skillsafe.ai/tokens.html:
// const ss = new PortcoDesk("YOUR_TOKEN");
c := &Client{}
// The slug goes in the BODY of /guest - the only request that carries it.
raw, err := c.Call("POST", "/guest", map[string]string{"slug": "portco-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
SubjectType string `json:"subject_type"`
}
if err := json.Unmarshal(raw, &guest); err != nil {
panic(err)
}
c.Token = guest.Token
fmt.Println(guest.SubjectType, guest.GuestID)
// Running a lane needs a personal token: c := &Client{Token: "YOUR_TOKEN"}
PortcoDesk ss = new PortcoDesk(null);
// The slug goes in the BODY of /guest - the only request that carries it.
String guest = ss.call("POST", "/guest", "{\"slug\":\"portco-desk\"}", null);
System.out.println(guest);
// Pull data.token out of that with your JSON library and construct
// new PortcoDesk(token). Running a lane needs a PERSONAL token from
// https://portco-desk.skillsafe.ai/tokens.html
ss = PortcoDesk.new
# The slug goes in the BODY of /guest - the only request that carries it.
guest = ss.call("POST", "/guest", { "slug" => "portco-desk" })
ss = PortcoDesk.new(guest["token"])
puts "#{guest["subject_type"]} #{guest["guest_id"]}"
# Running a lane needs a PERSONAL token from
# https://portco-desk.skillsafe.ai/tokens.html
<?php
$ss = new PortcoDesk();
// The slug goes in the BODY of /guest - the only request that carries it.
$guest = $ss->call("POST", "/guest", ["slug" => "portco-desk"]);
$ss = new PortcoDesk($guest["token"]);
echo $guest["subject_type"], " ", $guest["guest_id"], "\n";
// Running a lane needs a PERSONAL token from
// https://portco-desk.skillsafe.ai/tokens.html
var ss = new PortcoDesk();
// The slug goes in the BODY of /guest - the only request that carries it.
var guest = await ss.CallAsync(HttpMethod.Post, "/guest", new { slug = "portco-desk" });
ss = new PortcoDesk(guest.GetProperty("token").GetString());
Console.WriteLine(guest.GetProperty("subject_type").GetString());
// Running a lane needs a PERSONAL token from
// https://portco-desk.skillsafe.ai/tokens.html
3. Check the session and the balance
GET /me is free and tells you whether you are a user or a guest, and what the balance is.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","username":"you","credits":48210}}
#
# Compare credits against estimate.hold_credits BEFORE you run. A 402 after
# submitting is a failure of your client, not of the user.
me = ss.call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await ss.call("GET", "/me");
console.log(me.subject_type, me.credits);
raw, err = c.Call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(ss.call("GET", "/me", null, null));
me = ss.call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
$me = $ss->call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await ss.CallAsync(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the lane — free
POST /estimate creates no job and charges nothing. It returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits, min_credits and sponsor_enabled. Re-estimate on every lane change.
# /estimate is FREE. No job is created and nothing is charged.
# The body IS the input object. There is no "input" wrapper.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d @- <<'JSON'
{
"task": "monitor",
"pack": "Metric | Actual | Plan\nRevenue | 18400 | 19800\nEBITDA | 2760 | 2180",
"deal": "Entry equity: 42000\nEntry EBITDA: 6900\nEntry multiple: 11.5x\nHold years: 5\nExit multiple: 12.5x\nExit EBITDA: 15600",
"stage": "growth",
"emphasis": "general",
"context": "",
"carryover": "",
"prescan_facts": {
"stats": {
"has_plan": true
},
"variances": [],
"derived": [],
"returns": {},
"flags": []
}
}
JSON
# -> {"ok":true,"data":{
# "model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":4820,"min_credits":210,"sponsor_enabled":false}}
#
# hold_credits is a RESERVATION priced at the full output cap, not the price. The
# charge is usually far lower. Re-estimate on every lane change: the prompt sections
# and output caps differ per lane, so lane A's hold is not lane B's.
input = {
"task": "monitor", # monitor | unit | plan | returns
"pack": PACK, # the reporting pack, as text
"deal": DEAL, # deal terms as key: value lines
"stage": "growth", # buyout | growth | carve-out | platform | venture | unknown
"emphasis": "general", # general | revenue | margin | cash | exit
"context": "Q3 board pack. The CRO left in July and the role is unfilled.",
"carryover": "", # a previous lane's digest, or ""
"prescan_facts": { # your own arithmetic, handed over as ground truth
"stats": {"has_plan": True},
"variances": [],
"derived": [],
"returns": {},
"flags": [],
},
}
est = ss.call("POST", "/estimate", input) # free: no job, no charge
print(est["model_alias"], est["model"], est["hold_credits"], est["min_credits"])
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("balance below the minimum for this lane")
const input = {
task: "monitor", // monitor | unit | plan | returns
pack: PACK, // the reporting pack, as text
deal: DEAL, // deal terms as key: value lines
stage: "growth",
emphasis: "general",
context: "Q3 board pack. The CRO left in July and the role is unfilled.",
carryover: "",
prescan_facts: { stats: { has_plan: true }, variances: [], derived: [], returns: {}, flags: [] },
};
// The body IS the input object - there is no "input" wrapper.
const est = await ss.call("POST", "/estimate", input); // free: no job, no charge
console.log(est.model_alias, est.model, est.hold_credits, est.min_credits);
if ((me.credits ?? 0) < est.min_credits) throw new Error("balance below the minimum");
input := map[string]any{
"task": "monitor", // monitor | unit | plan | returns
"pack": pack,
"deal": deal,
"stage": "growth",
"emphasis": "general",
"context": "Q3 board pack. The CRO left in July and the role is unfilled.",
"carryover": "",
"prescan_facts": map[string]any{
"stats": map[string]any{"has_plan": true},
"variances": []any{}, "derived": []any{}, "returns": map[string]any{}, "flags": []any{},
},
}
// The body IS the input object - there is no "input" wrapper.
raw, err = c.Call("POST", "/estimate", input, nil)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
_ = json.Unmarshal(raw, &est)
fmt.Println(est.ModelAlias, est.Model, est.HoldCredits)
// The body IS the input object - there is no "input" wrapper.
// Build it with the JSON library you already use; a Java 15+ text block also works:
String body = """
{
"task": "monitor",
"pack": "Metric | Actual | Plan\nRevenue | 18400 | 19800\nEBITDA | 2760 | 2180",
"deal": "Entry equity: 42000\nEntry EBITDA: 6900\nEntry multiple: 11.5x\nHold years: 5\nExit multiple: 12.5x\nExit EBITDA: 15600",
"stage": "growth",
"emphasis": "general",
"context": "",
"carryover": "",
"prescan_facts": {
"stats": {
"has_plan": true
},
"variances": [],
"derived": [],
"returns": {},
"flags": []
}
}
""";
System.out.println(ss.call("POST", "/estimate", body, null));
// -> data.model_alias = "gpt-terra", data.model = "gpt-5.6-terra",
// data.markup_bps = 1000, plus hold_credits and min_credits.
input = {
"task" => "monitor", # monitor | unit | plan | returns
"pack" => PACK,
"deal" => DEAL,
"stage" => "growth",
"emphasis" => "general",
"context" => "Q3 board pack. The CRO left in July and the role is unfilled.",
"carryover" => "",
"prescan_facts" => { "stats" => { "has_plan" => true }, "variances" => [],
"derived" => [], "returns" => {}, "flags" => [] }
}
# The body IS the input object - there is no "input" wrapper.
est = ss.call("POST", "/estimate", input)
puts "#{est["model_alias"]} #{est["model"]} #{est["hold_credits"]}"
<?php
$input = [
"task" => "monitor", // monitor | unit | plan | returns
"pack" => $pack,
"deal" => $deal,
"stage" => "growth",
"emphasis" => "general",
"context" => "Q3 board pack. The CRO left in July and the role is unfilled.",
"carryover" => "",
"prescan_facts" => ["stats" => ["has_plan" => true], "variances" => [],
"derived" => [], "returns" => new stdClass(), "flags" => []],
];
// The body IS the input object - there is no "input" wrapper.
$est = $ss->call("POST", "/estimate", $input);
echo $est["model_alias"], " ", $est["model"], " ", $est["hold_credits"], "\n";
var input = new Dictionary<string, object> {
["task"] = "monitor", // monitor | unit | plan | returns
["pack"] = pack,
["deal"] = deal,
["stage"] = "growth",
["emphasis"] = "general",
["context"] = "Q3 board pack. The CRO left in July and the role is unfilled.",
["carryover"] = "",
["prescan_facts"] = new Dictionary<string, object> {
["stats"] = new { has_plan = true },
["variances"] = Array.Empty<object>(),
["derived"] = Array.Empty<object>(),
["returns"] = new { },
["flags"] = Array.Empty<object>(),
},
};
// The body IS the input object - there is no "input" wrapper.
var est = await ss.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("model_alias").GetString());
5. Run it and poll
POST /run returns a job_id; poll GET /jobs/{id} to a terminal status. This is metered.
# METERED. This charges the token's owner.
#
# Idempotency-Key is not optional in a real client: a network blip that retries
# without one bills twice. Derive it from a content hash of the input INCLUDING the
# lane plus an attempt counter - two lanes over the same pack are two distinct runs
# and must never collide on one key.
KEY="portco-desk:monitor:$(printf '%s' "$PACK$DEAL" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @body.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["job_id"])')
# Poll to a terminal state.
while :; do
J=$(curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
S=$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
sleep 1
done
printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["output"]["output"])'
# The analysis is the JSON text in data.output.output. Parse THAT for the envelope.
import hashlib
# Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
# counter. A reformat retry must reuse the key derived from the same input, or a
# malformed first reply bills twice.
digest = hashlib.sha256(
json.dumps([input["task"], input["pack"], input["deal"], input["stage"],
input["emphasis"], input["context"], input["carryover"]],
sort_keys=True).encode()).hexdigest()[:16]
key = "portco-desk:%s:%s:a1" % (input["task"], digest)
job = ss.call("POST", "/run", input, {"Idempotency-Key": key}) # METERED
job_id = job["job_id"]
while True:
job = ss.call("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1)
if job["status"] == "failed":
raise SystemExit(job.get("error") or "run failed")
analysis = json.loads(job["output"]["output"]) # the envelope
print(analysis["lane"], analysis["verdict"], len(analysis["findings"]), "findings")
print("charged", job.get("charged_credits"))
// Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
// counter. A reformat retry reuses the key derived from the same input.
const enc = new TextEncoder().encode(JSON.stringify([
input.task, input.pack, input.deal, input.stage, input.emphasis, input.context, input.carryover,
]));
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
.map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
const key = `portco-desk:${input.task}:${digest}:a1`;
let job = await ss.call("POST", "/run", input, { "Idempotency-Key": key }); // METERED
const jobId = job.job_id;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 1000));
job = await ss.call("GET", `/jobs/${jobId}`);
}
if (job.status === "failed") throw new Error(job.error || "run failed");
const analysis = JSON.parse(job.output.output); // the envelope
console.log(analysis.lane, analysis.verdict, analysis.findings.length, "findings");
console.log("charged", job.charged_credits);
// Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
// counter. Needs "crypto/sha256", "encoding/hex" and "time".
seed, _ := json.Marshal([]any{input["task"], input["pack"], input["deal"],
input["stage"], input["emphasis"], input["context"], input["carryover"]})
sum := sha256.Sum256(seed)
key := fmt.Sprintf("portco-desk:%v:%s:a1", input["task"], hex.EncodeToString(sum[:])[:16])
raw, err = c.Call("POST", "/run", input, map[string]string{"Idempotency-Key": key}) // METERED
if err != nil {
panic(err)
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, err = c.Call("GET", "/jobs/"+started.JobID, nil, nil)
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(time.Second)
}
// job.Output.Output is the analysis JSON text - unmarshal THAT for the envelope.
fmt.Println(job.Status, job.ChargedCredits, len(job.Output.Output))
// Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
// counter, so a retry cannot double-bill.
String seed = body; // the exact request body you are about to send
String digest = java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256")
.digest(seed.getBytes(java.nio.charset.StandardCharsets.UTF_8)))
.substring(0, 16);
String key = "portco-desk:monitor:" + digest + ":a1";
String started = ss.call("POST", "/run", body, java.util.Map.of("Idempotency-Key", key));
// Pull data.job_id out of that, then poll:
// ss.call("GET", "/jobs/" + jobId, null, null)
// until data.status is "succeeded" or "failed", sleeping a second between polls.
// data.output.output is the analysis JSON text - parse THAT for the envelope.
System.out.println(started);
require "digest"
# Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
# counter, so a retry cannot double-bill.
seed = JSON.generate([input["task"], input["pack"], input["deal"], input["stage"],
input["emphasis"], input["context"], input["carryover"]])
key = "portco-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a1"
job = ss.call("POST", "/run", input, { "Idempotency-Key" => key }) # METERED
job_id = job["job_id"]
loop do
job = ss.call("GET", "/jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 1
end
raise job["error"].to_s if job["status"] == "failed"
analysis = JSON.parse(job["output"]["output"]) # the envelope
puts "#{analysis["lane"]} #{analysis["verdict"]} #{analysis["findings"].length} findings"
<?php
// Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
// counter, so a retry cannot double-bill.
$seed = json_encode([$input["task"], $input["pack"], $input["deal"], $input["stage"],
$input["emphasis"], $input["context"], $input["carryover"]]);
$key = "portco-desk:" . $input["task"] . ":" . substr(hash("sha256", $seed), 0, 16) . ":a1";
$job = $ss->call("POST", "/run", $input, ["Idempotency-Key" => $key]); // METERED
$jobId = $job["job_id"];
do {
sleep(1);
$job = $ss->call("GET", "/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") {
throw new RuntimeException($job["error"] ?? "run failed");
}
$analysis = json_decode($job["output"]["output"], true); // the envelope
echo $analysis["lane"], " ", $analysis["verdict"], " ", count($analysis["findings"]), "\n";
using System.Security.Cryptography;
// Idempotency key: a content hash of the input INCLUDING the lane, plus an attempt
// counter, so a retry cannot double-bill.
var seed = JsonSerializer.Serialize(new[] {
input["task"], input["pack"], input["deal"], input["stage"],
input["emphasis"], input["context"], input["carryover"],
});
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(seed)))
.ToLowerInvariant()[..16];
var key = $"portco-desk:{input["task"]}:{digest}:a1";
var started = await ss.CallAsync(HttpMethod.Post, "/run", input, ("Idempotency-Key", key));
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await ss.CallAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1000);
}
// job.output.output is the analysis JSON text - parse THAT for the envelope.
var analysisText = job.GetProperty("output").GetProperty("output").GetString();
using var analysis = JsonDocument.Parse(analysisText);
Console.WriteLine(analysis.RootElement.GetProperty("verdict").GetString());
6. Or stream it
POST /run-stream is the same body with an SSE response — job, delta, done and error events. Keep partial output on a mid-flight failure rather than discarding it, and handle done.truncated as “response cut short” rather than presenting a clipped answer as complete.
# Same body, same Idempotency-Key discipline, but the response is text/event-stream.
# Events are: job, delta, done, error.
curl -sN -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $KEY" \
-d @body.json
# event: job
# data: {"job_id":"job_..."}
#
# event: delta
# data: {"text":"{\"lane\":\"monitor\","}
#
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":3120,
# "output":{"output":"{...the whole envelope...}"},"truncated":false}
#
# NOTE: on an idempotent replay the server may answer with plain JSON instead of a
# stream. Check the response Content-Type before assuming SSE.
# Streaming: same body, same key. Accumulate the deltas; the done event carries the
# authoritative full output.
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(input).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + ss.token)
req.add_header("Idempotency-Key", key)
acc, done = "", None
with urllib.request.urlopen(req) as res:
# An idempotent replay may answer with plain JSON rather than a stream.
if "text/event-stream" not in res.headers.get("Content-Type", ""):
done = json.loads(res.read().decode())["data"]
else:
event, buf = "message", ""
for line in res:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
buf += line[5:].strip()
elif line == "":
if buf:
payload = json.loads(buf)
if event == "delta":
acc += payload.get("text", "")
elif event in ("done", "pending"):
done = payload
elif event == "error":
raise SystemExit(payload.get("message", "stream failed"))
event, buf = "message", ""
full = (done or {}).get("output", {}).get("output") or acc
analysis = json.loads(full)
if (done or {}).get("truncated"):
print("TRUNCATED - the balance capped the output; render what parsed and say so")
// Streaming: same body, same key. If the stream dies mid-flight, render whatever
// parsed rather than discarding it - that is what the app itself does.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${ss.token}`,
"Idempotency-Key": key,
},
body: JSON.stringify(input),
});
// An idempotent replay may answer with plain JSON rather than a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const j = await res.json();
console.log(JSON.parse(j.data.output.output).verdict);
} else {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "", acc = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += dec.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let ev = "message", dataStr = "";
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
const payload = JSON.parse(dataStr);
if (ev === "delta") acc += payload.text || "";
else if (ev === "done" || ev === "pending") done = payload;
else if (ev === "error") throw new Error(payload.message || "stream failed");
}
}
const analysis = JSON.parse(done?.output?.output || acc);
if (done?.truncated) console.warn("TRUNCATED - the balance capped the output");
console.log(analysis.lane, analysis.verdict);
}
// Streaming: same body, same key. Read the SSE frames with bufio.Scanner.
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
// An idempotent replay may answer with plain JSON rather than a stream.
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
io.Copy(os.Stdout, res.Body)
return
}
var acc strings.Builder
event, data := "message", ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
if data != "" {
var p struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(data), &p)
if event == "delta" {
acc.WriteString(p.Text)
}
}
event, data = "message", ""
}
}
fmt.Println(acc.Len(), "characters of analysis JSON")
// Streaming: same body, same key. HttpClient can hand you the body as a line stream.
HttpRequest req = HttpRequest.newBuilder(URI.create(PortcoDesk.BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder acc = new StringBuilder();
String[] event = { "message" };
StringBuilder data = new StringBuilder();
res.body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:")) {
data.append(line.substring(5).trim());
} else if (line.isEmpty() && data.length() > 0) {
// Parse data with your JSON library; on "delta" append payload.text to acc.
data.setLength(0);
event[0] = "message";
}
});
System.out.println(acc.length());
# Streaming: same body, same key. Net::HTTP will yield the body in chunks.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = key
req.body = JSON.generate(input)
acc = ""
buffer = ""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
frame = buffer.slice!(0, idx + 2)
event = "message"
data = ""
frame.each_line do |line|
line = line.chomp
event = line[6..].strip if line.start_with?("event:")
data << line[5..].strip if line.start_with?("data:")
end
next if data.empty?
payload = JSON.parse(data)
acc << payload["text"].to_s if event == "delta"
end
end
end
end
puts "#{acc.length} characters of analysis JSON"
<?php
// Streaming: same body, same key. A write callback receives the SSE frames.
$acc = "";
$buffer = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $token,
"Idempotency-Key: " . $key,
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$acc, &$buffer) {
$buffer .= $chunk;
while (($idx = strpos($buffer, "\n\n")) !== false) {
$frame = substr($buffer, 0, $idx);
$buffer = substr($buffer, $idx + 2);
$event = "message";
$data = "";
foreach (explode("\n", $frame) as $line) {
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data .= trim(substr($line, 5));
}
}
if ($data === "") {
continue;
}
$payload = json_decode($data, true) ?: [];
if ($event === "delta") {
$acc .= $payload["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
echo strlen($acc), " characters of analysis JSON\n";
// Streaming: same body, same key. Read the response stream line by line.
using var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.TryAddWithoutValidation("Idempotency-Key", key);
using var http = new HttpClient();
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
await using var stream = await res.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
var acc = new StringBuilder();
var evt = "message";
var data = new StringBuilder();
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
else if (line.Length == 0 && data.Length > 0)
{
using var payload = JsonDocument.Parse(data.ToString());
if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
acc.Append(t.GetString());
data.Clear();
evt = "message";
}
}
Console.WriteLine($"{acc.Length} characters of analysis JSON");
7. Worked example: the Monitor lane
Where is the company against the plan it was underwritten on, and what is driving each gap? Derived from @anthropics/portfolio-monitoring. Its extra block is drivers.
# The ONLY thing that changes between lanes is the task field. Same pack, same
# deal terms, same everything else.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,sys; b=json.load(open("body.json")); b["task"]="monitor"; print(json.dumps(b))')"
# Then POST the same body to /run or /run-stream with an Idempotency-Key that
# includes the lane. The reply carries this block:
#
# "drivers": [
# {"metric": "New ARR",
# "variance_read": "-1,550 against plan, -33.7%",
# "classification": "trend", // timing | trend | one-off | definition | unknown
# "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
# "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
# "verify": "pull the July-September new-logo pipeline conversion by stage"}
# ]
input["task"] = "monitor" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, and re-priced per lane
print(est["hold_credits"])
# The reply from /run carries this block:
#
# "drivers": [
# {"metric": "New ARR",
# "variance_read": "-1,550 against plan, -33.7%",
# "classification": "trend", // timing | trend | one-off | definition | unknown
# "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
# "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
# "verify": "pull the July-September new-logo pipeline conversion by stage"}
# ]
input.task = "monitor"; // the only field that changes
const est = await ss.call("POST", "/estimate", input); // free, re-priced per lane
console.log(est.hold_credits);
// The reply from /run carries this block:
//
// "drivers": [
// {"metric": "New ARR",
// "variance_read": "-1,550 against plan, -33.7%",
// "classification": "trend", // timing | trend | one-off | definition | unknown
// "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
// "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
// "verify": "pull the July-September new-logo pipeline conversion by stage"}
// ]
input["task"] = "monitor" // the only field that changes
raw, err = c.Call("POST", "/estimate", input, nil) // free, re-priced per lane
// The reply from /run carries this block:
//
// "drivers": [
// {"metric": "New ARR",
// "variance_read": "-1,550 against plan, -33.7%",
// "classification": "trend", // timing | trend | one-off | definition | unknown
// "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
// "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
// "verify": "pull the July-September new-logo pipeline conversion by stage"}
// ]
// Change one field in the body you already built:
String laneBody = body.replace("\"task\": \"monitor\"", "\"task\": \"monitor\"");
System.out.println(ss.call("POST", "/estimate", laneBody, null));
// The reply from /run carries this block:
//
// "drivers": [
// {"metric": "New ARR",
// "variance_read": "-1,550 against plan, -33.7%",
// "classification": "trend", // timing | trend | one-off | definition | unknown
// "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
// "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
// "verify": "pull the July-September new-logo pipeline conversion by stage"}
// ]
input["task"] = "monitor" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, re-priced per lane
puts est["hold_credits"]
# The reply from /run carries this block:
#
# "drivers": [
# {"metric": "New ARR",
# "variance_read": "-1,550 against plan, -33.7%",
# "classification": "trend", // timing | trend | one-off | definition | unknown
# "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
# "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
# "verify": "pull the July-September new-logo pipeline conversion by stage"}
# ]
<?php
$input["task"] = "monitor"; // the only field that changes
$est = $ss->call("POST", "/estimate", $input); // free, re-priced per lane
echo $est["hold_credits"], "\n";
// The reply from /run carries this block:
//
// "drivers": [
// {"metric": "New ARR",
// "variance_read": "-1,550 against plan, -33.7%",
// "classification": "trend", // timing | trend | one-off | definition | unknown
// "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
// "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
// "verify": "pull the July-September new-logo pipeline conversion by stage"}
// ]
input["task"] = "monitor"; // the only field that changes
var est = await ss.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
// The reply from /run carries this block:
//
// "drivers": [
// {"metric": "New ARR",
// "variance_read": "-1,550 against plan, -33.7%",
// "classification": "trend", // timing | trend | one-off | definition | unknown
// "likely_driver": "acquisition, not renewals: New customers came in 21 under plan",
// "evidence": "New ARR 3,050 vs plan 4,600; New customers 41 vs plan 62",
// "verify": "pull the July-September new-logo pipeline conversion by stage"}
// ]
8. Worked example: the Unit economics lane
Do the unit economics support the plan, and which ratio is load-bearing? Derived from @anthropics/unit-economics. Its extra block is metrics.
# The ONLY thing that changes between lanes is the task field. Same pack, same
# deal terms, same everything else.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,sys; b=json.load(open("body.json")); b["task"]="unit"; print(json.dumps(b))')"
# Then POST the same body to /run or /run-stream with an Idempotency-Key that
# includes the lane. The reply carries this block:
#
# "metrics": [
# {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
# "name": "CAC payback",
# "value": "26.34 months", // or "not derivable"
# "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
# "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
# "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
# "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
# ]
input["task"] = "unit" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, and re-priced per lane
print(est["hold_credits"])
# The reply from /run carries this block:
#
# "metrics": [
# {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
# "name": "CAC payback",
# "value": "26.34 months", // or "not derivable"
# "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
# "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
# "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
# "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
# ]
input.task = "unit"; // the only field that changes
const est = await ss.call("POST", "/estimate", input); // free, re-priced per lane
console.log(est.hold_credits);
// The reply from /run carries this block:
//
// "metrics": [
// {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
// "name": "CAC payback",
// "value": "26.34 months", // or "not derivable"
// "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
// "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
// "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
// "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
// ]
input["task"] = "unit" // the only field that changes
raw, err = c.Call("POST", "/estimate", input, nil) // free, re-priced per lane
// The reply from /run carries this block:
//
// "metrics": [
// {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
// "name": "CAC payback",
// "value": "26.34 months", // or "not derivable"
// "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
// "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
// "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
// "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
// ]
// Change one field in the body you already built:
String laneBody = body.replace("\"task\": \"monitor\"", "\"task\": \"unit\"");
System.out.println(ss.call("POST", "/estimate", laneBody, null));
// The reply from /run carries this block:
//
// "metrics": [
// {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
// "name": "CAC payback",
// "value": "26.34 months", // or "not derivable"
// "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
// "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
// "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
// "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
// ]
input["task"] = "unit" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, re-priced per lane
puts est["hold_credits"]
# The reply from /run carries this block:
#
# "metrics": [
# {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
# "name": "CAC payback",
# "value": "26.34 months", // or "not derivable"
# "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
# "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
# "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
# "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
# ]
<?php
$input["task"] = "unit"; // the only field that changes
$est = $ss->call("POST", "/estimate", $input); // free, re-priced per lane
echo $est["hold_credits"], "\n";
// The reply from /run carries this block:
//
// "metrics": [
// {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
// "name": "CAC payback",
// "value": "26.34 months", // or "not derivable"
// "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
// "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
// "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
// "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
// ]
input["task"] = "unit"; // the only field that changes
var est = await ss.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
// The reply from /run carries this block:
//
// "metrics": [
// {"id": "cac_payback_months", // one entry per prescan_facts.derived id, same order
// "name": "CAC payback",
// "value": "26.34 months", // or "not derivable"
// "derived_from": "S&M spend 5,980; New customers 41; ARR 73,920; Customers 812; Gross margin 73.0%",
// "verdict": "watch", // strong | adequate | watch | poor | measured | not-derivable
// "threshold": "at or below 18 months is adequate for a growth holding; 2 of the 5 hold years remain",
// "note": "Payback runs past the remaining hold, so growth spend transfers value to the next owner."}
// ]
9. Worked example: the Value creation lane
What closes the gap, and what happens in the first hundred days? Derived from @anthropics/value-creation-plan. Its extra block is levers.
# The ONLY thing that changes between lanes is the task field. Same pack, same
# deal terms, same everything else.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,sys; b=json.load(open("body.json")); b["task"]="plan"; print(json.dumps(b))')"
# Then POST the same body to /run or /run-stream with an Idempotency-Key that
# includes the lane. The reply carries this block:
#
# "levers": [
# {"id": "VL-1",
# "name": "Stand up a renewals desk against the churn line",
# "family": "revenue", // revenue | cost | capital | operational
# "horizon": "100-day", // 100-day | year-1 | year-2
# "ebitda_impact": "+620 to +900 (USD thousands)",
# "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
# "confidence": "medium", // high | medium | low
# "owner_role": "VP Customer Success", // a role, never a person
# "kpi": "Churned ARR", // a metric the pack already reports
# "first_actions": ["Name an interim owner while the CRO seat is open",
# "Build the 90-day renewal calendar from the ARR movement rows"],
# "risk": "The 6% renewal uplift is itself driving some of the churn",
# "counter_indicator": "Churned ARR does not fall below plan by the next pack",
# "depends_on": ""}
# ]
input["task"] = "plan" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, and re-priced per lane
print(est["hold_credits"])
# The reply from /run carries this block:
#
# "levers": [
# {"id": "VL-1",
# "name": "Stand up a renewals desk against the churn line",
# "family": "revenue", // revenue | cost | capital | operational
# "horizon": "100-day", // 100-day | year-1 | year-2
# "ebitda_impact": "+620 to +900 (USD thousands)",
# "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
# "confidence": "medium", // high | medium | low
# "owner_role": "VP Customer Success", // a role, never a person
# "kpi": "Churned ARR", // a metric the pack already reports
# "first_actions": ["Name an interim owner while the CRO seat is open",
# "Build the 90-day renewal calendar from the ARR movement rows"],
# "risk": "The 6% renewal uplift is itself driving some of the churn",
# "counter_indicator": "Churned ARR does not fall below plan by the next pack",
# "depends_on": ""}
# ]
input.task = "plan"; // the only field that changes
const est = await ss.call("POST", "/estimate", input); // free, re-priced per lane
console.log(est.hold_credits);
// The reply from /run carries this block:
//
// "levers": [
// {"id": "VL-1",
// "name": "Stand up a renewals desk against the churn line",
// "family": "revenue", // revenue | cost | capital | operational
// "horizon": "100-day", // 100-day | year-1 | year-2
// "ebitda_impact": "+620 to +900 (USD thousands)",
// "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
// "confidence": "medium", // high | medium | low
// "owner_role": "VP Customer Success", // a role, never a person
// "kpi": "Churned ARR", // a metric the pack already reports
// "first_actions": ["Name an interim owner while the CRO seat is open",
// "Build the 90-day renewal calendar from the ARR movement rows"],
// "risk": "The 6% renewal uplift is itself driving some of the churn",
// "counter_indicator": "Churned ARR does not fall below plan by the next pack",
// "depends_on": ""}
// ]
input["task"] = "plan" // the only field that changes
raw, err = c.Call("POST", "/estimate", input, nil) // free, re-priced per lane
// The reply from /run carries this block:
//
// "levers": [
// {"id": "VL-1",
// "name": "Stand up a renewals desk against the churn line",
// "family": "revenue", // revenue | cost | capital | operational
// "horizon": "100-day", // 100-day | year-1 | year-2
// "ebitda_impact": "+620 to +900 (USD thousands)",
// "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
// "confidence": "medium", // high | medium | low
// "owner_role": "VP Customer Success", // a role, never a person
// "kpi": "Churned ARR", // a metric the pack already reports
// "first_actions": ["Name an interim owner while the CRO seat is open",
// "Build the 90-day renewal calendar from the ARR movement rows"],
// "risk": "The 6% renewal uplift is itself driving some of the churn",
// "counter_indicator": "Churned ARR does not fall below plan by the next pack",
// "depends_on": ""}
// ]
// Change one field in the body you already built:
String laneBody = body.replace("\"task\": \"monitor\"", "\"task\": \"plan\"");
System.out.println(ss.call("POST", "/estimate", laneBody, null));
// The reply from /run carries this block:
//
// "levers": [
// {"id": "VL-1",
// "name": "Stand up a renewals desk against the churn line",
// "family": "revenue", // revenue | cost | capital | operational
// "horizon": "100-day", // 100-day | year-1 | year-2
// "ebitda_impact": "+620 to +900 (USD thousands)",
// "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
// "confidence": "medium", // high | medium | low
// "owner_role": "VP Customer Success", // a role, never a person
// "kpi": "Churned ARR", // a metric the pack already reports
// "first_actions": ["Name an interim owner while the CRO seat is open",
// "Build the 90-day renewal calendar from the ARR movement rows"],
// "risk": "The 6% renewal uplift is itself driving some of the churn",
// "counter_indicator": "Churned ARR does not fall below plan by the next pack",
// "depends_on": ""}
// ]
input["task"] = "plan" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, re-priced per lane
puts est["hold_credits"]
# The reply from /run carries this block:
#
# "levers": [
# {"id": "VL-1",
# "name": "Stand up a renewals desk against the churn line",
# "family": "revenue", // revenue | cost | capital | operational
# "horizon": "100-day", // 100-day | year-1 | year-2
# "ebitda_impact": "+620 to +900 (USD thousands)",
# "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
# "confidence": "medium", // high | medium | low
# "owner_role": "VP Customer Success", // a role, never a person
# "kpi": "Churned ARR", // a metric the pack already reports
# "first_actions": ["Name an interim owner while the CRO seat is open",
# "Build the 90-day renewal calendar from the ARR movement rows"],
# "risk": "The 6% renewal uplift is itself driving some of the churn",
# "counter_indicator": "Churned ARR does not fall below plan by the next pack",
# "depends_on": ""}
# ]
<?php
$input["task"] = "plan"; // the only field that changes
$est = $ss->call("POST", "/estimate", $input); // free, re-priced per lane
echo $est["hold_credits"], "\n";
// The reply from /run carries this block:
//
// "levers": [
// {"id": "VL-1",
// "name": "Stand up a renewals desk against the churn line",
// "family": "revenue", // revenue | cost | capital | operational
// "horizon": "100-day", // 100-day | year-1 | year-2
// "ebitda_impact": "+620 to +900 (USD thousands)",
// "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
// "confidence": "medium", // high | medium | low
// "owner_role": "VP Customer Success", // a role, never a person
// "kpi": "Churned ARR", // a metric the pack already reports
// "first_actions": ["Name an interim owner while the CRO seat is open",
// "Build the 90-day renewal calendar from the ARR movement rows"],
// "risk": "The 6% renewal uplift is itself driving some of the churn",
// "counter_indicator": "Churned ARR does not fall below plan by the next pack",
// "depends_on": ""}
// ]
input["task"] = "plan"; // the only field that changes
var est = await ss.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
// The reply from /run carries this block:
//
// "levers": [
// {"id": "VL-1",
// "name": "Stand up a renewals desk against the churn line",
// "family": "revenue", // revenue | cost | capital | operational
// "horizon": "100-day", // 100-day | year-1 | year-2
// "ebitda_impact": "+620 to +900 (USD thousands)",
// "impact_basis": "Churned ARR ran 860 above plan at the reported 73.0% gross margin",
// "confidence": "medium", // high | medium | low
// "owner_role": "VP Customer Success", // a role, never a person
// "kpi": "Churned ARR", // a metric the pack already reports
// "first_actions": ["Name an interim owner while the CRO seat is open",
// "Build the 90-day renewal calendar from the ARR movement rows"],
// "risk": "The 6% renewal uplift is itself driving some of the churn",
// "counter_indicator": "Churned ARR does not fall below plan by the next pack",
// "depends_on": ""}
// ]
10. Worked example: the Exit case lane
On this trajectory, what does the exit look like, and does it clear the hurdle? Derived from @anthropics/returns-analysis. Its extra block is cases.
# The ONLY thing that changes between lanes is the task field. Same pack, same
# deal terms, same everything else.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,sys; b=json.load(open("body.json")); b["task"]="returns"; print(json.dumps(b))')"
# Then POST the same body to /run or /run-stream with an Idempotency-Key that
# includes the lane. The reply carries this block:
#
# "cases": [
# {"name": "base", // exactly three: downside, base, upside
# "exit_year": "FY28",
# "hold_years": "5",
# "exit_ebitda": "15,600",
# "exit_multiple": "12.5x",
# "net_debt_at_exit": "26,000",
# "equity_value": "169,000",
# "moic": "4.024x", // reproduces prescan_facts.returns exactly
# "irr_pct": "32.11%",
# "clears_hurdle": "yes", // yes | no | hurdle not stated
# "changed_assumptions": [], // empty for the base case
# "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
# "debt_paydown": "14.3%", "residual": "-5.4%"},
# "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
# ]
input["task"] = "returns" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, and re-priced per lane
print(est["hold_credits"])
# The reply from /run carries this block:
#
# "cases": [
# {"name": "base", // exactly three: downside, base, upside
# "exit_year": "FY28",
# "hold_years": "5",
# "exit_ebitda": "15,600",
# "exit_multiple": "12.5x",
# "net_debt_at_exit": "26,000",
# "equity_value": "169,000",
# "moic": "4.024x", // reproduces prescan_facts.returns exactly
# "irr_pct": "32.11%",
# "clears_hurdle": "yes", // yes | no | hurdle not stated
# "changed_assumptions": [], // empty for the base case
# "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
# "debt_paydown": "14.3%", "residual": "-5.4%"},
# "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
# ]
input.task = "returns"; // the only field that changes
const est = await ss.call("POST", "/estimate", input); // free, re-priced per lane
console.log(est.hold_credits);
// The reply from /run carries this block:
//
// "cases": [
// {"name": "base", // exactly three: downside, base, upside
// "exit_year": "FY28",
// "hold_years": "5",
// "exit_ebitda": "15,600",
// "exit_multiple": "12.5x",
// "net_debt_at_exit": "26,000",
// "equity_value": "169,000",
// "moic": "4.024x", // reproduces prescan_facts.returns exactly
// "irr_pct": "32.11%",
// "clears_hurdle": "yes", // yes | no | hurdle not stated
// "changed_assumptions": [], // empty for the base case
// "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
// "debt_paydown": "14.3%", "residual": "-5.4%"},
// "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
// ]
input["task"] = "returns" // the only field that changes
raw, err = c.Call("POST", "/estimate", input, nil) // free, re-priced per lane
// The reply from /run carries this block:
//
// "cases": [
// {"name": "base", // exactly three: downside, base, upside
// "exit_year": "FY28",
// "hold_years": "5",
// "exit_ebitda": "15,600",
// "exit_multiple": "12.5x",
// "net_debt_at_exit": "26,000",
// "equity_value": "169,000",
// "moic": "4.024x", // reproduces prescan_facts.returns exactly
// "irr_pct": "32.11%",
// "clears_hurdle": "yes", // yes | no | hurdle not stated
// "changed_assumptions": [], // empty for the base case
// "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
// "debt_paydown": "14.3%", "residual": "-5.4%"},
// "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
// ]
// Change one field in the body you already built:
String laneBody = body.replace("\"task\": \"monitor\"", "\"task\": \"returns\"");
System.out.println(ss.call("POST", "/estimate", laneBody, null));
// The reply from /run carries this block:
//
// "cases": [
// {"name": "base", // exactly three: downside, base, upside
// "exit_year": "FY28",
// "hold_years": "5",
// "exit_ebitda": "15,600",
// "exit_multiple": "12.5x",
// "net_debt_at_exit": "26,000",
// "equity_value": "169,000",
// "moic": "4.024x", // reproduces prescan_facts.returns exactly
// "irr_pct": "32.11%",
// "clears_hurdle": "yes", // yes | no | hurdle not stated
// "changed_assumptions": [], // empty for the base case
// "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
// "debt_paydown": "14.3%", "residual": "-5.4%"},
// "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
// ]
input["task"] = "returns" # the only field that changes
est = ss.call("POST", "/estimate", input) # free, re-priced per lane
puts est["hold_credits"]
# The reply from /run carries this block:
#
# "cases": [
# {"name": "base", // exactly three: downside, base, upside
# "exit_year": "FY28",
# "hold_years": "5",
# "exit_ebitda": "15,600",
# "exit_multiple": "12.5x",
# "net_debt_at_exit": "26,000",
# "equity_value": "169,000",
# "moic": "4.024x", // reproduces prescan_facts.returns exactly
# "irr_pct": "32.11%",
# "clears_hurdle": "yes", // yes | no | hurdle not stated
# "changed_assumptions": [], // empty for the base case
# "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
# "debt_paydown": "14.3%", "residual": "-5.4%"},
# "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
# ]
<?php
$input["task"] = "returns"; // the only field that changes
$est = $ss->call("POST", "/estimate", $input); // free, re-priced per lane
echo $est["hold_credits"], "\n";
// The reply from /run carries this block:
//
// "cases": [
// {"name": "base", // exactly three: downside, base, upside
// "exit_year": "FY28",
// "hold_years": "5",
// "exit_ebitda": "15,600",
// "exit_multiple": "12.5x",
// "net_debt_at_exit": "26,000",
// "equity_value": "169,000",
// "moic": "4.024x", // reproduces prescan_facts.returns exactly
// "irr_pct": "32.11%",
// "clears_hurdle": "yes", // yes | no | hurdle not stated
// "changed_assumptions": [], // empty for the base case
// "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
// "debt_paydown": "14.3%", "residual": "-5.4%"},
// "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
// ]
input["task"] = "returns"; // the only field that changes
var est = await ss.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
// The reply from /run carries this block:
//
// "cases": [
// {"name": "base", // exactly three: downside, base, upside
// "exit_year": "FY28",
// "hold_years": "5",
// "exit_ebitda": "15,600",
// "exit_multiple": "12.5x",
// "net_debt_at_exit": "26,000",
// "equity_value": "169,000",
// "moic": "4.024x", // reproduces prescan_facts.returns exactly
// "irr_pct": "32.11%",
// "clears_hurdle": "yes", // yes | no | hurdle not stated
// "changed_assumptions": [], // empty for the base case
// "bridge": {"ebitda_growth": "78.8%", "multiple_change": "12.3%",
// "debt_paydown": "14.3%", "residual": "-5.4%"},
// "must_be_true": ["EBITDA reaches 15,600 from an LTM run rate the pack puts at 11,040"]}
// ]
11. Conventions worth copying
- Idempotency-Key on every run, including retries. Hash
(task, pack, deal, stage, emphasis, context, carryover)and append an attempt counter. Two lanes over the same pack are two runs and must not share a key; a reformat retry of the same input must reuse the key it was derived from, or a malformed first reply bills twice. - Preflight the balance. Compare
/meagainstestimate.hold_creditsbefore you submit.hold_creditsis a reservation priced at the full output cap, not the price; the charge is usually far lower. - Handle truncation. If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced cap and returnstruncated: true. Say “cut short”; do not present it as complete. - Send your prescan, then hold the model to it. Every
prescan_facts.flags[].idyou send must come back exactly once incoverage_check, eitherconfirmedorset-aside. Check that it did, and surface any id the model dropped or invented — the page does. - Check the returns lane against your own arithmetic. The
basecase must reproduce the MOIC and IRR you sent inprescan_facts.returns. A base case that drifts is the clearest signal the ground truth was ignored. - Clip on row boundaries. If a pack is too long, cut the middle and keep the header and the closing rows: cash and balance-sheet lines live at the bottom, so a head slice throws away exactly what the cash checks need. Say in the text what was cut.