Rival Scope — API

Paste a brief and competitor notes, get a competitive-landscape analysis.

API tokens Open the app

Analyze a competitive landscape from your own code

Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS, so you can run the Five Forces / Four Actions / positioning analysis from a strategy pipeline, a CRM automation or a one-off script. 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. 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. Estimates are free; runs are metered against your credit balance. There is a single run task — one call in, one JSON analysis out, no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest running a large custom analysis).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/…" -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": …} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your own secret store in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — load it from your secret store in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances, estimate costs and run the built-in example (free). To analyze your own brief and competitor notes, use your personal token: open the token page, sign in, and hit "Copy shell export" — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password — it can spend your credits. For fully headless scripts, POST /guest (below) mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"rival-scope"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "rival-scope"})["token"]
const { token } = await api("POST", "/guest", { slug: "rival-scope" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "rival-scope"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"rival-scope"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "rival-scope" })["token"]
$token = api("POST", "/guest", ["slug" => "rival-scope"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "rival-scope" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before an expensive run.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send the same input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created.

Input fieldTypeNotes
briefstring, requiredYour company or product: what it does, who buys it, pricing, stage, current positioning if any.
competitor_notesstring, requiredEverything you know about competitors — names, offerings, pricing, strengths, weaknesses. Free-form text, one competitor per block or line.
marketstring, optionalThe market/category label you target, e.g. "status page & incident communication tools".
focusstring, optionalWhat you most want out of the analysis, e.g. "how do we stand out without going enterprise?".
# input.json holds the whole request body — easier than escaping long text inline
cat > input.json <<'JSON'
{
  "brief": "PulseBoard is a status-page and incident-communication SaaS for small software teams. One flat plan at $29/month…",
  "competitor_notes": "Statuspage (Atlassian) — the default choice, $99+/mo in practice, bundled into Atlassian deals, clunky incident workflow.\n\nBetter Stack — monitoring + status pages + on-call in one, usage pricing that gets expensive.\n\nInstatus — cheap ($20/mo), fast pages, thin monitoring.",
  "market": "status page & incident communication tools",
  "focus": "How do we stand out against Atlassian above us and cheap options below us?"
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
payload = {
    "brief": brief,                       # required — your product description
    "competitor_notes": competitor_notes,  # required — free-form competitor dump
    "market": "status page & incident communication tools",
    "focus": "How do we stand out against Atlassian above us and cheap options below us?",
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const payload = {
  brief,                                   // required — your product description
  competitor_notes: competitorNotes,       // required — free-form competitor dump
  market: "status page & incident communication tools",
  focus: "How do we stand out against Atlassian above us and cheap options below us?",
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
payload := map[string]string{
	"brief":            brief,           // required
	"competitor_notes": competitorNotes, // required
	"market":           "status page & incident communication tools",
	"focus":            "How do we stand out against Atlassian above us and cheap options below us?",
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String jsonPayload = """
    {"brief": %s,
     "competitor_notes": %s,
     "market": "status page & incident communication tools",
     "focus": "How do we stand out against Atlassian above us and cheap options below us?"}
    """.formatted(toJsonString(brief), toJsonString(competitorNotes));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
payload = {
  brief: brief,                       # required
  competitor_notes: competitor_notes, # required
  market: "status page & incident communication tools",
  focus: "How do we stand out against Atlassian above us and cheap options below us?",
}

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$payload = [
    "brief"            => $brief,            // required
    "competitor_notes" => $competitorNotes,  // required
    "market"           => "status page & incident communication tools",
    "focus"            => "How do we stand out against Atlassian above us and cheap options below us?",
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var payload = new {
    brief,                                  // required
    competitor_notes = competitorNotes,     // required
    market = "status page & incident communication tools",
    focus = "How do we stand out against Atlassian above us and cheap options below us?",
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

Longer input costs more: the brief and the competitor notes are both sent verbatim to the model. Trim site chrome and boilerplate from pasted competitor pages, but keep pricing, positioning lines and weaknesses — those are what the analysis is grounded in.

Step 4 — Run an analysis and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 20–60 s). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The analysis is in output (sometimes nested as output.output, and possibly a JSON string — parse defensively).

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: scope-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# the analysis is a JSON string inside the envelope — parse it once more
echo "$JOB" | jq -r '.data.output.output' | jq '{verdict, attractiveness, moves}'
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "scope-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
analysis = json.loads(raw) if isinstance(raw, str) else raw

print(analysis["company"], "—", analysis["attractiveness"])
for f in analysis["forces"]:
    print(f"{f['id']:>14}: {f['score']}/5  {f['note']}")
print("next move:", analysis["moves"][0])
const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const analysis = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(analysis.company, "—", analysis.attractiveness);
for (const f of analysis.forces) console.log(f.id, `${f.score}/5`, f.note);
console.log("next move:", analysis.moves[0]);
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
// job.Output holds the analysis (may be {"output": …} or a JSON string —
// unwrap/unquote before unmarshalling into your Analysis struct with
// Company, Market, Verdict, Attractiveness, Forces, Positioning,
// FourActions, Opportunities, Moves, PositioningStatement, Summary).
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the analysis is at data.output (sometimes data.output.output, possibly a
// JSON string — parse it again if so), then read company, attractiveness,
// forces[], positioning.map[], four_actions, opportunities[], moves[].
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
analysis = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{analysis["company"]} — #{analysis["attractiveness"]}"
analysis["forces"].each { |f| puts "  #{f["id"]}: #{f["score"]}/5" }
puts "next move: #{analysis["moves"].first}"
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$analysis = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$analysis['company']} — {$analysis['attractiveness']}\n";
foreach ($analysis["forces"] as $f) {
    echo "  {$f['id']}: {$f['score']}/5\n";
}
echo "next move: {$analysis['moves'][0]}\n";
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

// the analysis is at job.GetProperty("output") — sometimes nested under
// "output", possibly a JSON string; parse defensively.
var text = job.GetProperty("output").GetProperty("output").GetString();
using var analysis = JsonDocument.Parse(text!);
Console.WriteLine(analysis.RootElement.GetProperty("verdict"));

The analysis object has this shape:

FieldType
company, marketstring — the product label read out of your brief, and the category
verdictstring — 2–3 sentences: industry attractiveness plus the biggest strategic implication
attractivenesshigh | moderate | low
forcesarray of exactly 5 — {id, score, note}, ids new_entrants, supplier_power, buyer_power, substitutes, rivalry; score 1–5 (1 = weak/favorable, 5 = intense/hostile)
positioning{x_axis, y_axis, map[], gap} — each map entry is {name, x, y, is_you, note} with 0–100 coordinates; every competitor you named appears once, plus one entry with is_you: true
four_actions{eliminate[], reduce[], raise[], create[]} — string arrays (Blue Ocean)
opportunitiesarray of {title, type, impact, rationale, action}; type is positioning | product | pricing | distribution | moat, impact is high | medium | low
movesstring[] — 3–6 prioritized strategic moves, most important first
positioning_statement{current, recommended, note}current is "" when the brief implies none
summarystring — 3–5 sentence executive summary

Trimmed example of a successful output.output, once parsed:

{
  "company": "PulseBoard",
  "market": "status page & incident communication tools",
  "verdict": "A moderately attractive niche: buyers switch cheaply and Atlassian bundles the default option, but nobody owns incident *communication* quality for small teams. The single biggest implication is that PulseBoard must stop competing on 'simple status page' and own the drafting workflow.",
  "attractiveness": "moderate",
  "forces": [
    { "id": "new_entrants",   "score": 4, "note": "A hosted status page is a weekend project; Instatus at $20/mo shows how little pricing power the base feature has." },
    { "id": "supplier_power", "score": 2, "note": "Email/SMS delivery and hosting are commodity inputs; the LLM drafting feature is the only supplier concentration mentioned in the brief." },
    { "id": "buyer_power",    "score": 4, "note": "600 self-serve customers at $29 flat, monthly billing and near-zero switching cost — trials openly compare against a bundled Atlassian option." },
    { "id": "substitutes",    "score": 4, "note": "A pinned Slack channel, a tweet, or Cachet self-hosted all substitute for the base product; the notes name Cachet as the free option for data-residency buyers." },
    { "id": "rivalry",        "score": 3, "note": "Rivals are differentiated rather than price-warring — Better Stack on breadth, Instatus on price — so rivalry is real but not destructive." }
  ],
  "positioning": {
    "x_axis": "breadth of the monitoring/on-call stack (narrow → full platform)",
    "y_axis": "quality of communication to non-technical subscribers (low → high)",
    "map": [
      { "name": "PulseBoard",           "x": 30, "y": 78, "is_you": true,  "note": "Narrow stack, strongest drafting workflow." },
      { "name": "Statuspage (Atlassian)","x": 42, "y": 55, "is_you": false, "note": "Brand and bundling, clunky incident workflow." },
      { "name": "Better Stack",          "x": 88, "y": 40, "is_you": false, "note": "Full platform, engineer-facing, weak subscriber comms." },
      { "name": "Instatus",              "x": 22, "y": 30, "is_you": false, "note": "Price and speed, thin everywhere else." },
      { "name": "Cachet",                "x": 15, "y": 12, "is_you": false, "note": "Free, self-hosted, no notifications." }
    ],
    "gap": "The high-communication / narrow-stack corner is empty and worth taking: it is the one axis Atlassian cannot bundle its way into quickly."
  },
  "four_actions": {
    "eliminate": ["Competing on page-load speed benchmarks"],
    "reduce":    ["Breadth of uptime-check types", "Free-tier surface"],
    "raise":     ["Quality of AI-drafted incident updates", "Subscriber-facing tone and localization"],
    "create":    ["A post-incident customer-communication report the account manager can forward"]
  },
  "opportunities": [
    {
      "title": "Own 'incident communication', not 'status page'",
      "type": "positioning",
      "impact": "high",
      "rationale": "Trials ask 'why not the bundled Atlassian option?' — a category the buyer already prices at zero. The Slack drafting feature is the only capability in the brief that no listed rival offers.",
      "action": "Rewrite the homepage and trial emails around drafting and subscriber comms; move the status page itself to a supporting feature."
    }
  ],
  "moves": [
    "Reposition the site and onboarding around incident communication within one release cycle.",
    "Instrument trials to see how many drafted updates get sent; make that the activation metric.",
    "Publish a comparison page answering the Atlassian-bundling objection head-on.",
    "Add SSO only when it blocks a deal you already want — not before."
  ],
  "positioning_statement": {
    "current": "the simple status page",
    "recommended": "For small software teams who must tell customers what is happening during an incident, PulseBoard is the incident-communication tool that drafts and sends the update for you, unlike bundled status pages which leave the writing to whoever is on call.",
    "note": "Moves the claim from a commoditized artifact (the page) to the work the buyer actually dreads (the writing)."
  },
  "summary": "PulseBoard sits in a moderately attractive niche squeezed by Atlassian's bundling above and $20 tools below…"
}

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run. Every key listed above is always present; arrays can be empty when the input genuinely gives nothing for them.

Step 5 — Stream the analysis as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — this app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance).
done{job_id, status, charged_credits, output}The final, authoritative result — read the analysis from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: scope-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_…","status":"running"}
#
# event: delta
# data: {"text":"{\"company\":\"PulseBoard\",\"market\""}
# …
# event: done
# data: {"job_id":"job_…","status":"succeeded","charged_credits":538,"output":{"output":"{…}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "scope-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

analysis = json.loads(result["output"]["output"])        # authoritative
print("charged:", result["charged_credits"], "—", analysis["verdict"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const analysis = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits — ${analysis.verdict}`);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "scope-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the analysis JSON
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "scope-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "scope-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

analysis = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits — #{analysis["verdict"]}"
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: scope-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$analysis = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits — {$analysis['verdict']}\n";
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "scope-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var analysis = JsonDocument.Parse(text!);
Console.WriteLine(analysis.RootElement.GetProperty("verdict"));

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.