Drive Core-Hour Check from your own code
A review of a plan, not of a simulation. Nothing is run: there is no pilot, no output and no measured budget. The model reads the plan and what the skill's tools found; it never computes a new number, and it never calls a plan "converged", "validated" or "physically correct". A passing validator and a resource fit establish neither numerical convergence nor physical validity.
Core-Hour Check plans bounded fluidsim 0.9.0 computational-fluid-dynamics runs. The three planning
tools of the agent skill @k-dense-ai/fluidsim (k-dense-ai/scientific-agent-skills,
K-Dense Inc.) run free in your browser, as JavaScript ports with the same stdout and exit codes as
the Python: solver_config_validator.py (the plan against the skill's static fluidsim 0.9
profile: errors and warnings), grid_resource_estimator.py (a conservative memory,
storage and output-file envelope against the plan's declared bounds; never a runtime) and
simulation_dry_run.py (the opt-in launch script, its SHA-256 and the command that would
run it after approval).
Those free tools are not on the API - they run in the page (or on your machine,
from the skill's own scripts/). The API is the two metered lanes, on model
gpt-terra: draft turns a description of the study into one strict plan the
validator accepts, and review rates a plan against the skill's scientific acceptance
gate using the facts the three tools computed. The natural loop: draft, run the tools, review,
revise, re-check, then one tiny serial pilot.
Two lanes: the task field
task is the one required field and picks the lane. An unknown or missing value makes
the model pick the closest lane and report it in the reply's task.
| task | needs | returns |
|---|---|---|
draft | brief (a new plan), or plan + facts + notes to revise an existing plan (notes then carries the review to act on; a brief may come too) | One complete strict plan (schema 1.1, serial, fluidsim 0.9.0, fluidfft 0.4.5) sized as the first bounded step - a tiny serial pilot - with every value the brief did not state listed in assumptions, the physics still to decide in open_questions, and, for a revision, each change in changes. |
review | plan + facts | A verdict (ready_for_pilot, revise_first, not_plannable), a response to every browser flag, the twelve gate items rated addressed / thin / missing, concerns with fixes, what to inspect after the pilot, questions for you, and what nothing here establishes. |
Worked example: draft
The request the page builds from a description of a study (no plan yet, so no plan or
facts):
{
"task": "draft",
"brief": "Freely decaying two-dimensional turbulence in a doubly periodic square box of side 2*pi. Nondimensionalise lengths by L0 = box side / (2*pi) and velocities by U0 = the initial rms velocity, so times are in units of L0/U0. Start from seeded random noise with velo_max 0.5. Viscosity nu_2 = 1e-4 (Re = U0 L0 / nu = 10000). Observables: kinetic energy and enstrophy decay curves, and the energy spectrum at t = 10 and t = 20. I want a small pilot on my laptop first (8 cores, 16 GB RAM, 20 GB free disk, 30 minutes), then a production run at 1024x1024."
}
An abbreviated reply in the lane's contract. The real plan is the complete object
(every group the validator needs, all nine scientific statements); here parts are cut
and marked "...":
{
"task": "draft",
"headline": "A serial 128x128 doubly periodic ns2d pilot of freely decaying turbulence (seeded noise, nu_2 = 1e-4, t_end = 20), sized for the laptop budget ahead of the 1024x1024 study.",
"plan": {
"schema_version": "1.1",
"solver": "ns2d",
"parameters": {
"nu_2": 0.0001,
"oper": {"nx": 128, "ny": 128, "Lx": 6.283185307179586, "Ly": 6.283185307179586, "coef_dealiasing": 0.6666666666666666},
"time_stepping": {"USE_CFL": true, "USE_T_END": true, "cfl_coef": 0.5, "deltat0": 0.01, "deltat_max": 0.05, "max_elapsed": "00:25:00", "t_end": 20, "type_time_scheme": "RK4"},
"init_fields": {"type": "noise", "noise": {"length": 1.0, "velo_max": 0.5}},
"forcing": {"enable": false, "...": "..."},
"output": {"HAS_TO_SAVE": true, "ONLINE_PLOT_OK": false, "periods_save": {"phys_fields": 5, "spatial_means": 0.1, "spectra": 10, "...": "..."}, "...": "..."}
},
"scientific": {
"units_or_nondimensionalization": "Lengths by L0 = box side / (2*pi), velocities by U0 = the initial rms velocity, times by L0/U0.",
"boundary_conditions": "Doubly periodic square box of side 2*pi in x and y; no walls.",
"...": "seven more statements in the user's physics"
},
"resources": {"cpu_cores": 1, "mpi_ranks": 1, "threads_per_rank": 1, "ram_gib": 16, "disk_gib": 20, "wall_time_minutes": 30, "max_output_files": 200},
"execution": {"mode": "serial", "output_root": "fluidsim-runs", "random_seed": 12345, "script_name": "run_decay2d_pilot.py"},
"provenance": {"config_id": "replace-with-config-id", "created_utc": "replace-with-created-utc", "dependency_lock_sha256": "replace-with-uv-lock-sha256", "fluidfft": "0.4.5", "fluidsim": "0.9.0", "python": "3.11"}
},
"assumptions": [
{"field": "parameters.oper.nx", "value": "128", "why": "The brief gives no pilot grid; 128 fits a serial laptop run and leaves 1024x1024 for later."},
{"field": "parameters.init_fields.noise.length", "value": "1.0", "why": "The brief gives velo_max but not the noise length."},
{"field": "provenance.config_id", "value": "replace-with-config-id", "why": "Unknown provenance; fill before running."},
"... one entry per chosen value: grid, domain, viscosity, time bounds, save periods, resources"
],
"open_questions": [
"Which intermediate grids (for example 256x256 and 512x512) should precede the 1024x1024 run?"
],
"changes": []
}
Worked example: review
A real request the page builds for a 128x64 ns2d.strat pilot of freely evolving internal
gravity waves. The validator passes with no errors or warnings, the estimate fits every bound and
the browser raises no flags, so its verdict is ready_for_pilot. Below,
plan and facts are abbreviated with ... inside the strings;
a real call sends the whole plan text (3,439 characters here) and the whole facts string
(1,797 characters). Do not hand-write facts: let the page (or the fluidsim
skill's three tools, through the page's own deskkit.js - see
Building the body) produce it from the exact plan you send.
{
"task": "review",
"plan": "{\n \"schema_version\": \"1.1\",\n \"solver\": \"ns2d.strat\",\n \"parameters\": {\n \"nu_2\": 0.001,\n ... the whole plan: parameters, scientific, resources, execution, provenance ...\n \"python\": \"3.11\"\n }\n}",
"facts": "{\"page\":\"core-hour-check\",\"skill_tools\":\"solver_config_validator.py, grid_resource_estimator.py, simulation_dry_run.py (fluidsim skill, run in the browser)\",\"plan_sha256\":\"0f9e49ad...\",\"validator\":{\"exit\":0,\"ok\":true,\"errors\":[],\"warnings\":[],\"numerical_convergence_established\":false,\"physical_validity_established\":false},\"estimator\":{\"exit\":0,\"ok\":true,\"limits\":{\"files_within_bound\":true,\"memory_within_bound\":true,\"storage_within_bound\":true},\"estimates\":{\"peak_memory_gib\":0.005196,\"storage_gib\":0.005466,\"estimated_output_files\":13,...},\"grid_points\":8192,...},\"dryrun\":{\"exit\":0,\"script_name\":\"run_strat_pilot.py\",\"script_sha256\":\"3475f8d6...\",...,\"job_submitted\":false},\"derived\":{\"solver\":\"ns2d.strat\",\"dimensions\":2,\"grid\":[{\"axis\":\"x\",\"n\":128,\"L\":6.28319,\"dx\":0.04909,\"kmax_after_truncation\":42.67},{\"axis\":\"y\",\"n\":64,\"L\":3.14159,\"dx\":0.04909,\"kmax_after_truncation\":42.67}],\"min_steps\":200,...},\"flags\":[],\"browser_verdict\":\"ready_for_pilot\"}",
"notes": "Freely evolving internal gravity waves from noise; this pilot comes before a 256x128 run."
}
An abbreviated reply in the lane's contract (a real reply rates all twelve gate items, each once, in this order):
{
"task": "review",
"verdict": "ready_for_pilot",
"headline": "The plan passes the validator with wide resource margins; it can go to its one tiny serial pilot, but it names no benchmark to interpret results against.",
"flag_responses": [],
"gate": [
{"item": "units", "status": "addressed", "evidence": "Lengths scaled by Ly/pi and time by 1/N with N = 1", "note": "Tied to parameters.N and parameters.oper.Ly."},
{"item": "boundaries", "status": "addressed", "evidence": "Periodic in x and y", "note": "Matches the solver's periodic box."},
{"item": "initial_conditions", "status": "addressed", "evidence": "Seeded low-amplitude noise, velo_max=0.01, seed 12345", "note": "Matches parameters.init_fields.noise.velo_max and execution.random_seed."},
{"item": "forcing", "status": "addressed", "evidence": "Unforced", "note": "Matches parameters.forcing.enable = false."},
{"item": "resolution", "status": "addressed", "evidence": "128x64 pilot with cubic 2/3 truncation", "note": "facts.derived gives dx 0.04909 and kmax_after_truncation 42.67 on both axes."},
{"item": "timestep", "status": "addressed", "evidence": "Adaptive CFL 0.3 with deltat_max 0.01", "note": "facts.derived.min_steps 200 is a floor; the CFL may take smaller steps."},
{"item": "budgets", "status": "addressed", "evidence": "check the residual in spatial means", "note": "periods_save.spatial_means 0.01 is on."},
{"item": "refinement", "status": "addressed", "evidence": "Repeat at 256x128 and with half deltat_max", "note": "Concrete refinement targets."},
{"item": "benchmark", "status": "missing", "evidence": "", "note": "No analytical, manufactured, published or independent comparison is named."},
{"item": "acceptance", "status": "addressed", "evidence": "Energy residual trend below 1 percent", "note": "Quantified and tied to the refinement run."},
{"item": "resources", "status": "addressed", "evidence": "peak_memory_gib 0.005196 vs ram_gib 4.0", "note": "Storage 0.005466 GiB vs disk_gib 2.0 and 13 files vs 64 also fit."},
{"item": "provenance", "status": "addressed", "evidence": "config_id strat-pilot-2026-09-27", "note": "No placeholder ids; fluidsim 0.9.0 and fluidfft 0.4.5 pinned."}
],
"concerns": [
{"severity": "medium", "issue": "No benchmark comparison appears in any scientific statement.", "fix": "Name the comparison target in scientific.convergence_and_refinement before results are interpreted."}
],
"pilot_checks": [
"CFL and deltat history against deltat_max 0.01.",
"Energy budget residual from spatial_means, divergence, spectral tail near kmax 42.67, file count against 13, NaN/Inf."
],
"questions": ["Which benchmark should the 256x128 results be compared with?"],
"not_established": ["Numerical convergence.", "Physical validity.", "Any runtime: facts.estimator.runtime_estimated is false."]
}
Input fields
Every field is a string - plan and facts included, each a JSON text, not
an object. This matches the app's declared input schema.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "draft" or "review". |
plan | string | no (review: yes) | The plan as strict JSON text, exactly as the tools read it. Review: always. Draft: only when revising an existing plan. The page sends up to 40,000 characters and never cuts a plan. |
facts | string | no (with every plan) | A JSON-encoded string of what the three tools found on that plan - see below. The page builds it with DeskKit.buildInput; it goes with every plan. |
brief | string | no (draft: yes, unless revising) | Draft only: your description of the study, in your words (the page sends up to 6,000 characters, cut on a sentence and marked as cut). |
notes | string | no | Your context, or, for a revision draft, the review to act on (up to 6,000 characters). |
question | string | no | Something you want answered inside the lane's fields (up to 2,000 characters). |
retry_note | string | no | Sent by the page only on its one reformat retry after a reply that did not parse. |
The facts string
facts is a JSON string, not an object. It holds page,
skill_tools and plan_sha256; validator (exit,
ok, errors, warnings, and
numerical_convergence_established and physical_validity_established, both
always false - or crash / failure when the tool stopped);
estimator (exit, ok, limits with
memory_within_bound, storage_within_bound and
files_within_bound, estimates with peak_memory_gib,
idealized_peak_bytes_per_rank, storage_gib,
estimated_output_files, state_snapshots and
resident_state_bytes, grid_points, declared_bounds,
assumptions and runtime_estimated false - or crash /
failure); dryrun (exit, script_name,
script_sha256, execute_argv_after_approval,
mpi_launch_preview_only, job_submitted false - or crash /
failure); derived (solver, dimensions,
grid with dx and kmax_after_truncation = coef_dealiasing x pi
/ dx per axis, min_steps = t_end / deltat_max rounded up, forcing,
output_records); flags (F1.. with kind - for
example validator_error, validator_warning,
estimator_memory, estimator_storage, estimator_files,
tool_crash, forbidden_label, placeholder_provenance - and
text); and browser_verdict.
Produce it with the page, not by hand: every number in it is one the reconciler checks the reply
against, and a facts that does not belong to the exact plan you send
makes the review meaningless. The page computes it in the browser from the plan in its editor.
Building the body
To build a body that matches the page byte for byte, run the page's own modules in Node 18 or later:
save fluidkit.js (the port of the three tools) and deskkit.js next to the
script below. Both export themselves with module.exports, and deskkit.js
loads ./fluidkit.js itself.
// make-body.js - build the exact body the page sends, with the page's own code.
// node make-body.js review plan.json "notes" "question" > body.json
// node make-body.js draft brief.txt "notes" "question" > body.json
// node make-body.js draft brief.txt "review text" "" plan.json > body.json (revise plan.json)
const fs = require("fs");
const K = require("./deskkit.js"); // runs fluidkit.js: validator, estimator, dry run
const [lane, file, notes = "", question = "", revisePlan = ""] = process.argv.slice(2);
const read = (p) => (p ? fs.readFileSync(p, "utf8") : "");
let A = null;
const set = { notes, question };
if (lane === "review") {
A = K.analyze(read(file), { name: "config.json" });
} else {
set.brief = read(file);
if (revisePlan) { A = K.analyze(read(revisePlan), { name: "config.json" }); set.revise = true; }
}
const body = K.buildInput(lane, A, set);
if (!body) throw new Error("nothing to send: review needs a plan, draft a brief or a plan (plans up to 40,000 characters)");
K.mustBeObject(body);
if (A) console.error("browser verdict:", A.verdict, "| flags:", A.flags.map((f) => f.id + " " + f.kind).join(", ") || "none");
console.error("idempotency key: core-hour-check:" + body.task + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
# Or build the body in any language. Only a draft from a brief needs no facts;
# every plan must travel with the facts the page computed for it, as a STRING.
import json
body = {
"task": "draft",
"brief": open("brief.txt").read(),
"notes": "Laptop first; production later.",
}
json.dump(body, open("body.json", "w"))
The output
A finished run's output text is one JSON object in the lane's contract, every key
present ([] where there is nothing to say). Parse it yourself (step 7). The model is told
to send no prose and no code fences; still, extract the outermost object defensively.
| lane | key | content |
|---|---|---|
| both | task | "draft" or "review": the lane the model answered. |
| draft | headline | One sentence: what the plan sets up. |
| draft | plan | The complete plan object (not a string): schema_version "1.1", solver, parameters, scientific, resources, execution (mode serial), provenance (fluidsim "0.9.0", fluidfft "0.4.5", python "3.11"; unknown ids start with replace-with-). |
| draft | assumptions | [{field, value, why}]: every value the brief did not state, field a dotted path such as parameters.oper.nx. |
| draft | open_questions | Strings: what you must decide (physics the brief does not give). |
| draft | changes | Strings: each change against a given plan; [] unless revising. |
| review | verdict | ready_for_pilot | revise_first | not_plannable. |
| review | headline | One sentence. |
| review | flag_responses | [{ref, stance, note}], one per flag in facts.flags; stance is confirmed | dismissed | explained. |
| review | gate | [{item, status, evidence, note}], the twelve items once each in this order: units, boundaries, initial_conditions, forcing, resolution, timestep, budgets, refinement, benchmark, acceptance, resources, provenance; status is addressed | thin | missing. |
| review | concerns | [{severity, issue, fix}]; severity is high | medium | low. |
| review | pilot_checks | Strings: what to inspect after the tiny serial pilot (CFL and deltat history, divergence, budgets, spectral tails, output growth, NaN/Inf). |
| review | questions | Strings: what you must decide or supply (always includes a missing benchmark or refinement). |
| review | not_established | Strings: always includes numerical convergence and physical validity. |
The page does more than parse: its recon.js reconciles every reply against the input.
For a review it checks that every flag is answered (and nothing else), that the twelve gate items are
each rated once, that the verdict is not looser than facts.browser_verdict (unless every
flag behind it was dismissed) and never ready_for_pilot with a blocking item
missing, that no forbidden label (DNS, converged, validated, steady, physically correct)
is asserted, and that every number is grounded in the plan, the facts or your notes. For a draft it
re-runs the skill's validator, estimator and dry run on the returned plan, and checks that every
assumption names a field that is in the plan and, for a revision, that changes is not
empty. Over the API you get the raw reply; run the same checks, or load recon.js in Node
(Recon.reconcile(Recon.normalize(Recon.parseResult(text), task), {input: body})).
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":"core-hour-check"} 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: facts must be a JSON-encoded string, not an object. |
| 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="core-hour-check"
TOKEN="$SKILLSAFE_TOKEN" # from https://core-hour-check.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 = "core-hour-check"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://core-hour-check.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 = "core-hour-check";
// Paste the token from https://core-hour-check.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 = "core-hour-check"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://core-hour-check.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 CoreHourCheck {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "core-hour-check";
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 = "core-hour-check"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://core-hour-check.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 = "core-hour-check";
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 CoreHourCheck
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "core-hour-check";
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":"core-hour-check"}, 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://core-hour-check.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":"core-hour-check"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://core-hour-check.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": "core-hour-check"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://core-hour-check.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: "core-hour-check" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://core-hour-check.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":"core-hour-check"}`)))
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://core-hour-check.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\":\"core-hour-check\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://core-hour-check.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: "core-hour-check" }.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://core-hour-check.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" => "core-hour-check"]));
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://core-hour-check.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\":\"core-hour-check\"}", 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 CoreHourCheck.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 and
markup_bps 1000 (a 10% markup). 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 not validate the
body, so check the shape yourself: an object whose every value is a string, task equal
draft or review, a review with a non-empty plan and
facts, a draft with a non-empty brief or a plan, and every
plan travelling with a facts that is a JSON string parsing to an object
(the page's own guard, DeskKit.mustBeObject, refuses to spend on a non-object body).
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. 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" or b.get("plan"):
assert b.get("plan", "").strip() and isinstance(json.loads(b.get("facts", "")), dict)
else:
assert b.get("brief", "").strip()
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"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")) # built by make-body.js above, or by hand
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" or INPUT.get("plan"):
assert INPUT.get("plan", "").strip(), "a review needs the plan text"
assert isinstance(json.loads(INPUT.get("facts", "")), dict) # facts is a JSON STRING
else:
assert INPUT.get("brief", "").strip(), "a draft needs a brief or a plan"
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], 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")); // built by make-body.js above
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" || INPUT.plan) {
if (!(INPUT.plan || "").trim()) throw new Error("a review needs the plan text");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
} else if (!(INPUT.brief || "").trim()) throw new Error("a draft needs a brief or a plan");
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, 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") // built by make-body.js above
var input map[string]string // every field is a string, plan and facts included
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" || input["plan"] != "" {
if strings.TrimSpace(input["plan"]) == "" {
panic("a review needs the plan text")
}
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["brief"]) == "" {
panic("a draft needs a brief or a plan")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
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("\"plan\"") && input.contains("\"facts\"")))
throw new IllegalStateException("a review needs plan and facts");
if (lane.equals("draft") && !(input.contains("\"brief\"") || input.contains("\"plan\"")))
throw new IllegalStateException("a draft needs a brief or a plan");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
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" || INPUT["plan"]
raise "a review needs the plan text" if INPUT["plan"].to_s.strip.empty?
raise "facts must hold an object" unless JSON.parse(INPUT["facts"].to_s).is_a?(Hash)
elsif INPUT["brief"].to_s.strip.empty?
raise "a draft needs a brief or a plan"
end
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
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" || isset($input["plan"])) {
if (trim($input["plan"] ?? "") === "") { throw new Exception("a review needs the plan text"); }
if (!is_array(json_decode($input["facts"] ?? "", true))) { throw new Exception("facts must be a JSON string"); }
} elseif (trim($input["brief"] ?? "") === "") { throw new Exception("a draft needs a brief or a plan"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
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" || root.TryGetProperty("plan", out _))
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
else if (string.IsNullOrWhiteSpace(root.GetProperty("brief").GetString()))
throw new Exception("a draft needs a brief or a plan");
var est = await CoreHourCheck.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, 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, core-hour-check:<lane>:<hash>:a<attempt> (for example
core-hour-check:review:112ce8u0i1o:a1 for the review example above), so a retried request
returns the same job instead of billing a second run. Use one key per distinct input: an edited plan
(so changed facts), brief, notes or question is a new hash, the same plan in the other lane is a new
key, and replaying an old key with a different body is a 409. The page uses
DeskKit.hashInput(body) for the hash (a digest of the body with its keys sorted, covering
task, plan, facts, brief, notes and
question; make-body.js prints the key); any stable digest of the body
works from other languages. 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="core-hour-check:$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\",\"verdict\":\"ready_for_pilot\",\"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"core-hour-check:{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 = `core-hour-check:${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("core-hour-check:%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 = "core-hour-check:" + 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 = "core-hour-check:#{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 = "core-hour-check:" . $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 = $"core-hour-check:{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 CoreHourCheck.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\",\"verdict\":\"ready_for_pilot\",\"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 verdict and the gate; for a draft, save
plan as a file and run the skill's tools on it again (the page does this for you).
# The reply is a JSON string inside data.output.output. Pull it out and parse it:
printf '%s' "$OUT" | python3 -c 'import sys,json;r=json.loads(json.load(sys.stdin)["data"]["output"]["output"]);print(r["task"],r.get("verdict",""),r["headline"])'
# A draft: save the plan and re-check it with the skill's own tools.
python3 -c 'import json;r=json.load(open("reply.json"));json.dump(r["plan"],open("config.json","w"),indent=2)'
python3 scripts/solver_config_validator.py --config config.json
reply = json.loads(text)
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["headline"])
if reply["task"] == "review":
print(reply["verdict"])
for g in reply["gate"]: # twelve items, in order
print(g["item"], g["status"], g["note"])
for f in reply["flag_responses"]:
print(f["ref"], f["stance"], f["note"])
else:
json.dump(reply["plan"], open("config.json", "w"), indent=2) # then run the skill's validator on it
for a in reply["assumptions"]:
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") {
console.log(reply.verdict);
for (const g of reply.gate) console.log(g.item, g.status, g.note);
for (const f of reply.flag_responses) console.log(f.ref, f.stance, f.note);
} else {
writeFileSync("config.json", JSON.stringify(reply.plan, null, 2)); // then re-run the skill's tools
for (const a of reply.assumptions) console.log(a.field, "=", a.value, "-", a.why);
}
var reply struct {
Task, Verdict, Headline string
Plan json.RawMessage
Gate []struct{ Item, Status, Evidence, Note string }
FlagResponses []struct{ Ref, Stance, Note string } `json:"flag_responses"`
}
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.Verdict)
for _, g := range reply.Gate {
fmt.Println(g.Item, g.Status, g.Note)
}
if reply.Task == "draft" {
_ = os.WriteFile("config.json", reply.Plan, 0o644) // then re-run the skill's tools on it
}
// 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("verdict").asText());
for (JsonNode g : reply.get("gate")) System.out.println(g.get("item").asText() + " " + g.get("status").asText());
} else {
Files.writeString(Path.of("config.json"), reply.get("plan").toPrettyString()); // then re-run the skill's tools
}
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["verdict"]
reply["gate"].each { |g| puts "#{g['item']} #{g['status']} #{g['note']}" }
else
File.write("config.json", JSON.dump(reply["plan"])) # then re-run the skill's tools
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["verdict"], "\n";
foreach ($reply["gate"] as $g) { echo $g["item"], " ", $g["status"], " ", $g["note"], "\n"; }
} else {
file_put_contents("config.json", json_encode($reply["plan"], JSON_PRETTY_PRINT)); // then re-run the skill's tools
}
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("verdict").GetString());
foreach (var g in reply.GetProperty("gate").EnumerateArray())
Console.WriteLine($"{g.GetProperty("item")} {g.GetProperty("status")} {g.GetProperty("note")}");
}
else
{
File.WriteAllText("config.json", reply.GetProperty("plan").GetRawText()); // then re-run the skill's tools
}
Costs
- The three 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. A guest token can call
/meand/estimateonly; get 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: every flag id in
facts.flagsis answered inflag_responses, and nothing else; the twelve gate items are rated once each; the verdict is never looser thanfacts.browser_verdict(ready_for_pilot < revise_first < not_plannable) unless every flag behind it was dismissed, and is neverready_for_pilotwith any item other thanbenchmarkorrefinementratedmissing; amissingbenchmark or refinement appears inquestions;not_establishednames numerical convergence and physical validity. - Every number in the review's prose and evidence exists in the plan, the facts, your notes or your question. No sentence asserts DNS, converged, validated, steady or physically correct (quoting the plan's own words as evidence is allowed).
- Draft: the returned
planpassessolver_config_validator.py(exit 0), fits its own declared RAM, disk and file bounds undergrid_resource_estimator.py, and renders a dry-run script; everyassumptions[].fieldis a path that exists in the plan; placeholders start withreplace-with-and must be filled before the pilot; a revision lists itschangesand differs from the plan you sent. - Nothing in either lane launches anything. The path stays: validate, estimate, dry run, one tiny serial pilot, then refinement - never MPI, a scheduler job or a restart with modified parameters from here.