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.
| Status | Meaning |
|---|---|
400 | Malformed body — usually a where filter written as a bare value instead of an operator object. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large brief). |
404 | Unknown job or record id. |
429 | Rate limited — back off and retry. |
5xx | Transient 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
| Field | Type | Meaning |
|---|---|---|
brief | string | Required. 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_hint | string | auto, or one of market, brand, consumer, financial, industry, investment, competitive, macro. |
audience | string | Who reads the finished report. Sets the evidence standard. |
depth | string | brief (4–5 chapters), standard (6–8), deep (9–12). |
output_locale | string | The language the finished report will be written in. The plan itself always comes back in English. |
constraints | string | Timeline, budget, data access, confidentiality, currency. Drives the feasibility section. |
known_data | string | Data the user already holds. Listed requirements it covers are downgraded to P2. |
current_datetime | string | The caller's local timestamp, used to resolve relative windows like "the last three years". |
prescan | object | What the client-side scan found: detected domain, time range, geographies, subject candidates, currency, specificity score, suggested frameworks. |
prescan_gaps | array | {"id": "GAP1", "label": "No time range"} entries. The plan must return exactly one gap_check entry per id. |
framework_catalogue | array | The framework names the plan may select from. Anything outside it is flagged as invented. |
retry_note | string | Optional. 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.
| Operator | Use |
|---|---|
eq, ne | Exact match, e.g. {"readiness": {"eq": "reframe"}}. |
lt, lte, gt, gte | Ranges over numbers and timestamps. |
in | Up to 20 values. |
contains | Substring 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.
| Field | Type | Meaning |
|---|---|---|
plan_title | string | Names the study. |
subject_restated | string | The research subject in one precise sentence. |
domain | string | One of the eight domain keys. |
output_locale | string | Echoes the input value. |
readiness | string | ready-to-collect, scope-first or reframe. Anything else is rejected. |
headline | string | One sentence justifying the readiness call. |
scope | object | time_range, geography, audience, in_scope[], out_of_scope[]. |
frameworks | array | 2–4 entries of {name, layer, chapters_applied[], why}. Every name must appear in the request's framework_catalogue. |
chapters | array | At 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}. |
feasibility | array | {item, status, detail}; status is obtainable, hard, proxy-needed or unobtainable. |
assumptions, open_questions, next_steps | array<string> | May be empty arrays, but never omitted. |
gap_check | array | {id, addressed, note}, exactly one per id in prescan_gaps. |
summary | string | Two 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.