Forecast a period from your own pipeline data
Send the measured facts — every parsed deal with its amount, mapped probability and
weighted value, the commit and upside bands, the excluded deals with the reason each was
removed, the risk flags and the solved gap — and get back one JSON object:
deal_calls (exactly one commit / upside /
omit per forecastable deal, each with a rationale),
risk_actions (exactly one per flagged deal, with the ask),
gap_plan, pipeline_generation, questions and
unverified. The interesting part is that all of it is mechanically
checkable, and the checker ships with the app: /pipekit.js is plain ES5 with
no dependencies and no network calls, so your pipeline can compute the same facts and run
the same reconciliation — both partitions counted, every dollar figure traced back
to a measured total, the worst case implied by the model's own commit set computed beside
the engine's — before a number ever reaches a forecast call. Every code step below
is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and
the whole page follows.
Basics
Base URL https://api.skillsafe.ai/v1/app-api, app slug
forecast-desk. Every request sends
Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}}
on failure. Forecast calls are written by the gpt-terra model alias
(currently gpt-5.6-terra) at a publisher markup of 1000 bps
— 10%. Credits are units of 1/10 000 of a US dollar, so 10 000 credits is
$1.00. /estimate, /me and /guest are free;
/run and /run-stream are metered. Run input caps at 1 MB
of JSON.
Error codes
| code | status | what it means |
|---|---|---|
unauthorized | 401 | Missing or stale token. Mint a guest token or sign in again. |
forbidden | 403 | The token belongs to a different app. |
payment_required | 402 | Balance below min_credits. Call /estimate first and compare against /me. |
validation_error | 400 | Malformed body. error.details names the field. A where value that is not an operator object lands here. |
rate_limited | 429 | Back off. /similar is 30 req/min per IP, tighter than the other data endpoints. |
not_found | 404 | Unknown job or record id. |
internal | 5xx | Retry with the SAME Idempotency-Key - it returns the original job instead of billing again. |
/pipekit.js.
Load it in Node with a global.window = {} stub and
PipeKit.analyze({pipeline, quota, closed, period_start, period_end, as_of,
stage_map, commit_threshold, stale_days}) gives you the same facts
this API expects, from PipeKit.factsForModel(analysis).
Step 1 · Get a token
Two ways in. /tokens.html shows the token this browser already holds and copies a shell export for it — you never need the DevTools console. Or mint a guest token from anywhere: a guest can call /me and the free /estimate, which is enough to verify the model binding, but a forecast run needs a personal token so it bills your own wallet.
# Option A - take the token this browser already holds: open /tokens.html,
# press "Copy shell export", and paste the line it prints.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"
# Option B - mint a guest token with no browser at all. A guest can call /me and
# the free /estimate, which is enough to verify the model binding; a forecast run
# needs a personal token so it bills your own wallet.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H 'Content-Type: application/json' \
-d '{"slug":"forecast-desk"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "forecast-desk"
TOKEN = "YOUR_TOKEN" # from /tokens.html, or the guest() call below
def call(path, body=None, token=None, method=None):
"""The whole client. Every later step is one line on top of this."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read().decode())
if "error" in payload:
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]
def guest():
return call("/guest", {"slug": SLUG})["token"]
if TOKEN == "YOUR_TOKEN":
TOKEN = guest()
print(TOKEN[:8] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "forecast-desk";
let TOKEN = "YOUR_TOKEN"; // from /tokens.html, or the guest() call below
async function call(path, body, opts = {}) {
const res = await fetch(BASE + path, {
method: opts.method || (body ? "POST" : "GET"),
headers: {
"Content-Type": "application/json",
...(opts.token ? { Authorization: "Bearer " + opts.token } : {}),
...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (payload.error) throw new Error(payload.error.code + ": " + payload.error.message);
return payload.data;
}
const guest = () => call("/guest", { slug: SLUG }).then((d) => d.token);
if (TOKEN === "YOUR_TOKEN") TOKEN = await guest();
console.log(TOKEN.slice(0, 8) + "...");
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "forecast-desk"
var token = "YOUR_TOKEN" // from /tokens.html, or guest() below
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any, tok string, idem string) (json.RawMessage, error) {
var rdr io.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Content-Type", "application/json")
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
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.Error != nil {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
func guest() (string, error) {
data, err := call("/guest", map[string]string{"slug": slug}, "", "")
if err != nil {
return "", err
}
var out struct{ Token string `json:"token"` }
err = json.Unmarshal(data, &out)
return out.Token, err
}
func main() {
if token == "YOUR_TOKEN" {
t, err := guest()
if err != nil {
panic(err)
}
token = t
}
fmt.Println(token[:8] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Optional;
public class DayOne {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "forecast-desk";
static String token = "YOUR_TOKEN"; // from /tokens.html, or guest() below
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the raw JSON body. Any JSON library will do for parsing;
* the envelope is {"data": ...} on success, {"error": {...}} on failure. */
static String call(String path, String body, String tok, String idem) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (tok != null) b.header("Authorization", "Bearer " + tok);
if (idem != null) b.header("Idempotency-Key", idem);
b = body == null ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(body));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"error\"")) throw new RuntimeException(res.body());
return res.body();
}
static String guest() throws Exception {
String out = call("/guest", "{\"slug\":\"" + SLUG + "\"}", null, null);
int at = out.indexOf("\"token\":\"") + 9;
return out.substring(at, out.indexOf('"', at));
}
public static void main(String[] args) throws Exception {
if (token.equals("YOUR_TOKEN")) token = guest();
System.out.println(token.substring(0, 8) + "...");
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "forecast-desk"
TOKEN = "YOUR_TOKEN" # from /tokens.html, or guest below
def call(path, body = nil, token: nil, idem: nil)
uri = URI(BASE.to_s + path)
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req["Idempotency-Key"] = idem if idem
req.body = JSON.dump(body) if body
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"]}" if payload["error"]
payload["data"]
end
def guest
call("/guest", { "slug" => SLUG })["token"]
end
token = TOKEN == "YOUR_TOKEN" ? guest : TOKEN
puts token[0, 8] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "forecast-desk";
$token = "YOUR_TOKEN"; // from /tokens.html, or guest() below
function call(string $path, ?array $body = null, ?string $token = null, ?string $idem = null) {
$headers = ["Content-Type: application/json"];
if ($token) $headers[] = "Authorization: Bearer $token";
if ($idem) $headers[] = "Idempotency-Key: $idem";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => $body === null ? "GET" : "POST",
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
function guest(): string {
return call("/guest", ["slug" => SLUG])["token"];
}
if ($token === "YOUR_TOKEN") $token = guest();
echo substr($token, 0, 8), "...\n";
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "forecast-desk";
var token = "YOUR_TOKEN"; // from /tokens.html, or Guest() below
var http = new HttpClient();
async Task<JsonElement> Call(string path, object? body = null, string? tok = null, string? idem = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (tok is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tok);
if (idem is not null) req.Headers.Add("Idempotency-Key", idem);
var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
return doc.RootElement.GetProperty("data").Clone();
}
async Task<string> Guest() => (await Call("/guest", new { slug = Slug })).GetProperty("token").GetString()!;
if (token == "YOUR_TOKEN") token = await Guest();
Console.WriteLine(token[..8] + "...");
Step 2 · Check the session and the balance
/me is free and tells you whether the token is a personal one or a guest, and what the wallet holds. Compare it against min_credits from step 3 before submitting: a 402 after the fact is a failure of your client, not of the user.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","credits":48210,"app_slug":"forecast-desk"}}
# credits are 1/10 000 of a US dollar, so 48210 is $4.8210.
me = call("/me", token=TOKEN)
print(me["subject_type"], me["credits"], "credits =",
"${:.4f}".format(me["credits"] / 10000))
const me = await call("/me", null, { token: TOKEN });
console.log(me.subject_type, me.credits, "credits = $" + (me.credits / 10000).toFixed(4));
data, err := call("/me", nil, token, "")
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Printf("%s %d credits = $%.4f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
String me = call("/me", null, token, null);
System.out.println(me); // {"data":{"subject_type":"user","credits":48210,...}}
me = call("/me", token: token)
puts "#{me["subject_type"]} #{me["credits"]} credits = $#{"%.4f" % (me["credits"] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.4f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt32();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F4}");
Step 3 · Estimate, and assert the model binding
/estimate costs nothing and creates no job. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. hold_credits is reserved against the full output cap; the run settles at charged_credits, usually far lower. Because it is free, it is also the cheapest possible assertion in CI that this app is still bound to the model and markup you expect.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d @input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged: it prices the full output cap. The run
# settles at charged_credits, usually far lower. /estimate is free and creates
# no job, so it is also the cheapest way to assert the model binding in CI.
est = call("/estimate", INPUT, token=TOKEN)
assert est["model"] == "gpt-5.6-terra" and est["model_alias"] == "gpt-terra"
assert est["markup_bps"] == 1000
if me["credits"] < est["min_credits"]:
raise SystemExit("balance below the model minimum - top up before running")
print("reserved up to", est["hold_credits"], "credits; only what the run uses is charged")
const est = await call("/estimate", INPUT, { token: TOKEN });
if (est.model_alias !== "gpt-terra" || est.markup_bps !== 1000) throw new Error("unexpected binding");
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
console.log("reserved up to", est.hold_credits, "- charged is usually much less");
data, err = call("/estimate", input, token, "")
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(data, &est)
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
panic("unexpected model binding")
}
fmt.Println("reserved up to", est.HoldCredits, "credits")
String est = call("/estimate", INPUT_JSON, token, null);
if (!est.contains("\"model_alias\":\"gpt-terra\"")) throw new RuntimeException("unexpected binding");
System.out.println(est);
est = call("/estimate", INPUT, token: token)
raise "unexpected binding" unless est["model_alias"] == "gpt-terra" && est["markup_bps"] == 1000
raise "balance below the model minimum" if me["credits"] < est["min_credits"]
puts "reserved up to #{est["hold_credits"]} credits"
$est = call("/estimate", $input, $token);
if ($est["model_alias"] !== "gpt-terra" || $est["markup_bps"] !== 1000) {
throw new RuntimeException("unexpected model binding");
}
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("balance below the model minimum");
}
echo "reserved up to ", $est["hold_credits"], " credits\n";
var est = await Call("/estimate", input, token);
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("unexpected binding");
if (credits < est.GetProperty("min_credits").GetInt32()) throw new Exception("balance below minimum");
Console.WriteLine($"reserved up to {est.GetProperty("hold_credits").GetInt32()} credits");
The input — what facts has to carry
Everything under facts is measured, not asked for. The model is instructed
never to recompute a figure: every total, every per-deal weighted value, every bucket and
the whole gap solution are already here, and anything it writes is traced back to them.
Compute this object with PipeKit.factsForModel(PipeKit.analyze(...)) from
/pipekit.js, or assemble it yourself in the shape below.
{
"period_label": "Q1 FY26 - Payments West",
"note": "free-text steer (may be empty)",
"facts": {
"as_of": "2026-03-02",
"period": { "label": "Q1 FY26 - Payments West", "start": "2026-01-01",
"end": "2026-03-31", "business_days_left": 22 },
"totals": { "quota": 900000, "closed_to_date": 310000, "open_pipeline": 815000,
"weighted_forecast": 492800, "commit_total": 355000, "upside_total": 460000,
"best_case": 1125000, "likely_case": 802800, "worst_case": 665000,
"gap_to_quota": 97200, "coverage_ratio": 1.38, "median_deal": 92000,
"blended_rate": 0.605, "outlook": "at-risk" },
"note_on_derivation": "coverage_ratio and best_case are the same measurement restated; do not present them as two independent signals.",
"commit": [{ "id": "D1", "name": "Meridian platform rollout", "account": "Meridian Freight",
"owner": "Rita Voss", "amount": 210000, "stage": "Negotiation",
"probability": 0.8, "weighted": 168000, "close_date": "2026-03-19",
"days_to_close": 17, "last_activity": "2026-02-27", "bucket": "commit",
"risk_flags": [] }],
"upside": [{ "id": "D6", "name": "Lumen data migration", "amount": 64000,
"stage": "Evaluation", "probability": 0.4, "weighted": 25600,
"close_date": "2026-03-25", "bucket": "upside", "risk_flags": ["stale"] }],
"excluded": [{ "id": "D9", "name": "Zenith Corp", "amount": 90000,
"reason": "closes after the period ends" }],
"closed_won_rows": [{ "id": "D9", "name": "Tessellate renewal", "amount": 310000,
"close_date": "2026-02-12" }],
"risk_flags": [{ "id": "D7", "name": "Falco pilot", "amount": 40000,
"flags": [{ "id": "early_stage_late_date",
"detail": "closes in 4d but is still at Discovery / Qualification" }] }],
"gap_solution": { "state": "closable | covered | unreachable | no-quota",
"picks": [{ "id": "D5", "name": "Ardent platform suite",
"amount": 88000, "prob": 0.4, "uplift": 52800 }],
"surplus": 3600, "per_day": 4418, "new_pipeline": 160751,
"new_deals": 1.75, "text": "the solved gap, in one sentence" },
"data_quality": [{ "level": "pass | warn | fail | info", "id": "unmapped_stage",
"text": "..." }],
"decidable_ids": ["D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8"],
"flagged_ids": ["D6", "D7"]
},
"pipeline_excerpt": "the export as pasted, cut on whole rows with the header kept",
"stage_map_excerpt": "the stage-probability overrides",
"targets": { "quota": "$900K", "closed_to_date": "$310K", "period_start": "2026-01-01",
"period_end": "2026-03-31", "as_of": "2026-03-02" },
"current_datetime": "2026-03-02T09:00:00Z",
"retry_note": "optional - sent ONLY on a reformat retry, naming the parse error"
}
as_of is what slipped and
stale are measured from, so it is what makes "already past its close date" a
fact rather than an opinion — pin it in tests and the output stops drifting.
decidable_ids and flagged_ids are the two partitions the model
must cover exactly once each, and they deliberately exclude anything the engine itself
measured as absent: a deal with no amount, no readable close date, or a close date after
the period end is not in decidable_ids, so the model is never
blamed for a gap the free lane already found and named.
retry_note is optional and the app sends it only when a first reply could not
be parsed as the single JSON object. It carries the parse error and instructs a re-answer of
the same request in the correct format. If you are driving this API yourself, send it the
same way — and reuse the same Idempotency-Key body hash with a bumped
attempt suffix, so a reformat retry is a second attempt at one job rather than a second
charge for the same input.
Step 4 · Run and poll
Always send an Idempotency-Key: a content hash of the input plus an attempt counter. A timeout, a dropped connection or a 5xx retried with the same key returns the original job rather than billing a second one. output.output is the forecast-call JSON as a string - parse it, then check it. truncated: true means a low balance cut the reply short; render it as partial rather than presenting it as a whole forecast call.
# The Idempotency-Key is a content hash of the input plus an attempt counter.
# Reusing it after a timeout or a 5xx returns the ORIGINAL job instead of
# billing a second one.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: forecast-desk:9f2ac41b:a1' \
-d @input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll to a terminal state.
until curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" | tee /tmp/job.json \
| grep -q '"status":"\(succeeded\|failed\)"'; do sleep 2; done
# output.output is the forecast-call JSON as a string. Parse it, then check it.
python3 -c 'import json;d=json.load(open("/tmp/job.json"))["data"];\
print(json.loads(d["output"]["output"])["title"]);\
print("charged", d.get("charged_credits"), "truncated", d.get("truncated"))'
import time, hashlib
key = "forecast-desk:" + hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:8] + ":a1"
job_id = call("/run", INPUT, token=TOKEN)["job_id"] # send the key as Idempotency-Key
while True:
job = call("/jobs/" + job_id, token=TOKEN)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
call_obj = json.loads(job["output"]["output"]) # the JSON object SKILL.md defines
if job.get("truncated"):
print("reply cut short by the available balance - treat it as partial")
print(call_obj["title"], "-", len(call_obj["deal_calls"]), "deal calls,",
len(call_obj["risk_actions"]), "risk actions")
print("charged", job.get("charged_credits"), "credits")
const key = "forecast-desk:" + hash(JSON.stringify(INPUT)) + ":a1"; // any stable hash
const { job_id } = await call("/run", INPUT, { token: TOKEN, idempotencyKey: key });
let job;
for (;;) {
job = await call("/jobs/" + job_id, null, { token: TOKEN });
if (job.status === "succeeded" || job.status === "failed") break;
await new Promise((r) => setTimeout(r, 2000));
}
const fc = JSON.parse(job.output.output);
if (job.truncated) console.warn("reply cut short by the balance - partial");
console.log(fc.title, fc.deal_calls.length, "calls,", fc.risk_actions.length, "actions");
data, err = call("/run", input, token, "forecast-desk:9f2ac41b:a1")
if err != nil {
panic(err)
}
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(data, &started)
var job struct {
Status string `json:"status"`
Truncated bool `json:"truncated"`
ChargedCredits int `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for {
data, err = call("/jobs/"+started.JobID, nil, token, "")
if err != nil {
panic(err)
}
json.Unmarshal(data, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
var fc map[string]any
json.Unmarshal([]byte(job.Output.Output), &fc)
fmt.Println(fc["title"], "charged", job.ChargedCredits, "truncated", job.Truncated)
String started = call("/run", INPUT_JSON, token, "forecast-desk:9f2ac41b:a1");
String jobId = started.split("\"job_id\":\"")[1].split("\"")[0];
String job;
while (true) {
job = call("/jobs/" + jobId, null, token, null);
if (job.contains("\"status\":\"succeeded\"") || job.contains("\"status\":\"failed\"")) break;
Thread.sleep(2000);
}
System.out.println(job); // data.output.output holds the forecast-call JSON as a string
started = call("/run", INPUT, token: token, idem: "forecast-desk:9f2ac41b:a1")
job = nil
loop do
job = call("/jobs/#{started["job_id"]}", token: token)
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
fc = JSON.parse(job["output"]["output"])
warn "reply cut short by the balance - partial" if job["truncated"]
puts "#{fc["title"]}: #{fc["deal_calls"].length} calls, charged #{job["charged_credits"]}"
$started = call("/run", $input, $token, "forecast-desk:9f2ac41b:a1");
do {
$job = call("/jobs/" . $started["job_id"], null, $token);
if (in_array($job["status"], ["succeeded", "failed"], true)) break;
sleep(2);
} while (true);
$fc = json_decode($job["output"]["output"], true);
if (!empty($job["truncated"])) fwrite(STDERR, "reply cut short - partial\n");
printf("%s: %d calls, charged %d\n", $fc["title"], count($fc["deal_calls"]), $job["charged_credits"]);
var started = await Call("/run", input, token, "forecast-desk:9f2ac41b:a1");
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await Call("/jobs/" + jobId, null, token);
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
using var fc = JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(fc.RootElement.GetProperty("title").GetString());
The output contract
Exactly one JSON object, no prose and no code fences. Two of these fields are
partitions, and that is the part worth wiring into your own tests:
deal_calls carries exactly one entry per id in
facts.decidable_ids, and risk_actions exactly one per id in
facts.flagged_ids. Count them - an id appearing twice is as much a failure
as one missing, and asserting presence alone will not catch it.
{
"title": "Q1 FY26 forecast - Payments West",
"call_summary": "Two to four sentences: where the number lands, what it rests on, and the single thing most likely to move it.",
"deal_calls": [
{ "deal_id": "D1", "call": "commit | upside | omit",
"rationale": "one sentence citing the stage, the days to close, the activity gap or a flag" }
],
"risk_actions": [
{ "deal_id": "D7", "action": "the thing the rep does this week",
"ask": "the specific request to the customer or the manager" }
],
"gap_plan": ["ordered steps that close the measured gap, most leverage first"],
"pipeline_generation": "how much new opportunity is needed, at what deal size - or that it is not required",
"questions": ["three to six questions, each aimed at a specific deal or a measured weakness"],
"unverified": ["anything that could not be traced to facts - ideally empty"]
}
What the app checks, and what you should check too
| check | how it fails |
|---|---|
deal_calls partition | A forecastable deal with no call, a deal called twice, a call for a deal the engine excluded (reported separately as off-contract), or a deal_id that is not a deal id at all. Four distinct failures, four distinct findings. |
risk_actions partition | A flagged deal with no action, an action twice, an action for an unflagged deal, or an empty action string. When flagged_ids is empty, any risk action at all is a failure. |
call vocabulary | Anything outside commit / upside / omit. |
| figure grounding | Every $ token in the narrative is parsed and matched, within 2%, against a total in facts.totals, a deal amount, a deal weighted value or a number in gap_solution. Anything that matches nothing is named as an invented figure. |
| commit floor, measured | The worst case implied by the model's own commit set is computed and printed beside the engine's, along with every deal it promoted out of upside or pulled back out of commit. A disagreement is not a failure - it is a judgement, recorded. |
| gap honesty | A measured gap above zero with an empty gap_plan fails. |
| document checks | Run on the rendered forecast: a missing section, a stated weighted forecast that does not equal the sum of its own per-deal weighted column, a commit total that does not add up, a coverage ratio quoted without saying it is best case restated, and an unreachable gap the document does not admit to. |
| the export guard | Before any download the rendered document is read back and every measured deal id is looked up. A deal that is missing, duplicated across two buckets, or rendered under a bucket it was not measured into refuses the export rather than warning about it. |
deal_calls partition entirely. They are reported in
facts.excluded with the reason each was removed, so an operator can see them,
but the model is not asked to call a deal that has no amount or no close date - and is
therefore never penalised for a data gap the free lane already measured. The two sets are
disjoint by construction, so a genuinely missing call cannot hide inside the exemption.
Step 5 · Stream it instead
/run-stream is the same call over server-sent events, which is what the app itself uses so the progress card can advance on real signals. The frame name arrives on the event: line - there is no type field inside the payload. Frames are job, delta, done and error. Accumulate the delta text and prefer done.output.output when it arrives; if the stream dies mid-flight, what you accumulated is usually still worth parsing.
# SSE. The frame name arrives on the `event:` line - there is no `type` field
# inside the payload. Frames: job (job_id), delta (text chunks), done (the
# terminal job with charged_credits), error.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: forecast-desk:9f2ac41b:a1' \
-d @input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Q1 FY26 forecast"}
# event: done
# data: {"status":"succeeded","charged_credits":1180,"output":{"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 " + TOKEN)
req.add_header("Idempotency-Key", "forecast-desk:9f2ac41b:a1")
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:] # the frame name is on this line
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
raw += payload.get("text", "") # stream the forecast call as it is written
elif event == "done":
raw = payload["output"]["output"] or raw
print("charged", payload.get("charged_credits"))
call_obj = json.loads(raw)
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + TOKEN,
"Idempotency-Key": "forecast-desk:9f2ac41b:a1"
},
body: JSON.stringify(INPUT)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) {
const payload = JSON.parse(line.slice(6));
if (event === "delta") raw += payload.text || "";
else if (event === "done") raw = payload.output?.output || raw;
}
}
}
const fc = JSON.parse(raw);
body, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "forecast-desk:9f2ac41b:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw, event string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimSpace(line[7:])
case strings.HasPrefix(line, "data: "):
var p struct {
Text string `json:"text"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal([]byte(line[6:]), &p)
if event == "delta" {
raw += p.Text
} else if event == "done" && p.Output.Output != "" {
raw = p.Output.Output
}
}
}
fmt.Println(len(raw), "characters of forecast-call JSON")
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "forecast-desk:9f2ac41b:a1")
.POST(HttpRequest.BodyPublishers.ofString(INPUT_JSON))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7).trim();
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
String d = line.substring(6);
int at = d.indexOf("\"text\":\"");
if (at >= 0) raw.append(d, at + 8, d.lastIndexOf('"'));
}
});
System.out.println(raw.length() + " characters streamed");
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = "forecast-desk:9f2ac41b:a1"
req.body = JSON.dump(INPUT)
raw = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ")
event = line[7..].strip
elsif line.start_with?("data: ")
payload = JSON.parse(line[6..])
raw += payload["text"].to_s if event == "delta"
raw = payload.dig("output", "output") || raw if event == "done"
end
end
end
end
end
fc = JSON.parse(raw)
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer $token",
"Idempotency-Key: forecast-desk:9f2ac41b:a1",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line);
if (str_starts_with($line, "event: ")) {
$event = trim(substr($line, 7));
} elseif (str_starts_with($line, "data: ")) {
$payload = json_decode(substr($line, 6), true);
if ($event === "delta") $raw .= $payload["text"] ?? "";
if ($event === "done") $raw = $payload["output"]["output"] ?? $raw;
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$fc = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", "forecast-desk:9f2ac41b:a1");
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is string line)
{
if (line.StartsWith("event: ")) evt = line[7..].Trim();
else if (line.StartsWith("data: "))
{
using var d = JsonDocument.Parse(line[6..]);
if (evt == "delta" && d.RootElement.TryGetProperty("text", out var t))
raw.Append(t.GetString());
else if (evt == "done" && d.RootElement.TryGetProperty("output", out var o))
raw.Clear().Append(o.GetProperty("output").GetString());
}
}
using var fc = JsonDocument.Parse(raw.ToString());
Step 6 · Store and search past forecasts
The app declares one collection, forecasts, with
acl_read: owner and acl_write: user - records belong to the
calling identity. Declared fields are title, period,
owner_team, summary and outlook (strings),
quota, likely, gap, deal_count and
check_fails (numbers) and ran_at (timestamp); the rest of the
document, including the whole forecast markdown, round-trips intact but is not
filterable. The embed set is title, period,
owner_team and summary - the summary is the one that earns its
place, because "the quarter that hung on one renewal" lives in the gap solution, not in
the title.
# Exact filter: every plan that came back blocked, newest first. `where` values
# must be operator OBJECTS - a bare value is rejected. Ordering is the `sort`
# object; `order_by` is silently ignored.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/forecasts/query \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"where":{"outlook":{"eq":"short"}},
"sort":{"field":"ran_at","dir":"desc"},"limit":20}'
# Create a record. Note the path: /records, not the collection root.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/forecasts/records \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Q1 FY26 forecast - Payments West","period":"Q1 FY26 - Payments West",
"owner_team":"Rita Voss, Sam Iyer","summary":"9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
"outlook":"at-risk","likely":802800,"deal_count":9,"check_fails":0,
"ran_at":"2026-03-02T09:00:00Z","doc_md":"# Sales forecast..."}'
# Semantic search over title, period, owner_team and summary. 30 req/min per IP and
# about ten times the cost of the filter above - use `where` when an exact match
# would do.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/forecasts/similar \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"text":"the quarter that hung on one renewal","limit":8}'
# Exact filter - cheap, and the right tool whenever the question has an exact answer.
page = call("/collections/forecasts/query", {
"where": {"outlook": {"eq": "short"}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 20
}, token=TOKEN)
for rec in page["records"]:
print(rec["record_id"], rec["doc"]["title"], rec["doc"]["likely"])
# Write one. The path ends in /records.
call("/collections/forecasts/records", {
"title": "Q1 FY26 forecast - Payments West",
"period": "Q1 FY26 - Payments West",
"owner_team": "Rita Voss, Sam Iyer",
"summary": "9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
"outlook": "at-risk", "likely": 802800,
"deal_count": 9, "check_fails": 0,
"ran_at": "2026-03-02T09:00:00Z", "doc_md": plan_markdown
}, token=TOKEN)
# Semantic search. Returns records with a `score`; debounce it, 30/min per IP.
hits = call("/collections/forecasts/similar",
{"text": "the quarter that hung on one renewal", "limit": 8}, token=TOKEN)
for rec in (hits if isinstance(hits, list) else hits["records"]):
print(round(rec["score"], 3), rec["doc"]["summary"])
const page = await call("/collections/forecasts/query", {
where: { outlook: { eq: "short" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
}, { token: TOKEN });
await call("/collections/forecasts/records", {
title: "Q1 FY26 forecast - Payments West",
period: "Q1 FY26 - Payments West",
owner_team: "Rita Voss, Sam Iyer",
summary: "9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
outlook: "at-risk", likely: 802800,
deal_count: 9, check_fails: 0,
ran_at: new Date().toISOString(), doc_md: planMarkdown
}, { token: TOKEN });
// similar() over the SDK resolves to the record ARRAY; the REST call returns
// {records}. Accept either shape rather than trusting one.
const hits = await call("/collections/forecasts/similar",
{ text: "the quarter that hung on one renewal", limit: 8 }, { token: TOKEN });
for (const rec of Array.isArray(hits) ? hits : hits.records) {
console.log(rec.score.toFixed(3), rec.doc.summary);
}
query := map[string]any{
"where": map[string]any{"outlook": map[string]any{"eq": "short"}},
"sort": map[string]any{"field": "ran_at", "dir": "desc"},
"limit": 20,
}
data, err = call("/collections/forecasts/query", query, token, "")
if err != nil {
panic(err)
}
var page struct {
Records []struct {
RecordID string `json:"record_id"`
Doc map[string]any `json:"doc"`
} `json:"records"`
}
json.Unmarshal(data, &page)
for _, r := range page.Records {
fmt.Println(r.RecordID, r.Doc["title"])
}
// Semantic search - note the /similar path and the 30 req/min per-IP limit.
data, _ = call("/collections/forecasts/similar",
map[string]any{"text": "the quarter that hung on one renewal", "limit": 8}, token, "")
fmt.Println(string(data))
String body = "{\"where\":{\"outlook\":{\"eq\":\"short\"}},"
+ "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}";
System.out.println(call("/collections/forecasts/query", body, token, null));
// Create: the path ends in /records, not at the collection root.
String rec = "{\"title\":\"Q1 FY26 forecast - Payments West\","
+ "\"period\":\"Q1 FY26 - Payments West\",\"owner_team\":\"Rita Voss, Sam Iyer\","
+ "\"summary\":\"9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals\","
+ "\"outlook\":\"at-risk\",\"likely\":802800,\"deal_count\":9,"
+ "\"check_fails\":0,\"ran_at\":\"2026-08-17T09:00:00Z\"}";
call("/collections/forecasts/records", rec, token, null);
System.out.println(call("/collections/forecasts/similar",
"{\"text\":\"the quarter that hung on one renewal\",\"limit\":8}", token, null));
page = call("/collections/forecasts/query", {
"where" => { "outlook" => { "eq" => "short" } },
"sort" => { "field" => "ran_at", "dir" => "desc" },
"limit" => 20
}, token: token)
page["records"].each { |r| puts "#{r["record_id"]} #{r["doc"]["title"]}" }
call("/collections/forecasts/records", {
"title" => "Q1 FY26 forecast - Payments West",
"period" => "Q1 FY26 - Payments West",
"owner_team" => "Rita Voss, Sam Iyer",
"summary" => "9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
"outlook" => "at-risk", "likely" => 802800,
"deal_count" => 34, "check_fails" => 0,
"ran_at" => Time.now.utc.iso8601, "doc_md" => plan_markdown
}, token: token)
hits = call("/collections/forecasts/similar",
{ "text" => "the quarter that hung on one renewal", "limit" => 8 }, token: token)
records = hits.is_a?(Array) ? hits : hits["records"]
records.each { |r| puts "#{r["score"].round(3)} #{r["doc"]["summary"]}" }
$page = call("/collections/forecasts/query", [
"where" => ["outlook" => ["eq" => "short"]],
"sort" => ["field" => "ran_at", "dir" => "desc"],
"limit" => 20,
], $token);
foreach ($page["records"] as $rec) {
echo $rec["record_id"], " ", $rec["doc"]["title"], "\n";
}
call("/collections/forecasts/records", [
"title" => "Q1 FY26 forecast - Payments West",
"period" => "Q1 FY26 - Payments West",
"owner_team" => "Rita Voss, Sam Iyer",
"summary" => "9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
"outlook" => "at-risk", "likely" => 802800,
"deal_count" => 34, "check_fails" => 0,
"ran_at" => gmdate("c"), "doc_md" => $planMarkdown,
], $token);
$hits = call("/collections/forecasts/similar",
["text" => "the quarter that hung on one renewal", "limit" => 8], $token);
foreach ($hits["records"] ?? $hits as $rec) {
printf("%.3f %s\n", $rec["score"], $rec["doc"]["summary"]);
}
var page = await Call("/collections/forecasts/query", new
{
where = new { outlook = new { eq = "short" } },
sort = new { field = "ran_at", dir = "desc" },
limit = 20
}, token);
foreach (var rec in page.GetProperty("records").EnumerateArray())
Console.WriteLine(rec.GetProperty("doc").GetProperty("title").GetString());
await Call("/collections/forecasts/records", new
{
title = "Q1 FY26 forecast - Payments West",
period = "Q1 FY26 - Payments West",
owner_team = "Rita Voss, Sam Iyer",
summary = "9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals",
outlook = "at-risk", likely = 802800,
deal_count = 34, check_fails = 0,
ran_at = DateTime.UtcNow.ToString("o"), doc_md = planMarkdown
}, token);
var hits = await Call("/collections/forecasts/similar",
new { text = "the quarter that hung on one renewal", limit = 8 }, token);
Console.WriteLine(hits.ToString());
Three traps, all verified live. Every where entry must be an
operator object - {"outlook":"short"} is rejected,
{"outlook":{"eq":"short"}} is right. Ordering is the sort
object; order_by is accepted and then silently ignored, leaving you with
created_at desc. And record creation posts to
/collections/forecasts/records, not to the collection root.
Data endpoints share 120 requests/min; /collections/{name}/similar is
30/min per IP and costs roughly an order of magnitude more than a where
filter - use the filter whenever an exact match would do, and never fire a similarity
query per keystroke. Vector indexing is asynchronous, so a similar call
immediately after a write can lag by seconds. There is no backfill: records written
before an embed field existed are never vectorized. Storage quotas that
matter here: 64 KB per document, 10 000 records per collection, 1 000
records per owner.