Forecast Desk — API

Build a weighted forecast from your CRM export, your data warehouse or a Monday-morning cron job.

API tokens Open the app

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.

POST /v1/app-api/guest
GET /v1/app-api/me
POST /v1/app-api/estimate
POST /v1/app-api/run
GET /v1/app-api/jobs/{id}
POST /v1/app-api/run-stream
POST /v1/app-api/collections/forecasts/records
POST /v1/app-api/collections/forecasts/query
POST /v1/app-api/collections/forecasts/similar

Error codes

codestatuswhat it means
unauthorized401Missing or stale token. Mint a guest token or sign in again.
forbidden403The token belongs to a different app.
payment_required402Balance below min_credits. Call /estimate first and compare against /me.
validation_error400Malformed body. error.details names the field. A where value that is not an operator object lands here.
rate_limited429Back off. /similar is 30 req/min per IP, tighter than the other data endpoints.
not_found404Unknown job or record id.
internal5xxRetry with the SAME Idempotency-Key - it returns the original job instead of billing again.
None of the arithmetic needs an account. The whole free lane — parsing the export, mapping every free-text stage onto a probability, weighting the open pipeline, the closed / commit / upside / excluded partition, every risk flag, the gap solution, the forecast document, the deal CSV and every check — is in /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"
}
Three fields do quiet work. 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

checkhow it fails
deal_calls partitionA 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 partitionA 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 vocabularyAnything outside commit / upside / omit.
figure groundingEvery $ 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, measuredThe 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 honestyA measured gap above zero with an empty gap_plan fails.
document checksRun 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 guardBefore 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.
One exemption is deliberate and worth copying: deals the engine removed from the period are excluded from the 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.