Drive Forty-Five from your own code
The one metered step - the printability check of one part - is available over HTTP. A natural use is a print queue or a parts library: your code measures the STL with the page's own script, sends the measurements, and gets back the way to lay the part down, the findings with a fix each, whether and where supports go, the slicer settings to change, redesign moves when no side works, and a print as is / reorient first / redesign verdict.
The division of labour first: the page measures; the model judges; the page holds the judgement to its measurements. Reading the STL, welding the mesh, the open-edge and body counts, and for each of the six flat ways down the height, footprint and bed fit, bed contact, area past the overhang limit and flat ceilings all come from meshkit.js, which runs in node as well as in the browser. The mesh itself is never sent. Its check function then holds the reply to the numbers: the orientation must be one of the six and fit the bed, a "no supports" answer on a way that leans past the limit is flagged, and a verdict that ignores a must-fix flag is called out.
Get a token on the token page (step 1 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"forty-five"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the check is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Every run sends task. There is one task, check. It returns title, verdict, orientation, summary; findings [{level, area, text, fix}] (2-6); supports {needed, style, where}; settings [{name, value, why}] (2-6); redesign (2-5 model changes, empty unless the verdict is redesign); before_print (0-3 checks).
| Verdict | Meaning |
|---|---|
print | As exported is fine: orientation is Z-. |
reorient | Another way down is clearly better and no model change is needed: orientation is not Z-. |
redesign | No way down prints it well, or the mesh or its size must change first (open edges, too big, thinner than two lines, no good side). The reply still picks the least-bad way and lists redesign moves. |
| Field | Values | What the page does with it |
|---|---|---|
orientation | Z- (as exported), Z+ (upside down), X-, X+, Y-, Y+ (on that side) | Marked as the check's pick on the chips, the radar chart and the table, and drawn on the plate. An id that is not one of the six, or a way that does not fit, gets a note. |
findings[].level | fix, warn, tip | Shown as must fix / worth a look / tip. A stated wall thickness is flagged: the page does not measure walls. |
findings[].area | mesh, size, overhang, bed, strength, detail, fit, settings | Shown as a tag. |
supports.style | none, tree, normal, paint-on | "Needed: false" on a way with 5% or more past the limit, or a real flat ceiling, gets a warning. |
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (MeshKit.toWire does this). Only task is required by the schema.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | Always check. |
today | string | YYYY-MM-DD. |
part | string | The part's name. |
facts | string (JSON) | {part, file, units, purpose, material, nozzle_mm, layer_mm, overhang_limit_deg, bed_mm, size_mm, triangles, watertight, open_edges, non_manifold_edges, bodies, inverted, surface_mm2, volume_cm3, grams_estimate, as_exported, page_best}. purpose is functional, fit, display or outdoor; grams_estimate is rough (45% of the solid volume). |
orientations | string (JSON) | Six rows [{id, way, height_mm, footprint_mm, fits, turned_deg, bed_contact_mm2, contact_pct, overhang_mm2, overhang_pct, ceiling_mm2, base_width_mm, score}]. |
page_reads | string (JSON) | [{level, code, at, text}]; level is fix, warn or info; at is an orientation id or empty. |
page_verdict | string | The page's own reading: print, reorient or redesign. |
notes | string | Optional, the user's own words; past 1,200 characters the middle is cut. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with MeshKit.parseReply, then normalize, then check it with check(reply, state), which returns {pick, picked, notes}: the measured way down the reply chose (or the page's best when it named none), whether the reply named a valid one, and the notes (ok, info, warn, fix) the page shows under "Checked by the page".
Worked examples
Replies produced against the app's prompt for the sample parts (made-up parts built from boxes on a grid; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.
task: check - Cable clip, sits flat - print as is
Input:
{
"task": "check",
"today": "2026-10-20",
"part": "Desk cable clip",
"facts": "{\"part\":\"Desk cable clip\",\"file\":\"cable-clip.stl\",\"units\":\"mm\",\"purpose\":\"functional\",\"material\":\"PLA\",\"nozzle_mm\":0.4,\"layer_mm\":0.2,\"overhang_limit_deg\":45,\"bed_mm\":[256,256,256],\"size_mm\":[24,10,14],\"triangles\":2992,\"watertight\":true,\"open_edges\":0,\"non_manifold_edges\":0,\"bodies\":1,\"inverted\":false,\"surface_mm2\":1496,\"volume_cm3\":2.9,\"grams_estimate\":2,\"as_exported\":\"Z-\",\"page_best\":\"Z-\"}",
"orientations": "[{\"id\":\"Z-\",\"way\":\"As exported - on its bottom (-Z) face\",\"height_mm\":14,\"footprint_mm\":[24,10],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":240,\"contact_pct\":100,\"overhang_mm2\":0,\"overhang_pct\":0,\"ceiling_mm2\":0,\"base_width_mm\":10,\"score\":96},{\"id\":\"Z+\",\"way\":\"Upside down - on its top (+Z) face\",\"height_mm\":14,\"footprint_mm\":[24,10],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":180,\"contact_pct\":75,\"overhang_mm2\":60,\"overhang_pct\":4,\"ceiling_mm2\":60,\"base_width_mm\":10,\"score\":86},\"... 4 more\"]",
"page_reads": "[]",
"page_verdict": "print",
"notes": "Holds a 6 mm charging cable under the edge of a desk. Stuck on with double-sided tape on the flat base."
}
Reply:
{
"task": "check",
"title": "Desk cable clip printability check",
"verdict": "print",
"orientation": "Z-",
"summary": "Print it as exported, flat base down (Z-) - the page's own best pick too. In that position the whole 240 mm2 base touches the bed (100% contact) with 0% of the surface past the 45 degree limit and no flat ceiling, so it needs no supports. Laying it on its side (Y-/Y+) scores a touch higher (100 vs 96), but only 86% of that face would touch the bed, and the notes say the flat base is what gets taped to the desk, so staying as exported is the better match.",
"findings": [
{
"level": "tip",
"area": "fit",
"text": "The page doesn't measure clearances, so it can't tell you how snugly the 6 mm cable will actually sit once this is printed - plastic shrinks slightly as it cools.",
"fix": "Test-fit the real cable as soon as it's printed; if it's tighter than expected, warm it slightly or sand the contact points rather than guessing a new size."
},
{
"level": "tip",
"area": "strength",
"text": "This is a functional part that grips a cable, not just a display piece, and layers split apart more easily than the plastic bends.",
"fix": "If it needs to flex at all to let the cable in and out, add a couple of extra wall loops in that area so it holds up to repeated use instead of relying on thin infill."
},
{
"level": "tip",
"area": "fit",
"text": "The notes say the flat base gets stuck to the desk with tape - that's the face sitting on the bed in this orientation, so it will print smooth and fully flat already (100% of the footprint touches the bed).",
"fix": "Scrape off any stray first-layer blobs or a brim (if one was used) from that face before sticking the tape on."
}
],
"supports": {
"needed": false,
"style": "none",
"where": "Not needed in this orientation - 0% of the surface overhangs past the limit and there is no flat ceiling."
},
"settings": [
{
"name": "supports",
"value": "off",
"why": "Nothing overhangs past 45 degrees and there's no flat ceiling in this orientation, so supports would only add cleanup."
},
{
"name": "wall loops",
"value": "4 (up from a typical 2-3)",
"why": "It's a small functional part that may flex where it grips the cable, and extra loops resist cracking along a layer line better than infill alone."
},
{
"name": "infill",
"value": "20-25%",
"why": "A bit more than a display piece needs, without adding much material - the whole part is only about 2 g."
},
"..."
],
"redesign": [],
"before_print": [
"Test-fit the actual 6 mm cable before relying on this as final - a snug fit on screen can print a touch tighter.",
"Wipe down the printed base face (the one that was against the bed) before sticking on the tape, so the adhesive grips clean plastic."
]
}
task: check - Wall hook, exported standing - reorient
Input:
{
"task": "check",
"today": "2026-10-20",
"part": "Helmet wall hook",
"facts": "{\"part\":\"Helmet wall hook\",\"file\":\"helmet-hook.stl\",\"units\":\"mm\",\"purpose\":\"functional\",\"material\":\"PETG\",\"nozzle_mm\":0.4,\"layer_mm\":0.2,\"overhang_limit_deg\":45,\"bed_mm\":[220,220,250],\"size_mm\":[40,12,60],\"triangles\":1936,\"watertight\":true,\"open_edges\":0,\"non_manifold_edges\":0,\"bodies\":1,\"inverted\":false,\"surface_mm2\":3872,\"volume_cm3\":8.8,\"grams_estimate\":5,\"as_exported\":\"Z-\",\"page_best\":\"Y-\"}",
"orientations": "[{\"id\":\"Z-\",\"way\":\"As exported - on its bottom (-Z) face\",\"height_mm\":60,\"footprint_mm\":[40,12],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":96,\"contact_pct\":20,\"overhang_mm2\":384,\"overhang_pct\":9.9,\"ceiling_mm2\":384,\"base_width_mm\":12,\"score\":44},{\"id\":\"Z+\",\"way\":\"Upside down - on its top (+Z) face\",\"height_mm\":60,\"footprint_mm\":[40,12],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":480,\"contact_pct\":100,\"overhang_mm2\":0,\"overhang_pct\":0,\"ceiling_mm2\":0,\"base_width_mm\":12,\"score\":79},\"... 4 more\"]",
"page_reads": "[{\"level\":\"warn\",\"code\":\"asis-overhang\",\"at\":\"Z-\",\"text\":\"As exported, 10% of the surface (384 mm2) leans past 45 degrees - it needs supports there.\"},{\"level\":\"warn\",\"code\":\"asis-ceiling\",\"at\":\"Z-\",\"text\":\"As exported, 384 mm2 of flat ceiling is printed in mid-air - a bridge if it is short, supports if it is not.\"},\"... 3 more\"]",
"page_verdict": "reorient",
"notes": "Screwed to the wall through the post. A bike helmet hangs from the end of the arm."
}
Reply:
{
"task": "check",
"title": "Reorient the helmet hook flat",
"verdict": "reorient",
"orientation": "Y-",
"summary": "Reorient: as exported it stands 60 mm tall on a 12 mm wide base, with 9.9% of the surface (384 mm2) overhanging past 45 degrees, 384 mm2 of flat ceiling under the arm, and only 96 mm2 (20%) touching the bed. Laid on its -Y side instead, none of that applies - 0% overhang, 0 mm2 ceiling, and 736 mm2 of bed contact - and it also puts the layer lines along the length of the post and arm, the strong direction for the pull of the hanging helmet. Lay it flat on its -Y side; no supports needed that way.",
"findings": [
{
"level": "warn",
"area": "overhang",
"text": "As exported it stands 60 mm tall on a 12 mm base, with 9.9% of the surface (384 mm2) leaning past 45 degrees, 384 mm2 of flat ceiling under the arm, and only 96 mm2 (20%) touching the bed. Laid on its -Y side all of that clears: 0% overhang, 0 mm2 ceiling and 736 mm2 of contact.",
"fix": "Print it laid on its -Y side instead of as exported - that way needs no supports."
},
{
"level": "warn",
"area": "strength",
"text": "The helmet's weight pulls on the end of the arm, and the post carries that pull into the wall screws. Layers split apart more easily than they stretch, so printed standing up the post would take that pull mostly across its layers; laid flat on -Y the layer lines instead run the length of both the post and the arm.",
"fix": "Keep it on its -Y side rather than standing it up, and add a couple of extra wall loops around where the arm meets the post."
},
{
"level": "tip",
"area": "fit",
"text": "The page doesn't measure hole sizes, so it can't tell whether the screw hole through the post fits your wall screw.",
"fix": "Test the screw in the hole before mounting it for good, and open the hole up a touch if it's tight."
}
],
"supports": {
"needed": false,
"style": "none",
"where": "Not needed laid on -Y - nothing overhangs and no flat ceiling is left in mid-air."
},
"settings": [
{
"name": "wall loops",
"value": "4",
"why": "extra perimeters strengthen the post-to-arm joint that carries the hanging load."
},
{
"name": "infill density",
"value": "30%",
"why": "a denser fill adds strength for a load-bearing part, and at 5 g it barely adds plastic."
},
{
"name": "cooling fan",
"value": "lower, around 30%",
"why": "PETG bonds layers better with less cooling, which matters now that the layers run along the load path."
}
],
"redesign": [],
"before_print": [
"Clean the bed well before printing - PETG grips hard once stuck, so first-layer adhesion matters.",
"Dry the PETG filament first if it's been open a while; damp filament bonds layers weaker, which you don't want on a part holding weight.",
"..."
]
}
task: check - Six-arm jack, no good side - redesign
Input:
{
"task": "check",
"today": "2026-10-20",
"part": "Giant game jack",
"facts": "{\"part\":\"Giant game jack\",\"file\":\"giant-jack.stl\",\"units\":\"mm\",\"purpose\":\"display\",\"material\":\"PLA\",\"nozzle_mm\":0.4,\"layer_mm\":0.2,\"overhang_limit_deg\":45,\"bed_mm\":[220,220,250],\"size_mm\":[64,64,64],\"triangles\":5376,\"watertight\":true,\"open_edges\":0,\"non_manifold_edges\":0,\"bodies\":1,\"inverted\":false,\"surface_mm2\":10752,\"volume_cm3\":41,\"grams_estimate\":23,\"as_exported\":\"Z-\",\"page_best\":\"Z-\"}",
"orientations": "[{\"id\":\"Z-\",\"way\":\"As exported - on its bottom (-Z) face\",\"height_mm\":64,\"footprint_mm\":[64,64],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":256,\"contact_pct\":6,\"overhang_mm2\":1536,\"overhang_pct\":14.3,\"ceiling_mm2\":1536,\"base_width_mm\":56.6,\"score\":53},{\"id\":\"Z+\",\"way\":\"Upside down - on its top (+Z) face\",\"height_mm\":64,\"footprint_mm\":[64,64],\"fits\":true,\"turned_deg\":0,\"bed_contact_mm2\":256,\"contact_pct\":6,\"overhang_mm2\":1536,\"overhang_pct\":14.3,\"ceiling_mm2\":1536,\"base_width_mm\":56.6,\"score\":53},\"... 4 more\"]",
"page_reads": "[{\"level\":\"warn\",\"code\":\"asis-overhang\",\"at\":\"Z-\",\"text\":\"As exported, 14% of the surface (1,536 mm2) leans past 45 degrees - it needs supports there.\"},{\"level\":\"warn\",\"code\":\"asis-ceiling\",\"at\":\"Z-\",\"text\":\"As exported, 1,536 mm2 of flat ceiling is printed in mid-air - a bridge if it is short, supports if it is not.\"},\"... 2 more\"]",
"page_verdict": "redesign",
"notes": "A giant jack for a kids' party game. It gets thrown around, so the arms should not snap off."
}
Reply:
{
"task": "check",
"title": "This jack needs a redesign",
"verdict": "redesign",
"orientation": "Z-",
"summary": "This is a redesign, not just a reorientation: all six ways down measure exactly the same, and every one still leaves 14.3% of the surface (1,536 mm2) past the 45-degree limit, with that same 1,536 mm2 sitting as flat ceiling in mid-air - so no side prints clean. Keep it as exported (Z-) since no way beats another; it will need supports and only 256 mm2 (6%) touches the bed, so add a brim. Your notes say this gets thrown around, so I added strength checks and settings for that even though the page's own measurements don't speak to it.",
"findings": [
{
"level": "fix",
"area": "overhang",
"text": "Every one of the six ways it can lie still leaves at least 14% of the surface (1,536 mm2) past the 45-degree limit or sitting as flat ceiling - there is no side that avoids this.",
"fix": "Plan on supports no matter which way you print it, and use the redesign moves below to cut the overhang itself."
},
{
"level": "warn",
"area": "overhang",
"text": "Laid as exported (Z-), 1,536 mm2 (14.3% of the surface) leans past the 45-degree overhang limit.",
"fix": "Turn on supports under that area before slicing."
},
{
"level": "warn",
"area": "overhang",
"text": "The page also calls out 1,536 mm2 of that same area as flat ceiling printed in mid-air, not just sloped, which some slicers treat separately from a sloped overhang.",
"fix": "If any span is short, let it bridge; otherwise make sure supports cover it too rather than counting on bridging alone."
},
"..."
],
"supports": {
"needed": true,
"style": "normal",
"where": "under the arms that are not resting on the bed - the 1,536 mm2 of flat ceiling, 14% of the whole part"
},
"settings": [
{
"name": "supports",
"value": "on",
"why": "every orientation leaves 14% of the surface overhanging or as flat ceiling - nothing prints clean without them."
},
{
"name": "support style",
"value": "normal",
"why": "the 1,536 mm2 that needs holding up is flat ceiling, which a regular support grid holds evenly."
},
{
"name": "brim width",
"value": "add a brim",
"why": "only 6% of the footprint (256 mm2) touches the bed, so the print needs extra edge grip to stay put."
},
"..."
],
"redesign": [
"Chamfer or round the underside of the arms so no face leans past the 45-degree limit - that removes both the overhang and the flat ceiling every orientation currently shows.",
"Add a fillet where each arm meets the body - the notes say it gets thrown, and that joint is the likely snap point.",
"..."
],
"before_print": [
"Watch the first layer closely - only 256 mm2 (6% of its footprint) touches the bed, so it needs to grip well before it builds height.",
"After the supports come off, flex or gently test the arm joints before handing it over, since this one is meant to take impacts."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./meshkit.js")), then:
const K = MeshKit;
const fs = require("fs");
const tris = K.parseSTL(fs.readFileSync("bracket.stl")).tris; // binary or ASCII
const M = K.measure(tris); // mesh facts + six ways down, settings-free
const st = K.state({
today: "2026-10-20", part: "Shelf bracket", file: "bracket.stl", units: "mm", purpose: "functional",
material: "petg", nozzle: "0.4", layer: "0.2", limit: "45", bedX: "256", bedY: "256", bedZ: "256", notes: ""
}, M);
console.log(st.orients.map(o => [o.id, o.score]), st.reads, st.verdict);
const { input } = K.buildInput(st, { today: "2026-10-20" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { pick, notes } = K.check(reply, st);
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://forty-five.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://forty-five.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// forty-five.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://forty-five.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://forty-five.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// FortyFive.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class FortyFive {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://forty-five.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://forty-five.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://forty-five.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://forty-five.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"forty-five"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "forty-five"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "forty-five" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "forty-five"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "forty-five"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "forty-five" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "forty-five"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "forty-five" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "check", "today": "2026-10-20", "part": "Helmet wall hook", "facts": "{...}", "orientations": "[...]", "page_reads": "[...]", "page_verdict": "reorient"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "check", "today": "2026-10-20", "part": "Helmet wall hook", "facts": "{...}", "orientations": "[...]", "page_reads": "[...]", "page_verdict": "reorient"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "check", today, part, facts, orientations, page_reads, page_verdict, notes?}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses forty-five:check:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="forty-five:check:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "forty-five:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `forty-five:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "forty-five:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "forty-five:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "forty-five:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "forty-five:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"forty-five:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: forty-five:check:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "check", "today": "2026-10-20", "part": "Helmet wall hook", "facts": "{...}", "orientations": "[...]", "page_reads": "[...]", "page_verdict": "reorient"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "forty-five:check:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `forty-five:check:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "forty-five:check:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "forty-five:check:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "forty-five:check:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: forty-five:check:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"forty-five:check:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);