Drive Problem Solver from your own code
Everything the web app does goes through one public surface. Base URL:
https://api.skillsafe.ai/v1/app-api
Every request carries Authorization: Bearer <token> and
Content-Type: application/json. Every response is a JSON envelope:
{"ok":true,"data":{…}} on success and
{"ok":false,"error":{"code":"…","message":"…"}} on failure. Read
error.code, not the HTTP status alone.
Errors
| Code | Meaning | What to do |
|---|---|---|
UNAUTHORIZED | Missing, expired or wrong-app token. | Mint a guest token or sign in again. A cold 401 from /me before any token exists is normal. |
INSUFFICIENT_CREDITS | Balance below min_credits. | Top up. Call /estimate first — it is free and tells you the hold. |
VALIDATION_ERROR | The input object failed validation. | Check error.details. The run body is the input object itself — do not wrap it in {"input": …}. |
RATE_LIMITED | Too many requests. | Back off and retry. Do not tight-loop. |
JOB_FAILED | The run started and did not complete. | Retry with the same Idempotency-Key so a partial charge is not doubled. |
The input object
Problem Solver is a single-contract app. There is no task field and no lane router;
the same contract handles a first pass and a follow-up, distinguished by shape.
| Field | Type | Required | What it is |
|---|---|---|---|
shape | string | yes | "first-pass" or "revision". |
problem | string | yes | The problem in the person's own words. The web app clips anything over 14,000 characters from the middle, keeping both ends, and marks the cut in-band. |
problem_clipped | boolean | no | Whether the text above was clipped. |
facts | object | no | The free client-side pass: stated_constraints, stated_goals, already_rejected, other_parties, time_expressions, quantities, local_classification, specificity_band, missing_pieces. Send it and the breakdown is held to covering it; omit it and you lose the reconciliation. |
professional_domains | string[] | no | Any of medical, legal, financial, mental_health. Binds the boundary rules hard. |
frame_override | string | no | Force a problem type instead of letting the breakdown classify. |
prior | object | on revision | The previous breakdown's restated, problem_type, first_move, options, constraints, signals_working, signals_not_working. |
update | string | on revision | What actually happened since. |
update_kind | string | on revision | tried_it, constraint_changed or new_information. |
The output contract
One JSON object, no prose and no code fence. Top-level keys, all required:
title, problem_type, type_confidence,
type_rationale, restated, reframe,
optimising_for, constraints, unknowns,
options, frame, first_move, signals,
out_of_scope, delta, input_notes.
problem_type∈decision · diagnosis · debugging · negotiation · prioritisation · interpersonal · executionreframe.kind∈wrong_problem · excluded_option · hidden_objective · false_binary · not_yet_a_problem · constraint_is_soft · noneconstraints[].kind∈hard · soft · assumed; everyassumedone carries a non-emptytestoptions[].reversible∈easy · costly · no; ids areO1,O2, … in orderframe.slotshas six entries: the five the chosen type defines, labels verbatim and in order, then one whose label the model writes for that problem alone. The renderer marks the sixth as written-for-this-problem; a reply with only five is treated as cut short.deltais populated only whenshapeis"revision"
The web app's parser tolerates a truncated reply: it walks the fragment, discards the uncompletable tail and closes what is open, then renders the sections that arrived. If you write your own client, do the same rather than discarding a 90%-complete stream.
1. Get a token
Every call needs one. A guest token is minted automatically and is enough for /me and /estimate; running a breakdown is metered and needs a personal token from signing in. The tokens page shows yours, copies it, and mints a fresh guest token — no developer console needed. In the samples below, replace YOUR_TOKEN (or set PD_TOKEN in your shell) with it.
2. Check the session and the balance
/me returns exactly three fields: subject_type, subject_id and credits. There is no email and no name — the signed-in test is subject_type === "user".
curl -s "https://api.skillsafe.ai/v1/app-api/me" \ -H "Authorization: Bearer $PD_TOKEN"
import requests
TOKEN = "YOUR_TOKEN"
BASE = "https://api.skillsafe.ai/v1/app-api"
H = {"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"}
r = requests.get(f"{BASE}/me", headers=H, timeout=30).json()
print(r["data"]["subject_type"], r["data"]["credits"])
const TOKEN = "YOUR_TOKEN";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const H = { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" };
const r = await (await fetch(`${BASE}/me`, { headers: H })).json();
console.log(r.data.subject_type, r.data.credits);
package main
import ("encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func main() {
req, _ := http.NewRequest("GET", base+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out map[string]any
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out["data"])
}
var token = "YOUR_TOKEN";
var base = "https://api.skillsafe.ai/v1/app-api";
var req = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(base + "/me"))
.header("Authorization", "Bearer " + token)
.GET().build();
var res = java.net.http.HttpClient.newHttpClient()
.send(req, java.net.http.HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
TOKEN = "YOUR_TOKEN"
BASE = "https://api.skillsafe.ai/v1/app-api"
uri = URI("#{BASE}/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$base = "https://api.skillsafe.ai/v1/app-api";
$ch = curl_init("$base/me");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
]);
$out = json_decode(curl_exec($ch), true);
print_r($out["data"]);
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
var baseUrl = "https://api.skillsafe.ai/v1/app-api";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = await http.GetStringAsync($"{baseUrl}/me");
Console.WriteLine(body);
3. Price it before you run it
/estimate is free, starts no job and charges nothing. It returns hold_credits (a reservation priced against the full output cap, not the price), min_credits, model and model_alias. Estimate every input shape you submit — a first pass and a revision are structurally different bodies.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $PD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}'
payload = {
"shape": "first-pass",
"problem": "I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.",
"facts": {
"stated_constraints": [{"id": "C1", "quote": "I can't ask my current manager for more responsibility", "trigger": "can't", "hardness": "stated-hard"}],
"already_rejected": [{"id": "R1", "quote": "I already did that in March", "trigger": "I already raised", "stated_reason": null}],
"time_expressions": ["by the 14th"],
"quantities": ["50 minutes", "15"],
"local_classification": "decision",
"specificity_band": "workable"
},
"professional_domains": []
}
est = requests.post(f"{BASE}/estimate", headers=H, json=payload, timeout=30).json()["data"]
print(est["hold_credits"], est["min_credits"], est["model"], est["model_alias"])
const payload = {
"shape": "first-pass",
"problem": "I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.",
"facts": {
"stated_constraints": [{"id": "C1", "quote": "I can't ask my current manager for more responsibility", "trigger": "can't", "hardness": "stated-hard"}],
"already_rejected": [{"id": "R1", "quote": "I already did that in March", "trigger": "I already raised", "stated_reason": null}],
"time_expressions": ["by the 14th"],
"quantities": ["50 minutes", "15"],
"local_classification": "decision",
"specificity_band": "workable"
},
"professional_domains": []
};
const est = (await (await fetch(`${BASE}/estimate`, {
method: "POST", headers: H, body: JSON.stringify(payload)
})).json()).data;
console.log(est.hold_credits, est.model_alias);
payload := []byte(`{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}`)
req, _ := http.NewRequest("POST", base+"/estimate", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var payload = """
{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}
""";
var req = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(base + "/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(payload)).build();
payload = {
"shape": "first-pass",
"problem": "I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.",
"facts": {
"stated_constraints": [{"id": "C1", "quote": "I can't ask my current manager for more responsibility", "trigger": "can't", "hardness": "stated-hard"}],
"already_rejected": [{"id": "R1", "quote": "I already did that in March", "trigger": "I already raised", "stated_reason": null}],
"time_expressions": ["by the 14th"],
"quantities": ["50 minutes", "15"],
"local_classification": "decision",
"specificity_band": "workable"
},
"professional_domains": []
}
uri = URI("#{BASE}/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]["hold_credits"]
<?php
$payload = json_decode(<<<JSON
{
"shape": "first-pass",
"problem": "I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.",
"facts": {
"stated_constraints": [{"id": "C1", "quote": "I can't ask my current manager for more responsibility", "trigger": "can't", "hardness": "stated-hard"}],
"already_rejected": [{"id": "R1", "quote": "I already did that in March", "trigger": "I already raised", "stated_reason": null}],
"time_expressions": ["by the 14th"],
"quantities": ["50 minutes", "15"],
"local_classification": "decision",
"specificity_band": "workable"
},
"professional_domains": []
}
JSON, true);
$ch = curl_init("$base/estimate");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$est = json_decode(curl_exec($ch), true)["data"];
echo $est["hold_credits"];
var payload = """
{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}
""";
var res = await http.PostAsync($"{baseUrl}/estimate",
new StringContent(payload, System.Text.Encoding.UTF8, "application/json"));
Console.WriteLine(await res.Content.ReadAsStringAsync());
4. Run it and poll
/run submits and returns a job. Always send an Idempotency-Key: a retried request with the same key is the same run, so a network blip cannot bill you twice. Poll /jobs/{id} until status is succeeded or failed; the breakdown is the JSON string at output.output.
# Submit. The body IS the input object - there is no {"input": ...} wrapper.
JOB=$(curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $PD_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: problem-solver:first-pass:8f3ka2-1x:a1" \
-d '{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal.
while true; do
S=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $PD_TOKEN")
echo "$S" | grep -q '"status":"succeeded"' && break
sleep 2
done
echo "$S"
import time
headers = dict(H, **{"Idempotency-Key": "problem-solver:first-pass:8f3ka2-1x:a1"})
job = requests.post(f"{BASE}/run", headers=headers, json=payload, timeout=60).json()["data"]
while job["status"] in ("queued", "running"):
time.sleep(2)
job = requests.get(f"{BASE}/jobs/{job['job_id']}", headers=H, timeout=30).json()["data"]
import json
breakdown = json.loads(job["output"]["output"])
print(breakdown["problem_type"], breakdown["reframe"]["kind"])
print(breakdown["first_move"]["action"])
const headers = { ...H, "Idempotency-Key": "problem-solver:first-pass:8f3ka2-1x:a1" };
let job = (await (await fetch(`${BASE}/run`, {
method: "POST", headers, body: JSON.stringify(payload)
})).json()).data;
while (job.status === "queued" || job.status === "running") {
await new Promise(r => setTimeout(r, 2000));
job = (await (await fetch(`${BASE}/jobs/${job.job_id}`, { headers: H })).json()).data;
}
const breakdown = JSON.parse(job.output.output);
console.log(breakdown.problem_type, breakdown.first_move.action);
req, _ = http.NewRequest("POST", base+"/run", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "problem-solver:first-pass:8f3ka2-1x:a1")
// Then GET base+"/jobs/"+jobID every two seconds until status is
// "succeeded" or "failed", and json.Unmarshal output.output.
var req = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(base + "/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "problem-solver:first-pass:8f3ka2-1x:a1")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(payload)).build();
// Poll base + "/jobs/" + jobId until status is terminal.
req = Net::HTTP::Post.new(URI("#{BASE}/run"))
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "problem-solver:first-pass:8f3ka2-1x:a1"
req.body = payload.to_json
# Then poll "#{BASE}/jobs/#{job_id}" until status is terminal,
# and JSON.parse(job["output"]["output"]).
<?php
$ch = curl_init("$base/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: problem-solver:first-pass:8f3ka2-1x:a1",
],
]);
$job = json_decode(curl_exec($ch), true)["data"];
// Then poll "$base/jobs/{$job['job_id']}" until terminal.
var msg = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/run") {
Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json")
};
msg.Headers.Add("Idempotency-Key", "problem-solver:first-pass:8f3ka2-1x:a1");
var res = await http.SendAsync(msg);
// Then poll $"{baseUrl}/jobs/{jobId}" until status is terminal.
5. Or stream it
/run-stream is the same run as server-sent events. delta frames carry text as it arrives; the final done frame carries output.output, charged_credits and truncated. If truncated is true the balance sat between min_credits and hold_credits and the output cap was reduced — render what arrived and say so.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $PD_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: problem-solver:first-pass:8f3ka2-1x:a1" \
-d '{"shape":"first-pass","problem":"I've been offered a role at a smaller company. It pays about the same, the commute is 50 minutes each way instead of 15, and I'd be the only person doing my job there. I can't ask my current manager for more responsibility because I already did that in March. I have to answer by the 14th.","facts":{"stated_constraints":[{"id":"C1","quote":"I can't ask my current manager for more responsibility","trigger":"can't","hardness":"stated-hard"}],"already_rejected":[{"id":"R1","quote":"I already did that in March","trigger":"I already raised","stated_reason":null}],"time_expressions":["by the 14th"],"quantities":["50 minutes","15"],"local_classification":"decision","specificity_band":"workable"},"professional_domains":[]}'
# Server-sent events. `delta` frames carry text; the final `done`
# frame carries output.output, charged_credits and truncated.
with requests.post(f"{BASE}/run-stream", headers=headers, json=payload,
stream=True, timeout=300) as r:
buf = ""
for line in r.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
evt = json.loads(line[6:])
if evt.get("type") == "delta":
buf += evt["text"]
elif evt.get("type") == "done":
print(evt.get("charged_credits"), evt.get("truncated"))
breakdown = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST", headers, body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const l of lines) {
if (!l.startsWith("data: ")) continue;
const evt = JSON.parse(l.slice(6));
if (evt.type === "delta") out += evt.text;
}
}
const breakdown = JSON.parse(out);
// POST to base+"/run-stream" with the same headers, then read the
// response body line by line with bufio.Scanner. Lines beginning
// "event: " name the frame and "data: " carry its JSON; accumulate
// the .text of every "event: delta" frame and json.Unmarshal at the end.
// There is no {"type":"delta"} envelope - that shape never fires.
// POST to base + "/run-stream" with BodyHandlers.ofLines(), then // filter lines starting with "data: ", parse each as JSON, and // append the `text` of every event whose type is "delta".
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "problem-solver:first-pass:8f3ka2-1x:a1"
req.body = payload.to_json
out = ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
evt = JSON.parse(line[6..])
out << evt["text"] if evt["type"] == "delta"
end
end
end
end
breakdown = JSON.parse(out)
<?php
$ch = curl_init("$base/run-stream");
$out = "";
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$evt = json_decode(substr($line, 6), true);
if (($evt["type"] ?? "") === "delta") { $out .= $evt["text"]; }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$breakdown = json_decode($out, true);
var msg = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/run-stream") {
Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json")
};
var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new System.Text.StringBuilder();
while (await sr.ReadLineAsync() is string line) {
if (!line.StartsWith("data: ")) continue;
var evt = System.Text.Json.JsonDocument.Parse(line[6..]).RootElement;
if (evt.GetProperty("type").GetString() == "delta")
sb.Append(evt.GetProperty("text").GetString());
}