Everything the web app does, you can do from your own code: send a rough report, get back a
structured issue with a title, a type, labels, a readback, open questions and a confidence
line. Useful for wiring triage into a support inbox, a Slack workflow or a bot that files
issues from customer reports. Base URL https://api.skillsafe.ai/v1/app-api.
The object you send as input. Only report is required.
| Field | Type | Meaning |
|---|---|---|
report | string | The rough report, as written. Rambling, contradictory and partly irrelevant is expected. Clipped at 40,000 characters — from the middle, keeping both ends, with the cut announced in-band as [report truncated: N characters removed from the middle]. |
context | string | Optional project context: repository or product name, versions, environment, links, related threads. Anything omitted here is never invented — it comes back as an open question. |
type_hint | string | Optional, one of Bug, Feature, Task, Docs, or empty. Treated as a hint: a report that plainly describes something else is filed under the correct type, with the override explained in the readback. |
correction | string | Optional. What the previous readback got wrong, in the reporter's own words. Sent with the original report to produce a corrected issue. |
Every call carries Authorization: Bearer <token>. Open the token page to sign in, reveal your token and copy a ready-made shell export. It never asks you to open the DevTools console. A guest token works for reading and estimating; a signed-in token is needed to run.
# Every call below reuses this. Get the token from the token page
# linked in step 1 - never paste it into a shared shell history.
export SKILLSAFE_TOKEN="YOUR_TOKEN"
export SKILLSAFE_BASE="https://api.skillsafe.ai/v1/app-api"
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, json_body=None):
data = json.dumps(json_body).encode() if json_body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data: req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.loads(r.read())
if not env.get("ok"):
raise RuntimeError(env["error"]["code"] + ": " + env["error"].get("message", ""))
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Read it from your own secret store; never hard-code a real token.
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message ?? ""}`);
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any) (map[string]any, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_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 struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error struct{ Code, Message string } `json:"error"`
}
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.*;
class IssueForge {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String body) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (body != null) {
b.header("Content-Type", "application/json");
b.method(method, HttpRequest.BodyPublishers.ofString(body));
} else {
b.method(method, HttpRequest.BodyPublishers.noBody());
}
var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} - decode with your JSON library
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env["error"]["code"]}: #{env["error"]["message"]}" unless env["ok"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function call($method, $path, $body = null) {
global $TOKEN;
$headers = ["Authorization: Bearer $TOKEN"];
if ($body !== null) $headers[] = "Content-Type: application/json";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) throw new Exception($env["error"]["code"]);
return $env["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
async Task<string> Call(string method, string path, string? body) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (body != null)
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
return await res.Content.ReadAsStringAsync(); // {"ok":true,"data":{...}}
}
Confirms who the token belongs to and how many credits are available. Do this before a run: a 402 after submitting is avoidable.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
r = call("GET", "/me")
print(r)
const me = await call("GET", "/me");
console.log(me);
me, err := call("GET", "/me", nil)
if err != nil { log.Fatal(err) }
fmt.Println(me)
String me = call("GET", "/me", null);
System.out.println(me);
me = call("GET", "/me")
puts me
$me = call("GET", "/me");
print_r($me);
var me = await Call("GET", "/me", null);
Console.WriteLine(me);
Returns the credit hold a run would reserve, plus the resolved model and markup. It creates no job and charges nothing, so it is safe to call on every keystroke.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"}'
r = call("POST", "/estimate",
json={
"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.",
"context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.",
"type_hint": "Bug"
})
print(r)
const est = await call("POST", "/estimate", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"});
console.log(est);
est, err := call("POST", "/estimate", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"})
if err != nil { log.Fatal(err) }
fmt.Println(est)
String est = call("POST", "/estimate", "{\"report\": \"forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.\", \"context\": \"Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.\", \"type_hint\": \"Bug\"}");
System.out.println(est);
est = call("POST", "/estimate", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"})
puts est
$est = call("POST", "/estimate", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"});
print_r($est);
var est = await Call("POST", "/estimate", "{\"report\": \"forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.\", \"context\": \"Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.\", \"type_hint\": \"Bug\"}");
Console.WriteLine(est);
Creates a job and returns when it is terminal. Always send an Idempotency-Key: a retried request with the same key returns the original job instead of billing twice.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"}'
r = call("POST", "/run",
json={
"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.",
"context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.",
"type_hint": "Bug"
})
print(r)
const job = await call("POST", "/run", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"});
console.log(job);
job, err := call("POST", "/run", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"})
if err != nil { log.Fatal(err) }
fmt.Println(job)
String job = call("POST", "/run", "{\"report\": \"forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.\", \"context\": \"Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.\", \"type_hint\": \"Bug\"}");
System.out.println(job);
job = call("POST", "/run", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"})
puts job
$job = call("POST", "/run", {"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"});
print_r($job);
var job = await Call("POST", "/run", "{\"report\": \"forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.\", \"context\": \"Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.\", \"type_hint\": \"Bug\"}");
Console.WriteLine(job);
Same job, delivered as server-sent events. delta events carry incremental text, job carries the job id, and done carries the authoritative full output - trust done over the concatenated deltas, which can drop the tail.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: issue-forge-$(date +%s)" \
-d '{"input":{"report": "forwarding this from support - the CSV export is garbage after about the first hundred rows when I filter by team. unfiltered is fine.", "context": "Product: Atlas Analytics. Repo: acme/atlas-web. Version 4.11.2.", "type_hint": "Bug"}}'
# event: delta data: {"text":"TITLE: Filtered CSV export..."}
# event: job data: {"job_id":"job_..."}
# event: done data: {"output":{"output":"TITLE: ..."},"charged_credits":812}
import json, urllib.request
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "issue-forge-001")
raw = []
with urllib.request.urlopen(req) as r:
event = None
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
raw.append(payload.get("text", ""))
elif event == "done":
raw = [payload["output"]["output"]]
print("".join(raw))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "issue-forge-001",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "", event = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") raw += payload.text ?? "";
if (event === "done") raw = payload.output.output;
}
}
}
console.log(raw);
// SSE: read the body line by line rather than decoding it as one JSON document.
req, _ := http.NewRequest("POST", base+"/run-stream",
bytes.NewReader(inputJSON))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "issue-forge-001")
res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var p struct {
Text string `json:"text"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &p)
if event == "delta" { raw += p.Text }
if event == "done" { raw = p.Output.Output }
}
}
fmt.Println(raw)
// Stream the response body and split on SSE line prefixes.
var req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "issue-forge-001")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
var raw = new StringBuilder();
final String[] event = { null };
res.body().forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:") && "delta".equals(event[0])) {
// decode {"text":"..."} with your JSON library and append
raw.append(extractText(line.substring(5).trim()));
}
});
System.out.println(raw);
require "net/http"
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "issue-forge-001"
req.body = JSON.generate(INPUT)
raw = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
p = JSON.parse(line[5..].strip)
raw << p["text"].to_s if event == "delta"
raw = p["output"]["output"] if event == "done"
end
end
end
end
end
puts raw
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: issue-forge-001"],
CURLOPT_POSTFIELDS => json_encode($INPUT),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:")) {
$p = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") $raw .= $p["text"] ?? "";
if ($event === "done") $raw = $p["output"]["output"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
echo $raw;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Idempotency-Key", "issue-forge-001");
req.Content = new StringContent(inputJson, Encoding.UTF8, "application/json");
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var stream = await res.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? evt = null;
var raw = new StringBuilder();
while (!reader.EndOfStream) {
var line = await reader.ReadLineAsync();
if (line is null) continue;
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta") {
// decode {"text":"..."} with System.Text.Json and append
raw.Append(ExtractText(line[5..].Trim()));
}
}
Console.WriteLine(raw);
data.output.output is plain text in exactly this shape. This is what the app's
own parser decodes, and the same contract your code should parse against.
TITLE: <sentence-case title, no type prefix>
TYPE: <Bug|Feature|Task|Docs>
LABELS: <comma-separated lowercase labels, or "none">
BODY:
<the issue body in markdown>
TITLE: is the first line that starts with it;
TYPE: is the first such line after it and must be exactly one of the four words
or the parse fails; LABELS: splits on commas and lowercases, with the literal
none and a missing line both meaning no labels; the body is everything after a
line reading exactly BODY:.
Sections are ### headings matched to the type — What's wrong,
Steps to reproduce, Expected vs actual, Environment for a
Bug; Problem, Proposed solution, Alternatives
considered, Use cases for a Feature. Then, for every type, in
order: an unheaded paragraph beginning **Readback:**, a
### Open questions numbered list where blocking items are prefixed
Critical:, and a final line Confidence: NN%, <clause>.
report or context will not appear in
the output. If you need them, supply them — do not expect the model to guess. That is
the point of the tool.
Every response is {"ok": true, "data": {...}} or
{"ok": false, "error": {"code": "...", "message": "..."}}. Check
ok before reading data.
| Status | Code | What to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The input shape is wrong. error.details names the field. |
| 401 | UNAUTHORIZED | Missing, malformed or expired token. Mint a new one from the token page. |
| 402 | PAYMENT_REQUIRED | The balance is below the run's hold. Call /estimate first and compare against /me. |
| 404 | NOT_FOUND | Wrong slug or job id. |
| 429 | RATE_LIMITED | Back off and retry with the same idempotency key. |
| 5xx | INTERNAL | Retry with the same idempotency key; a completed job is returned rather than re-billed. |
Idempotency-Key on every /run
and /run-stream. Derive it from a hash of the input plus an attempt counter, so
a network retry collapses server-side while a genuine re-run gets its own key. The app does
exactly this, including on its automatic reformat retry.