Drive Brainstorm Desk from your own code
A review of a shortlist, not a decision. The model never picks the winner:
decision is always null, and the decision stays with the decision owner.
It never adds an idea that is not in your notes and never invents a rating. A valid register and a
ranked matrix establish neither that an idea works nor that it is safe to pursue.
Brainstorm Desk turns a scientific brainstorming session into a structured record and then
challenges its shortlist. The two tools of the agent skill
@k-dense-ai/scientific-brainstorming (k-dense-ai/scientific-agent-skills) run free in
your browser: validate_register.py (the idea register against schema 1.1: errors,
warnings and statistics) and evaluate_matrix.py (the scoring sheet against the criteria
file: normalised weights, scores, rating-interval and weight-sensitivity ranges, and the shortlist).
Those free tools are not on the API - the page runs them locally. Outside the page,
run the skill's own scripts to compute the same facts:
python3 scripts/validate_register.py register.json and
python3 scripts/evaluate_matrix.py scores.csv --config criteria.json. The API is the two
metered lanes, on model gpt-terra: draft turns session notes into a
register, a criteria file and a scoring sheet, and review challenges the shortlisted
ideas using the facts those tools computed. The natural loop: draft, run the tools, review, revise,
re-check, then hand the record to the decision owner.
Two lanes: the task field
task is the one required field and picks the lane. /estimate does not
validate the body, so always send a JSON object with task set to one of the two lanes.
| task | needs | returns |
|---|---|---|
draft | notes (the session notes); to revise, also register and criteria (and facts, if you have them) | A register (schema 1.1), a criteria file (schema 1.0) and a scoring sheet as CSV, with ratings_source notes (the ratings were in the notes) or blank (an empty sheet to fill), every value the notes did not state in assumptions_made, what you must decide in open_questions and, for a revision, each change in changes. |
review | criteria + scores + facts; register when you have one; notes as optional context | A status (ready_for_decision_owner, revise_first, not_reviewable), a response to every browser flag, a challenge of every shortlisted idea, fragility notes, safety and feasibility gates, criteria issues, missing perspectives, questions, decision: null, and what nothing here establishes. |
Worked example: review
Two shortlisted ideas scored on two criteria: information_gain (higher is better, 1-5,
weight 3) and resource_burden (lower is better, 1-5, weight 2). No register is sent, the
matrix ranks both, and the page raises one flag: the ideas' rating intervals overlap. Its status is
ready_for_decision_owner. Every value is a string: criteria and
facts are JSON texts, scores is CSV text.
{
"task": "review",
"criteria": "{\"schema_version\":\"1.0\",\"criteria\":[{\"name\":\"information_gain\",\"description\":\"How much the idea would teach us\",\"weight\":3,\"direction\":\"higher\",\"minimum\":1,\"maximum\":5},{\"name\":\"resource_burden\",\"description\":\"Staff time and cost\",\"weight\":2,\"direction\":\"lower\",\"minimum\":1,\"maximum\":5}]}",
"scores": "idea_id,information_gain,resource_burden\nI1,4,2\nI2,4,3\n",
"facts": "{\"page\":\"brainstorm-desk\",\"skill_tools\":\"validate_register.py, evaluate_matrix.py (scientific-brainstorming skill, run in the browser)\",\"register\":{\"absent\":true},\"matrix\":{\"exit\":0,\"weight_delta\":0.1,\"criteria\":[{\"name\":\"information_gain\",\"direction\":\"higher\",\"minimum\":1,\"maximum\":5,\"weight\":3,\"normalized_weight\":0.6},{\"name\":\"resource_burden\",\"direction\":\"lower\",\"minimum\":1,\"maximum\":5,\"weight\":2,\"normalized_weight\":0.4}],\"results\":[{\"idea_id\":\"I1\",\"presentation_rank\":1,\"score\":0.75,\"input_interval\":[0.6,0.85],\"weight_sensitivity_scores\":[0.75,0.75],\"weight_sensitivity_ranks\":[1,1],\"in_shortlist\":true},{\"idea_id\":\"I2\",\"presentation_rank\":2,\"score\":0.65,\"input_interval\":[0.5,0.8],\"weight_sensitivity_scores\":[0.6,0.7],\"weight_sensitivity_ranks\":[2,2],\"in_shortlist\":true}],\"warnings\":[],\"decision\":null},\"shortlist\":[\"I1\",\"I2\"],\"flags\":[{\"id\":\"F1\",\"kind\":\"interval_overlap\",\"text\":\"The input intervals of I1 [0.6, 0.85] and I2 [0.5, 0.8] overlap: the rank order may not hold.\"}],\"browser_status\":\"ready_for_decision_owner\"}",
"notes": "Imaging core sample-prep session; the decision owner is the core manager."
}
A reply in the lane's contract (a real reply covers every shortlisted idea once):
{
"task": "review",
"status": "ready_for_decision_owner",
"headline": "I1 ranks first on the sheet, but its interval overlaps I2's, so the order is a weak signal for the decision owner.",
"flag_responses": [
{"ref": "F1", "stance": "confirmed", "note": "Intervals [0.6, 0.85] and [0.5, 0.8] overlap; the 0.1 score gap rests on one resource_burden point."}
],
"idea_reviews": [
{"idea_id": "I1", "strongest_version": "Batch staining on a shared rack cuts hands-on time per sample.",
"counter_observation": "Batching may lengthen the wait for urgent single samples.",
"alternative_explanations": ["Perceived gain may come from the proposer's own workload.", "Prep time may be driven by booking, not staining."],
"measurement_failure": "Ratings are board estimates, not timed runs.",
"sampling_failure": "Three participants from one lab rated it.",
"harm_or_misuse": "Cross-contamination between samples on a shared rack.",
"mitigation": "Time ten preps before and after; label rack positions.",
"residual_uncertainty": "Effect size on turnaround is unknown.",
"disposition": "retain", "next_action": "pilot-design"},
{"idea_id": "I2", "strongest_version": "A pre-booked prep calendar removes queueing.",
"counter_observation": "Unused slots may block other users.",
"alternative_explanations": ["Queues may already be short outside peak weeks.", "No-shows, not booking, may cause the delays."],
"measurement_failure": "No queue data was cited.",
"sampling_failure": "Core users outside the session were not asked.",
"harm_or_misuse": "None apparent beyond access fairness.",
"mitigation": "Pull two months of booking logs first.",
"residual_uncertainty": "Whether queueing is the bottleneck.",
"disposition": "revise", "next_action": "further-search"}
],
"fragility": [{"idea_id": "I2", "note": "Its weight-sensitivity scores span 0.6 to 0.7; the rank holds at 2 across the tested weights."}],
"gates": [{"idea_id": "I1", "gate": "biosafety", "status": "unclear", "note": "Depends on which specimens share the rack."}],
"criteria_issues": ["No criterion covers turnaround for urgent samples."],
"missing_perspectives": ["Occasional core users", "Biosafety officer"],
"questions": ["Which specimen classes would share the batch rack?"],
"decision": null,
"not_established": ["That either idea reduces prep time.", "That the rank order would survive re-rating by other users."]
}
Worked example: draft
Short notes from a session of three people with three ideas, the ratings written on the board (no register or criteria yet, so none are sent):
{
"task": "draft",
"notes": "Session 2026-09-20. Facilitator P1, participants P2 and P3. Question: how can the imaging core cut sample-prep time? Ideas: I1 batch staining on a shared rack (P2); I2 a pre-booked prep-slot calendar (P3); I3 train two more users on the embedder (P1). Criteria: information_gain, higher is better, weight 3; resource_burden, lower is better, weight 2; both rated 1-5. Board ratings: I1 4 and 2; I2 2 and 1; I3 3 and 4."
}
A reply in the lane's contract. The real register is the complete schema 1.1 object the
validator reads; here it is cut and marked "...":
{
"task": "draft",
"headline": "Three ideas from a three-person imaging-core session, with the board ratings carried into the sheet.",
"register": {"schema_version": "1.1", "...": "the complete register: the session, pseudonymous participants P1-P3, and ideas I1-I3 exactly as in the notes"},
"criteria": {"schema_version": "1.0", "criteria": [
{"name": "information_gain", "description": "How much the idea would teach us", "weight": 3, "direction": "higher", "minimum": 1, "maximum": 5},
{"name": "resource_burden", "description": "Staff time and cost", "weight": 2, "direction": "lower", "minimum": 1, "maximum": 5}
]},
"scores_csv": "idea_id,information_gain,resource_burden\nI1,4,2\nI2,2,1\nI3,3,4\n",
"ratings_source": "notes",
"assumptions_made": [
{"field": "criteria[0].description", "value": "How much the idea would teach us", "why": "The notes name the criterion but do not describe it."}
],
"open_questions": ["Who is the decision owner for the shortlist?"],
"changes": []
}
Input fields
Every field is a string - register, criteria and facts
included, each a JSON text, not an object; scores is CSV text. This matches the app's
declared input schema.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "draft" or "review". |
notes | string | no (draft: yes) | Draft: your session notes (the page sends at most 12,000 characters, cut on a sentence and marked [... cut: N more characters not sent]). Review: optional context. |
register | string | no | The idea register as JSON text (schema 1.1 of the scientific-brainstorming skill). Review: when you have one. Draft: only when revising an existing register. |
criteria | string | no (review: yes) | The criteria file as JSON text, schema 1.0: {"schema_version":"1.0","criteria":[{"name","description","weight","direction":"higher|lower","minimum","maximum"}]}. Review: always. Draft: only when revising. |
scores | string | no (review: yes) | Review only: the scoring sheet as CSV text - the header plus the shortlisted ideas' rows. |
facts | string | no (review: yes) | A JSON-encoded string of what the skill's two tools found - see below. |
question | string | no | Something you want answered inside the lane's fields. |
retry_note | string | no | Only when re-asking after a reply that did not parse. |
The facts string
facts is a JSON string, not an object. The page computes it by running
the skill's validate_register.py and evaluate_matrix.py in the browser. It
holds page and skill_tools; register (exit,
valid, errors[], warnings[], statistics - or
failure, crash or absent); matrix
(exit, weight_delta, criteria[] with name,
direction, minimum, maximum, weight and
normalized_weight, results[] with idea_id,
presentation_rank, score, input_interval [lo, hi],
weight_sensitivity_scores [min, max], weight_sensitivity_ranks [min, max]
and in_shortlist, warnings[], and decision always
null); shortlist (idea ids); flags ({id: "F1", kind,
text}, ...); and browser_status: ready_for_decision_owner,
revise_first or not_reviewable.
If you do not run the tools, you may send your own facts in this shape; the model
treats it as given. To compute it outside the page, run the skill's scripts on the exact files you
send: python3 scripts/validate_register.py register.json and
python3 scripts/evaluate_matrix.py scores.csv --config criteria.json.
The output
A finished run's output text (job.output.output) is one JSON object in
the lane's contract, every key present ([] where there is nothing to say). Parse it
yourself (step 7), and extract the outermost object defensively.
| lane | key | content |
|---|---|---|
| both | task | "draft" or "review": the lane the model answered. |
| both | headline | One sentence. |
| draft | register | The complete register object (schema 1.1); participant ids are pseudonymous and every idea comes from the notes. |
| draft | criteria | The criteria object (schema 1.0). |
| draft | scores_csv | The scoring sheet as CSV text; blank ratings when the notes give none. |
| draft | ratings_source | notes | blank. |
| draft | assumptions_made | [{field, value, why}]: every value the notes did not state. |
| draft | open_questions | Strings: what you must decide. |
| draft | changes | Strings: each change against a given register or criteria; [] unless revising. |
| review | status | ready_for_decision_owner | revise_first | not_reviewable; never looser than facts.browser_status. |
| review | flag_responses | [{ref, stance, note}], one per flag in facts.flags; stance is confirmed | dismissed | explained. |
| review | idea_reviews | [{idea_id, strongest_version, counter_observation, alternative_explanations (at least 2), measurement_failure, sampling_failure, harm_or_misuse, mitigation, residual_uncertainty, disposition, next_action}]; disposition is retain | revise | pause | stop | external-review; next_action is further-search | consultation | simulation | pilot-design | protocol-development | preregistration | no-action. |
| review | fragility | [{idea_id, note}]. |
| review | gates | [{idea_id, gate, status, note}]; gate is ethics | biosafety | dual-use | human-subjects | animal | data-governance | clinical | regulatory | feasibility; status is review-required | unclear | not-apparent. |
| review | criteria_issues, missing_perspectives, questions | Strings. |
| review | decision | Always null. |
| review | not_established | Strings: what nothing here establishes. |
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"brainstorm-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: register, criteria and facts must be JSON-encoded strings, not objects. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="brainstorm-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://brainstorm-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "brainstorm-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://brainstorm-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "brainstorm-desk";
// Paste the token from https://brainstorm-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "brainstorm-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://brainstorm-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class BrainstormDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "brainstorm-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "brainstorm-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://brainstorm-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "brainstorm-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class BrainstormDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "brainstorm-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"brainstorm-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://brainstorm-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"brainstorm-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "brainstorm-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "brainstorm-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"brainstorm-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"brainstorm-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "brainstorm-desk" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "brainstorm-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://brainstorm-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"brainstorm-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await BrainstormDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra.
hold_credits is a reservation, not the price: it is held against your
balance while the run executes and released afterwards. min_credits is the least balance
that can start a run. What you actually pay is charged_credits, reported on the finished
job and in the done event, and it is usually far lower than the hold. The body is the
input object itself, with no {"input": ...} wrapper. /estimate does
no input validation, so check the shape yourself: an object whose every value is a
string, task equal to draft or review, a draft with non-empty
notes (or a register to revise), and a review with criteria,
scores and a facts that is a JSON string parsing to an object.
# body.json is the input object itself - no {"input": ...} wrapper (see the
# worked examples above). estimate does not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("draft", "review")
assert all(isinstance(v, str) for v in b.values())
if b["task"] == "review":
assert b.get("criteria", "").strip() and b.get("scores", "").strip()
assert isinstance(json.loads(b.get("facts", "")), dict)
else:
assert b.get("notes", "").strip() or b.get("register", "").strip()
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.
INPUT = json.load(open("body.json")) # the input object, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("draft", "review")
assert all(isinstance(v, str) for v in INPUT.values())
if INPUT["task"] == "review":
assert INPUT.get("criteria", "").strip() and INPUT.get("scores", "").strip()
assert isinstance(json.loads(INPUT.get("facts", "")), dict) # facts is a JSON STRING
else:
assert INPUT.get("notes", "").strip() or INPUT.get("register", "").strip(), "a draft needs notes"
est = call("estimate", INPUT)
print(est["model_alias"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // the input object, no wrapper
if (!INPUT || typeof INPUT !== "object" || !["draft", "review"].includes(INPUT.task)) throw new Error("task must be draft or review");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
if (INPUT.task === "review") {
if (!(INPUT.criteria || "").trim() || !(INPUT.scores || "").trim()) throw new Error("a review needs criteria and scores");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
} else if (!(INPUT.notes || "").trim() && !(INPUT.register || "").trim()) throw new Error("a draft needs notes");
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // the input object, no wrapper
var input map[string]string // every field is a string
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "draft" && input["task"] != "review" {
panic("task must be draft or review")
}
if input["task"] == "review" {
if strings.TrimSpace(input["criteria"]) == "" || strings.TrimSpace(input["scores"]) == "" {
panic("a review needs criteria and scores")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
} else if strings.TrimSpace(input["notes"]) == "" && strings.TrimSpace(input["register"]) == "" {
panic("a draft needs notes")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // the input object, no wrapper
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(draft|review)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task draft or review");
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(draft|review)\".*", "$1");
if (lane.equals("review") && !(input.contains("\"criteria\"") && input.contains("\"scores\"") && input.contains("\"facts\"")))
throw new IllegalStateException("a review needs criteria, scores and facts");
if (lane.equals("draft") && !(input.contains("\"notes\"") || input.contains("\"register\"")))
throw new IllegalStateException("a draft needs notes");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # the input object, no wrapper
raise "task must be draft or review" unless %w[draft review].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
if INPUT["task"] == "review"
raise "a review needs criteria and scores" if INPUT["criteria"].to_s.strip.empty? || INPUT["scores"].to_s.strip.empty?
raise "facts must hold an object" unless JSON.parse(INPUT["facts"].to_s).is_a?(Hash)
elsif INPUT["notes"].to_s.strip.empty? && INPUT["register"].to_s.strip.empty?
raise "a draft needs notes"
end
est = call("estimate", INPUT)
puts est["model_alias"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // the input object, no wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["draft", "review"], true)) { throw new Exception("task must be draft or review"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
if ($input["task"] === "review") {
if (trim($input["criteria"] ?? "") === "" || trim($input["scores"] ?? "") === "") { throw new Exception("a review needs criteria and scores"); }
if (!is_array(json_decode($input["facts"] ?? "", true))) { throw new Exception("facts must be a JSON string"); }
} elseif (trim($input["notes"] ?? "") === "" && trim($input["register"] ?? "") === "") { throw new Exception("a draft needs notes"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // the input object, no wrapper
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "draft" && lane != "review") throw new Exception("task must be draft or review");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
if (lane == "review")
{
if (!root.TryGetProperty("criteria", out _) || !root.TryGetProperty("scores", out _))
throw new Exception("a review needs criteria and scores");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
}
else if (!root.TryGetProperty("notes", out _) && !root.TryGetProperty("register", out _))
throw new Exception("a draft needs notes");
var est = await BrainstormDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, hold_credits, min_credits
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, brainstorm-desk:<lane>:<hash>:a<attempt>, so a retried
request returns the same job instead of billing a second run. Use one key per distinct input: edited
notes, criteria, scores, facts or question are a new hash, and replaying an old key with a different
body is a 409. Any stable digest of the body works. Leave retry_note out of
the hash and bump the attempt instead.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # draft or review
KEY="brainstorm-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"task\":\"review\",\"status\":\"ready_for_decision_owner\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"brainstorm-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `brainstorm-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("brainstorm-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "brainstorm-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "brainstorm-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = INPUT.to_json
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "brainstorm-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"brainstorm-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await BrainstormDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"task\":\"review\",\"status\":\"ready_for_decision_owner\",\"headline\":\"The"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object serialised as a string. Parse it, check that task is the
lane you asked for, then use it: for a review, the status, the flag responses and the idea reviews;
for a draft, save the register, criteria and scoring sheet as files and run the skill's scripts on
them to get the facts for the review.
# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c 'import json;r=json.load(open("reply.json"));print(r["task"],r.get("status",""),r["headline"])'
# A draft: save the three files and compute the facts with the skill's own scripts.
python3 -c '
import json
r = json.load(open("reply.json"))
json.dump(r["register"], open("register.json", "w"), indent=2)
json.dump(r["criteria"], open("criteria.json", "w"), indent=2)
open("scores.csv", "w").write(r["scores_csv"])
'
python3 scripts/validate_register.py register.json
python3 scripts/evaluate_matrix.py scores.csv --config criteria.json
reply = json.loads(text)
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["headline"])
if reply["task"] == "review":
assert reply["decision"] is None
print(reply["status"])
for f in reply["flag_responses"]:
print(f["ref"], f["stance"], f["note"])
for r in reply["idea_reviews"]:
print(r["idea_id"], r["disposition"], r["next_action"])
else:
json.dump(reply["register"], open("register.json", "w"), indent=2)
json.dump(reply["criteria"], open("criteria.json", "w"), indent=2)
open("scores.csv", "w").write(reply["scores_csv"]) # blank ratings if ratings_source == "blank"
for a in reply["assumptions_made"]:
print(a["field"], "=", a["value"], "-", a["why"])
import { writeFileSync } from "node:fs";
const reply = JSON.parse(text);
if (reply.task !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.headline);
if (reply.task === "review") {
if (reply.decision !== null) throw new Error("decision must be null");
console.log(reply.status);
for (const f of reply.flag_responses) console.log(f.ref, f.stance, f.note);
for (const r of reply.idea_reviews) console.log(r.idea_id, r.disposition, r.next_action);
} else {
writeFileSync("register.json", JSON.stringify(reply.register, null, 2));
writeFileSync("criteria.json", JSON.stringify(reply.criteria, null, 2));
writeFileSync("scores.csv", reply.scores_csv); // then run the skill's scripts on them
for (const a of reply.assumptions_made) console.log(a.field, "=", a.value, "-", a.why);
}
var reply struct {
Task, Status, Headline string
Register json.RawMessage
Criteria json.RawMessage
ScoresCSV string `json:"scores_csv"`
FlagResponses []struct{ Ref, Stance, Note string } `json:"flag_responses"`
IdeaReviews []struct {
IdeaID string `json:"idea_id"`
Disposition string `json:"disposition"`
NextAction string `json:"next_action"`
} `json:"idea_reviews"`
}
if err := json.Unmarshal([]byte(jobOutput), &reply); err != nil {
panic(err)
}
if reply.Task != input["task"] {
panic("the model answered as another lane")
}
fmt.Println(reply.Headline, reply.Status)
for _, r := range reply.IdeaReviews {
fmt.Println(r.IdeaID, r.Disposition, r.NextAction)
}
if reply.Task == "draft" {
_ = os.WriteFile("register.json", reply.Register, 0o644)
_ = os.WriteFile("criteria.json", reply.Criteria, 0o644)
_ = os.WriteFile("scores.csv", []byte(reply.ScoresCSV), 0o644)
}
// With any JSON library (Jackson shown): the reply is a string that holds a JSON object.
JsonNode reply = new ObjectMapper().readTree(outputString);
if (!reply.get("task").asText().equals(lane)) throw new IllegalStateException("the model answered as another lane");
System.out.println(reply.get("headline").asText());
if (lane.equals("review")) {
System.out.println(reply.get("status").asText());
for (JsonNode r : reply.get("idea_reviews"))
System.out.println(r.get("idea_id").asText() + " " + r.get("disposition").asText() + " " + r.get("next_action").asText());
} else {
Files.writeString(Path.of("register.json"), reply.get("register").toPrettyString());
Files.writeString(Path.of("criteria.json"), reply.get("criteria").toPrettyString());
Files.writeString(Path.of("scores.csv"), reply.get("scores_csv").asText());
}
reply = JSON.parse(text)
raise "the model answered as another lane" unless reply["task"] == INPUT["task"]
puts reply["headline"]
if reply["task"] == "review"
puts reply["status"]
reply["idea_reviews"].each { |r| puts "#{r['idea_id']} #{r['disposition']} #{r['next_action']}" }
else
File.write("register.json", JSON.pretty_generate(reply["register"]))
File.write("criteria.json", JSON.pretty_generate(reply["criteria"]))
File.write("scores.csv", reply["scores_csv"])
end
<?php
$reply = json_decode($text, true);
if ($reply["task"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["headline"], "\n";
if ($reply["task"] === "review") {
echo $reply["status"], "\n";
foreach ($reply["idea_reviews"] as $r) { echo $r["idea_id"], " ", $r["disposition"], " ", $r["next_action"], "\n"; }
} else {
file_put_contents("register.json", json_encode($reply["register"], JSON_PRETTY_PRINT));
file_put_contents("criteria.json", json_encode($reply["criteria"], JSON_PRETTY_PRINT));
file_put_contents("scores.csv", $reply["scores_csv"]);
}
var reply = JsonSerializer.Deserialize<JsonElement>(output);
if (reply.GetProperty("task").GetString() != lane) throw new Exception("the model answered as another lane");
Console.WriteLine(reply.GetProperty("headline").GetString());
if (lane == "review")
{
Console.WriteLine(reply.GetProperty("status").GetString());
foreach (var r in reply.GetProperty("idea_reviews").EnumerateArray())
Console.WriteLine($"{r.GetProperty("idea_id")} {r.GetProperty("disposition")} {r.GetProperty("next_action")}");
}
else
{
File.WriteAllText("register.json", reply.GetProperty("register").GetRawText());
File.WriteAllText("criteria.json", reply.GetProperty("criteria").GetRawText());
File.WriteAllText("scores.csv", reply.GetProperty("scores_csv").GetString());
}
Costs
- The two skill tools are free and run in the page; nothing is metered until you start a lane.
/estimateis free. It creates no job and returnshold_credits: a reservation held against your balance while the run executes, not the price.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold, which is released afterwards. - Sponsorship is off (
sponsor_enabledis false): every run is paid from the caller's own balance. - Runs need a signed-in user token. Guests cannot run: a guest token can call
/meand/estimateonly; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key and its own charge.
Invariants worth asserting
- The reply is one JSON object whose
taskequals thetaskyou sent, with every key of that lane's contract present (empty arrays where there is nothing to say). - Review:
decisionisnull; every flag id infacts.flagsis answered inflag_responses, and nothing else; every shortlisted idea has one entry inidea_reviewswith at least twoalternative_explanations; the status is never looser thanfacts.browser_status(ready_for_decision_owner < revise_first < not_reviewable). - Every score, rank and interval the review cites exists in
facts, your sheet or your notes; the model computes no new numbers. - Draft: every idea in the register comes from the notes;
ratings_sourceisblankand the sheet's ratings are empty when the notes give none; participant ids are pseudonymous; the register passesvalidate_register.pyand the sheet runs underevaluate_matrix.py; a revision lists itschanges.