Research Desk — API

Paste the brief, get the research plan.

API tokens Open the app

Run the research planner from your own tools

Send a research brief — a market study, a brand question, an industry scan, an investment diligence mandate — and get back one JSON object: the two to four analytical frameworks the study will use and why they are complementary, a chapter skeleton where every chapter carries an objective, a reasoning chain and one to three falsifiable hypotheses, the exact data each chapter needs with a priority, a period, source categories and the queries a collector would type, a visualization plan tied to the data behind each chart, a feasibility read, and a ready-to-collect / scope-first / reframe position on the brief itself. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into an intake form, generate collection worksheets in bulk, or gate a research budget on the readiness 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 research-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. The plan is produced by the gpt-terra model at a 10% publisher markup. Estimates are free; runs are metered against your credit balance. There is a single run task — one brief in, one plan out — plus a plans collection holding your saved runs.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/plans/query
StatusMeaning
400Malformed body — usually a where filter written as a bare value instead of an operator object.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large brief).
404Unknown job or record id.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff, reusing the same idempotency key.

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 shell environment 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 — read it from your shell environment 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;

class Api {
    static final String BASE = "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 call(String method, String path, String jsonBody) throws Exception {
        var body = jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody);
        var req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Authorization", "Bearer " + TOKEN)
                .header("Content-Type", "application/json")
                .method(method, body)
                .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body();   // {"data": ...}
    }
}
require "json"
require "net/http"
require "uri"

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

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
            "DELETE" => Net::HTTP::Delete }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  extra.each { |k, v| req[k] = v }
  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.dig("error", "message").to_s 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, array $extra = []): array {
    global $TOKEN;
    $headers = ["Authorization: Bearer $TOKEN", "Content-Type: application/json"];
    foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $payload = json_decode($raw, true);
    if ($status >= 400) { throw new RuntimeException($payload["error"]["message"] ?? "request failed"); }
    return $payload["data"];
}
// .NET 6+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class Api {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!; // step 1
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null,
                                               (string, string)? extraHeader = null) {
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (extraHeader is var (hk, hv) && hk is not null) req.Headers.Add(hk, hv);
        if (body is not null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

Two kinds. A guest token needs no account and is enough for /me and the free /estimate. A personal token bills metered runs to your own balance — get one from the token page, which shows the token this browser already holds, lets you sign in for a personal one, and copies a ready-made export SKILLSAFE_TOKEN="…" line. You never need the DevTools console.

# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"research-desk"}' | jq -r .data.token

# For a personal token (metered runs bill your account), open
#   https://research-desk.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
guest = requests.post(API + "/guest", json={"slug": "research-desk"}).json()["data"]
TOKEN = guest["token"]          # aut_...
guest_id = guest["guest_id"]    # gst_... — keep it if you later migrate the wallet on sign-in

# For a personal token (metered runs bill your account), open
#   https://research-desk.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "research-desk" }),
})).json();
const token = guest.data.token;

// For a personal token (metered runs bill your account), open
//   https://research-desk.skillsafe.ai/tokens.html
// sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
guestBody, _ := json.Marshal(map[string]string{"slug": "research-desk"})
req, _ := http.NewRequest("POST", API+"/guest", bytes.NewReader(guestBody))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var env struct {
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
token = env.Data.Token

// For a personal token, open https://research-desk.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var req = HttpRequest.newBuilder(URI.create(Api.BASE + "/guest"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"research-desk\"}"))
        .build();
var res = Api.HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// res.body() is {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
// — read data.token with your JSON library.

// For a personal token, open https://research-desk.skillsafe.ai/tokens.html
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
uri = URI(API + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "research-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
token = JSON.parse(res.body).dig("data", "token")

# For a personal token, open https://research-desk.skillsafe.ai/tokens.html
<?php
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode(["slug" => "research-desk"]),
]);
$guest  = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $guest["data"]["token"];

// For a personal token, open https://research-desk.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest") {
    Content = new StringContent("{\"slug\":\"research-desk\"}", Encoding.UTF8, "application/json"),
};
var guestRes = await new HttpClient().SendAsync(guestReq);
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guest.GetProperty("data").GetProperty("token").GetString();

// For a personal token, open https://research-desk.skillsafe.ai/tokens.html

Treat the token like a password: anyone holding it can spend its credits through this app. Keep it in your shell environment rather than in source control.

Step 2 — Check the session and the balance

GET /me is free and tells you whether the token is a guest or a real user, and how many credits it can spend. The app calls this before enabling its run button, and so should you — a 402 after submitting is avoidable.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq .data
# { "subject_type": "user", "subject_id": "...", "credits": 184220 }
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"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	panic(err)
}
fmt.Println(me.SubjectType, me.Credits)
String me = Api.call("GET", "/me", null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":184220}}
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — Estimate (free, no job created)

POST /estimate takes the exact body you would send to /run and returns the model binding and the price envelope without creating a job or charging anything. hold_credits is a worst-case reservation, priced against the full output cap; the settled charged_credits is usually much lower. If the balance sits between min_credits and hold_credits, the run still executes with a reduced cap and comes back "truncated": true.

Input fields

FieldTypeMeaning
briefstringRequired. The research brief in prose. The app clips anything over 24,000 characters on paragraph boundaries, dropping the middle and keeping the opening and closing.
domain_hintstringauto, or one of market, brand, consumer, financial, industry, investment, competitive, macro.
audiencestringWho reads the finished report. Sets the evidence standard.
depthstringbrief (4–5 chapters), standard (6–8), deep (9–12).
output_localestringThe language the finished report will be written in. The plan itself always comes back in English.
constraintsstringTimeline, budget, data access, confidentiality, currency. Drives the feasibility section.
known_datastringData the user already holds. Listed requirements it covers are downgraded to P2.
current_datetimestringThe caller's local timestamp, used to resolve relative windows like "the last three years".
prescanobjectWhat the client-side scan found: detected domain, time range, geographies, subject candidates, currency, specificity score, suggested frameworks.
prescan_gapsarray{"id": "GAP1", "label": "No time range"} entries. The plan must return exactly one gap_check entry per id.
framework_cataloguearrayThe framework names the plan may select from. Anything outside it is flagged as invented.
retry_notestringOptional. Sent only on the app's one reformat retry.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"brief":"Industry research on the European heat pump market for residential retrofit, 2021-2026.","domain_hint":"industry","depth":"standard","output_locale":"en","prescan_gaps":[{"id":"GAP5","label":"No stated audience"}]}' | jq .data

# { "model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000,
#   "hold_credits": 3140, "min_credits": 420, "sponsor_enabled": false }
payload = {
    "brief": "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
    "domain_hint": "industry",
    "audience": "Board of directors",
    "depth": "standard",
    "output_locale": "en",
    "constraints": "Six weeks. Figures in EUR.",
    "known_data": "",
    "current_datetime": "2026-08-06T09:00:00+02:00 (Thursday)",
    "prescan_gaps": [{"id": "GAP5", "label": "No stated audience"}],
}
est = api("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const payload = {
  brief: "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
  domain_hint: "industry",
  audience: "Board of directors",
  depth: "standard",
  output_locale: "en",
  constraints: "Six weeks. Figures in EUR.",
  known_data: "",
  current_datetime: "2026-08-06T09:00:00+02:00 (Thursday)",
  prescan_gaps: [{ id: "GAP5", label: "No stated audience" }],
};
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
payload := map[string]any{
	"brief":            "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
	"domain_hint":      "industry",
	"audience":         "Board of directors",
	"depth":            "standard",
	"output_locale":    "en",
	"current_datetime": "2026-08-06T09:00:00+02:00 (Thursday)",
}

var est struct {
	Model       string `json:"model"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	panic(err)
}
fmt.Println(est.Model, est.HoldCredits, est.MinCredits)
String payload = """
    {"brief":"Industry research on the European heat pump market for residential retrofit, 2021-2026.",
     "domain_hint":"industry","audience":"Board of directors","depth":"standard",
     "output_locale":"en","current_datetime":"2026-08-06T09:00:00+02:00 (Thursday)"}
    """;
String est = Api.call("POST", "/estimate", payload);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
payload = {
  "brief"            => "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
  "domain_hint"      => "industry",
  "audience"         => "Board of directors",
  "depth"            => "standard",
  "output_locale"    => "en",
  "current_datetime" => "2026-08-06T09:00:00+02:00 (Thursday)",
}
est = api("POST", "/estimate", payload)
puts est["model"], est["hold_credits"], est["min_credits"]
<?php
$payload = [
    "brief"            => "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
    "domain_hint"      => "industry",
    "audience"         => "Board of directors",
    "depth"            => "standard",
    "output_locale"    => "en",
    "current_datetime" => "2026-08-06T09:00:00+02:00 (Thursday)",
];
$est = api("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], "\n";
var payload = new {
    brief = "Industry research on the European heat pump market for residential retrofit, 2021-2026.",
    domain_hint = "industry",
    audience = "Board of directors",
    depth = "standard",
    output_locale = "en",
    current_datetime = "2026-08-06T09:00:00+02:00 (Thursday)",
};
var est = await Api.Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

The three assertions worth making in a test: model is gpt-5.6-terra, model_alias is gpt-terra, and markup_bps is 1000. Together they prove the app is bound to the right model at the right markup, and they cost nothing to check.

Step 4 — Run and poll

POST /run creates a job; GET /jobs/{id} polls it to a terminal state. Always send an Idempotency-Key derived from the input content plus an attempt counter — a retried POST with the same key returns the original job instead of billing a second one. The completed job's output.output is the plan as a JSON string.

# The Idempotency-Key makes a retried POST return the original job instead of
# billing a second one. Derive it from the input, not from a random value.
KEY="research-desk:$(printf %s "$BRIEF" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json | jq -r .data.job_id)

# Poll until terminal.
while :; do
  J=$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(echo "$J" | jq -r .data.status)
  [ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
  sleep 2
done
echo "$J" | jq -r .data.output.output | jq .   # the plan, as JSON
import hashlib, time

key = "research-desk:" + hashlib.sha256(payload["brief"].encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", payload, **{"Idempotency-Key": key})

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

plan = json.loads(j["output"]["output"])
print(plan["readiness"], len(plan["chapters"]), "chapters")
import { createHash } from "node:crypto";

const key = "research-desk:" + createHash("sha256").update(payload.brief).digest("hex").slice(0, 16) + ":a1";
const job = await api("POST", "/run", payload, { "Idempotency-Key": key });

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

const plan = JSON.parse(j.output.output);
console.log(plan.readiness, plan.chapters.length, "chapters");
import (
	"crypto/sha256"
	"encoding/hex"
	"time"
)

sum := sha256.Sum256([]byte(brief))
key := "research-desk:" + hex.EncodeToString(sum[:])[:16] + ":a1"

// call() with an extra header — add req.Header.Set("Idempotency-Key", key) there.
var job struct {
	JobID string `json:"job_id"`
}
if err := call("POST", "/run", payload, &job); err != nil {
	panic(err)
}

var j struct {
	Status string `json:"status"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+job.JobID, nil, &j); err != nil {
		panic(err)
	}
	if j.Status == "succeeded" || j.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
fmt.Println(j.Output.Output) // the plan, as a JSON string
// Add the header inside Api.call(), or build the request inline:
var runReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", "research-desk:" + Integer.toHexString(brief.hashCode()) + ":a1")
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
var runRes = Api.HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
// read data.job_id, then poll GET /jobs/{job_id} until status is succeeded or failed.
String job = Api.call("GET", "/jobs/" + jobId, null);
System.out.println(job);
require "digest"

key = "research-desk:#{Digest::SHA256.hexdigest(payload["brief"])[0, 16]}:a1"
job = api("POST", "/run", payload, { "Idempotency-Key" => key })

loop do
  @j = api("GET", "/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 2
end

plan = JSON.parse(@j["output"]["output"])
puts plan["readiness"], plan["chapters"].length
<?php
$key = "research-desk:" . substr(hash("sha256", $payload["brief"]), 0, 16) . ":a1";
$job = api("POST", "/run", $payload, ["Idempotency-Key" => $key]);

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

$plan = json_decode($j["output"]["output"], true);
echo $plan["readiness"], " ", count($plan["chapters"]), " chapters\n";
using System.Security.Cryptography;

var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(brief)))[..16].ToLower();
var job = await Api.Call(HttpMethod.Post, "/run", payload, ("Idempotency-Key", $"research-desk:{hash}:a1"));
var jobId = job.GetProperty("job_id").GetString();

JsonElement j;
do {
    await Task.Delay(2000);
    j = await Api.Call(HttpMethod.Get, $"/jobs/{jobId}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var plan = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(plan.GetProperty("readiness"));

Step 5 — Stream instead (SSE)

POST /run-stream is the same call with Accept: text/event-stream. It emits an event: job frame, then a sequence of event: delta frames whose data.text carries fragments of the JSON in order, then event: done with the settled charge (or event: error). The frame name is on the event: line — the data: payload carries no type field of its own, so track the current event name as you read. This is what the app itself uses, and it is what lets a progress UI advance on real signals — the arrival of "frameworks" or "chapters" in the stream — rather than on a timer. Concatenate every delta and parse the whole thing once at the end.

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" \
  -d @payload.json

# Standard SSE frames, separated by a blank line. The frame NAME is on the
# `event:` line; the payload is on the `data:` line and carries no type field of
# its own, so you must track the current event name as you read:
#
#   event: job
#   data: {"job_id":"job_..."}
#
#   event: delta
#   data: {"text":"{\"plan_title\":\"..."}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":812,"output":{"output":"..."}}
#
# Concatenate every delta's `text` in order and parse the result as one JSON
# object. An `event: error` frame carries a failure instead.
with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": f"Bearer {TOKEN}",
                            "Accept": "text/event-stream",
                            "Idempotency-Key": key}) as res:
    raw, event, done = "", "message", None
    for line in res.iter_lines(decode_unicode=True):
        if line is None:
            continue
        if line == "":                          # blank line ends a frame
            event = "message"
            continue
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data = json.loads(line[5:].strip())
            if event == "delta":
                raw += data.get("text", "")
            elif event == "done":
                done = data
            elif event == "error":
                raise RuntimeError(data.get("message", "stream failed"))

plan = json.loads(raw[raw.index("{"): raw.rindex("}") + 1])
print(plan["plan_title"], plan["readiness"], done["charged_credits"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
    Accept: "text/event-stream",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});

let raw = "", buf = "", done = null;
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += dec.decode(chunk.value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) >= 0) {     // frames are blank-line separated
    const frame = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    let event = "message", dataStr = "";
    for (const line of frame.split("\n")) {
      if (line.startsWith("event:")) event = line.slice(6).trim();
      else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
    }
    if (!dataStr) continue;
    const data = JSON.parse(dataStr);
    if (event === "delta") raw += data.text ?? "";
    else if (event === "done") done = data;
    else if (event === "error") throw new Error(data.message ?? "stream failed");
  }
}
const plan = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(plan.plan_title, plan.readiness, done?.charged_credits);
import (
	"bufio"
	"strings"
)

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("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", key)

stream, _ := http.DefaultClient.Do(req)
defer stream.Body.Close()

var raw bytes.Buffer
event := "message"
sc := bufio.NewScanner(stream.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case line == "":
		event = "message" // blank line ends the frame
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(line[5:]), &d)
		if event == "delta" {
			raw.WriteString(d.Text)
		}
	}
}
fmt.Println(raw.String()) // the plan, as one JSON document
var streamReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run-stream"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Accept", "text/event-stream")
        .header("Idempotency-Key", key)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();

var raw = new StringBuilder();
var event = new String[]{"message"};
Api.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.isEmpty()) {
        event[0] = "message";                 // blank line ends the frame
    } else if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library, then append the text
        raw.append(line.substring(5).trim());
    }
});
System.out.println(raw);
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Accept"]          = "text/event-stream"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)

raw   = +""
buf   = +""
event = "message"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n\n"))
        frame = buf.slice!(0, i + 2)
        frame.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip
          elsif line.start_with?("data:") && event == "delta"
            raw << (JSON.parse(line[5..].strip)["text"] || "")
          end
        end
        event = "message"   # the frame ended
      end
    end
  end
end

plan = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts plan["plan_title"]
<?php
$raw   = "";
$buf   = "";
$event = "message";

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Accept: text/event-stream",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$buf, &$event) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n\n")) !== false) {
            $frame = substr($buf, 0, $i);
            $buf   = substr($buf, $i + 2);
            foreach (explode("\n", $frame) as $line) {
                if (str_starts_with($line, "event:")) {
                    $event = trim(substr($line, 6));
                } elseif (str_starts_with($line, "data:") && $event === "delta") {
                    $d = json_decode(substr($line, 5), true);
                    $raw .= $d["text"] ?? "";
                }
            }
            $event = "message";   // the frame ended
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$start = strpos($raw, "{");
$plan  = json_decode(substr($raw, $start, strrpos($raw, "}") - $start + 1), true);
echo $plan["plan_title"], "\n";
var streamReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

var streamRes = await new HttpClient().SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());

var raw = new StringBuilder();
var evt = "message";
while (await reader.ReadLineAsync() is { } line) {
    if (line.Length == 0) { evt = "message"; continue; }   // blank line ends the frame
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); continue; }
    if (!line.StartsWith("data:")) continue;
    var data = JsonDocument.Parse(line[5..]).RootElement;
    if (evt == "delta") raw.Append(data.GetProperty("text").GetString());
}
Console.WriteLine(raw.ToString());

If the stream dies mid-plan you still hold every delta received so far. Slicing from the first { and appending the closing brackets recovers a partial plan often enough to be worth trying before you show an error.

Step 6 — Your saved plans

Every run the app completes is written to the plans collection (acl_read: owner, acl_write: user), so a plan follows the user across devices. Four fields are declared and therefore filterable and orderable: title, domain, readiness, chapter_count, requirement_count and ran_at. The rest of the document — the whole plan and the original brief — round-trips intact but is not indexed.

OperatorUse
eq, neExact match, e.g. {"readiness": {"eq": "reframe"}}.
lt, lte, gt, gteRanges over numbers and timestamps.
inUp to 20 values.
containsSubstring match on a string field.
# List your saved plans, newest first.
curl -s -X POST "$API/collections/plans/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"order_by":[{"field":"ran_at","dir":"desc"}],"limit":10}' | jq '.data.records[].doc.title'

# Only the ones that still need scoping decisions, with real work behind them.
curl -s -X POST "$API/collections/plans/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{"readiness":{"eq":"scope-first"},"requirement_count":{"gte":12}},
       "order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}' | jq .data
# Every `where` entry must be an operator object — the bare-value shorthand
# ({"readiness": "scope-first"}) is rejected.
recent = api("POST", "/collections/plans/query", {
    "where": {"readiness": {"eq": "scope-first"}, "requirement_count": {"gte": 12}},
    "order_by": [{"field": "ran_at", "dir": "desc"}],
    "limit": 20,
})
for rec in recent["records"]:
    d = rec["doc"]
    print(d["ran_at"], d["title"], d["chapter_count"], "chapters", d["requirement_count"], "requirements")
const recent = await api("POST", "/collections/plans/query", {
  where: { readiness: { eq: "scope-first" }, requirement_count: { gte: 12 } },
  order_by: [{ field: "ran_at", dir: "desc" }],
  limit: 20,
});
for (const rec of recent.records) {
  const d = rec.doc;
  console.log(d.ran_at, d.title, d.chapter_count, d.requirement_count);
}
query := map[string]any{
	"where":    map[string]any{"readiness": map[string]any{"eq": "scope-first"}},
	"order_by": []map[string]string{{"field": "ran_at", "dir": "desc"}},
	"limit":    20,
}

var out struct {
	Records []struct {
		RecordID string         `json:"record_id"`
		Doc      map[string]any `json:"doc"`
	} `json:"records"`
}
if err := call("POST", "/collections/plans/query", query, &out); err != nil {
	panic(err)
}
for _, r := range out.Records {
	fmt.Println(r.Doc["ran_at"], r.Doc["title"])
}
String query = """
    {"where":{"readiness":{"eq":"scope-first"}},
     "order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}
    """;
String plans = Api.call("POST", "/collections/plans/query", query);
System.out.println(plans);   // {"data":{"records":[{"record_id":"...","doc":{...}}]}}
recent = api("POST", "/collections/plans/query", {
  "where"    => { "readiness" => { "eq" => "scope-first" } },
  "order_by" => [{ "field" => "ran_at", "dir" => "desc" }],
  "limit"    => 20,
})
recent["records"].each { |r| puts "#{r["doc"]["ran_at"]} #{r["doc"]["title"]}" }
<?php
$recent = api("POST", "/collections/plans/query", [
    "where"    => ["readiness" => ["eq" => "scope-first"]],
    "order_by" => [["field" => "ran_at", "dir" => "desc"]],
    "limit"    => 20,
]);
foreach ($recent["records"] as $rec) {
    echo $rec["doc"]["ran_at"], " ", $rec["doc"]["title"], "\n";
}
var query = new {
    where = new { readiness = new { eq = "scope-first" } },
    order_by = new[] { new { field = "ran_at", dir = "desc" } },
    limit = 20,
};
var recent = await Api.Call(HttpMethod.Post, "/collections/plans/query", query);
foreach (var rec in recent.GetProperty("records").EnumerateArray())
    Console.WriteLine(rec.GetProperty("doc").GetProperty("title"));

Every where entry must be an operator object. The bare-value shorthand {"readiness": "scope-first"} is rejected with where.readiness must be an object of operators. Note also that each POST /guest mints a new guest subject, and acl_read: owner scopes rows to the calling subject — so a script must reuse one token across create and query or it will see an empty collection.

The output contract

One JSON object. These are the fields the app's own render path parses; anything missing makes the app fall back to showing the raw reply, so treat them as required.

FieldTypeMeaning
plan_titlestringNames the study.
subject_restatedstringThe research subject in one precise sentence.
domainstringOne of the eight domain keys.
output_localestringEchoes the input value.
readinessstringready-to-collect, scope-first or reframe. Anything else is rejected.
headlinestringOne sentence justifying the readiness call.
scopeobjecttime_range, geography, audience, in_scope[], out_of_scope[].
frameworksarray2–4 entries of {name, layer, chapters_applied[], why}. Every name must appear in the request's framework_catalogue.
chaptersarrayAt least 3 entries of {number, title, objective, analysis_logic, frameworks[], core_hypotheses[], data_requirements[], visualizations[]}.
chapters[].data_requirements[]object{metric, data_type, priority, time_range, sources[], search_keywords[]}. data_type is quantitative/qualitative/mixed; priority is P0/P1/P2.
chapters[].visualizations[]object{chart_type, title, encodes, source_requirement}.
feasibilityarray{item, status, detail}; status is obtainable, hard, proxy-needed or unobtainable.
assumptions, open_questions, next_stepsarray<string>May be empty arrays, but never omitted.
gap_checkarray{id, addressed, note}, exactly one per id in prescan_gaps.
summarystringTwo to four sentences a sponsor could read alone.

The plan never states an empirical figure as fact. Any number in the output is either quoted from your brief/known_data or framed as a hypothesis to falsify. If you are post-processing the output, that is a property you can rely on — and one worth asserting on.

Rendering it yourself

The two exports the app ships are pure functions of the plan, so they are easy to reproduce server-side. The data-collection worksheet flattens chapters[].data_requirements[] into chapter_no, chapter, metric, data_type, priority, time_range, suggested_sources, search_keywords, status — one row per requirement, with an empty status column for whoever gathers the evidence. The chart plan flattens chapters[].visualizations[] into chapter_no, chapter, chart_type, title, encodes, source_requirement. Semicolons separate list values inside a cell.