Use Blog Forge from your own code
Everything this app does goes through the SkillSafe App API - plain JSON over HTTPS, scriptable from any language. Each step below is tabbed across cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; the tab you pick sticks across steps and visits. Start with a helper, then token, session, estimate, run, stream.
0. A tiny client helper
Every language below uses one small function that adds the auth header, sends JSON, and
unwraps the {"data"}/{"error"} envelope. Define it once; every later step is a
one-liner against it.
# cURL needs no helper - each step below is a single command.
# Set your token once (get one on the token page, /tokens.html):
export SKILLSAFE_TOKEN="YOUR_TOKEN"
# helper.py - tiny client, standard library only
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"] # set from /tokens.html
def call(method, path, body=None):
req = urllib.request.Request(BASE + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
out = json.load(r)
if "error" in out and out["error"]:
raise RuntimeError(out["error"]["code"] + ": " + out["error"]["message"])
return out["data"]
// helper.mjs - tiny client for Node 18+ (built-in fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = process?.["env"]?.["SKILLSAFE_TOKEN"] ?? "YOUR_TOKEN"; // from /tokens.html
export async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const out = await res.json();
if (out.error) throw new Error(`${out.error.code}: ${out.error.message}`);
return out.data;
}
// helper.go - tiny client, standard library only
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any) (map[string]any, error) {
var buf *bytes.Buffer = bytes.NewBuffer(nil)
if body != nil {
b, _ := json.Marshal(body)
buf = bytes.NewBuffer(b)
}
req, _ := http.NewRequest(method, base+path, buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out struct {
Data map[string]any `json:"data"`
Error *struct{ Code, Message string } `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, err
}
if out.Error != nil {
return nil, fmt.Errorf("%s: %s", out.Error.Code, out.Error.Message)
}
return out.Data, nil
}
// Helper.java - tiny client for Java 11+ (java.net.http)
import java.net.URI;
import java.net.http.*;
public class Helper {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // from /tokens.html
static String call(String method, String path, String jsonBody) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = jsonBody == null ? b.method(method, HttpRequest.BodyPublishers.noBody())
: b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
var res = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"data": ...} or {"error": {"code", "message"}}
}
}
# helper.rb - tiny client, standard library only
require "net/http"
require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # set from /tokens.html
def call(method, path, body = nil)
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
out = JSON.parse(res.body)
raise "#{out.dig("error", "code")}: #{out.dig("error", "message")}" if out["error"]
out["data"]
end
<?php
// helper.php - tiny client, no dependencies
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // set from /tokens.html
function call(string $method, string $path, ?array $body = null) {
global $TOKEN;
$opts = ["http" => [
"method" => $method,
"header" => "Authorization: Bearer $TOKEN\r\nContent-Type: application/json",
"ignore_errors" => true,
]];
if ($body !== null) { $opts["http"]["content"] = json_encode($body); }
$out = json_decode(file_get_contents(BASE . $path, false,
stream_context_create($opts)), true);
if (!empty($out["error"])) {
throw new Exception($out["error"]["code"] . ": " . $out["error"]["message"]);
}
return $out["data"];
}
// Helper.cs - tiny client for .NET 6+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class Api {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!; // from /tokens.html
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string method, string path, object? body = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var doc = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err) && err.ValueKind != JsonValueKind.Null)
throw new Exception(err.GetProperty("code").GetString() + ": " +
err.GetProperty("message").GetString());
return doc.RootElement.GetProperty("data");
}
}
Basics
Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends
Authorization: Bearer YOUR_TOKEN and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": ...} on success, {"error": {"code", "message"}} on failure.
| Error code | Meaning |
|---|---|
unauthorized | Missing or expired token - get a fresh one on the token page. |
payment_required | Balance below min_credits - top up at skillsafe.ai. |
validation_error | The input JSON is malformed or missing notes. |
rate_limited | Too many requests - back off and retry. |
run_failed | The model run itself failed; nothing above the minimum is charged. |
The app's input shape, sent to /estimate, /run and /run-stream:
{"notes", "topic", "audience", "length", "focus"} - only notes is required.
audience is one of auto | users | engineers | internals;
length is short | standard | long (roughly 800 / 1200 / 1500 words).
Three optional fields the app also sends, documented here because the prompt defines them and a scripted client may use them too:
| Field | When the app sends it | What it does |
|---|---|---|
previous_draft | On a revision: the full markdown of the draft being revised. | Turns the call into a revision. The model starts from this draft instead of writing a new one. |
revision_note | On a revision: what to change, either typed by the user or generated from a failed checklist item. | The only change the model is allowed to make. Everything the note does not mention must be preserved. |
retry_note | Only when a previous reply could not be parsed as one JSON object. | Describes the parse failure and asks for the same result again in the correct format. It never changes the model's judgement of the content. |
Both revision fields must be sent together; sending one alone is treated as a
normal draft request. A revision is a separate billed run and must carry its own
Idempotency-Key - the app derives the key from a hash of the base input, a hash of
the revision fields, and the attempt number, so a retry can never double-bill and a revision can
never collide with the run it revises.
1. Get a token
Every call authenticates with a bearer token. The easiest path: open the token page, sign in (or use the guest token the app minted), and copy the shell export. Or mint a scripted guest token directly:
# A guest token, no browser needed (or copy your signed-in token from /tokens.html):
curl -s https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug": "blog-forge"}'
# -> {"data": {"token": "...", "guest_id": "..."}}
export SKILLSAFE_TOKEN="the token from the response"
# One-off: mint a guest token (no Authorization header needed)
import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "blog-forge"}).encode(),
headers={"Content-Type": "application/json"})
print(json.load(urllib.request.urlopen(req))["data"]["token"])
// One-off: mint a guest token (no Authorization header needed)
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "blog-forge" }),
});
console.log((await res.json()).data.token);
// One-off: mint a guest token (no Authorization header needed)
res, _ := http.Post("https://api.skillsafe.ai/v1/app-api/guest",
"application/json", strings.NewReader(`{"slug": "blog-forge"}`))
defer res.Body.Close()
io.Copy(os.Stdout, res.Body) // {"data": {"token": "...", ...}}
// One-off: mint a guest token (no Authorization header needed)
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"blog-forge\"}"))
.build();
System.out.println(HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body());
# One-off: mint a guest token (no Authorization header needed)
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req.body = { slug: "blog-forge" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body).dig("data", "token")
<?php // One-off: mint a guest token (no Authorization header needed)
$out = json_decode(file_get_contents("https://api.skillsafe.ai/v1/app-api/guest", false,
stream_context_create(["http" => ["method" => "POST",
"header" => "Content-Type: application/json",
"content" => json_encode(["slug" => "blog-forge"])]])), true);
echo $out["data"]["token"];
// One-off: mint a guest token (no Authorization header needed)
var res = await new HttpClient().PostAsync("https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\": \"blog-forge\"}", Encoding.UTF8, "application/json"));
Console.WriteLine(await res.Content.ReadAsStringAsync());
2. Check the session and balance
Confirms the token works and shows the credit balance the run will draw from. subject_type is user for a personal token, guest for an anonymous one.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data": {"subject_type": "user"|"guest", "credits": 123456, ...}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
me, err := call("GET", "/me", nil)
if err != nil { panic(err) }
fmt.Println(me["subject_type"], me["credits"])
System.out.println(Helper.call("GET", "/me", null));
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"];
var me = await Api.Call("GET", "/me");
Console.WriteLine(me.GetProperty("credits"));
3. Estimate before running
Free and it never starts a job. Send exactly the input you plan to run - the response's hold_credits is the reservation cap (runs usually settle far lower), and min_credits is the floor below which a run cannot start.
curl -s https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"notes": "...your research notes...", "topic": "offscreen culling",
"audience": "auto", "length": "standard", "focus": ""}'
# -> {"data": {"hold_credits": 2900, "min_credits": 700, "model": "gpt-5.6-terra", ...}}
# hold_credits is the RESERVATION cap, not the price - runs usually settle far lower.
input = {
"notes": open("notes.md").read(),
"topic": "offscreen culling",
"audience": "auto", # auto | users | engineers | internals
"length": "standard", # short | standard | long
"focus": ""
}
est = call("POST", "/estimate", input)
print("reserves up to", est["hold_credits"], "credits on", est["model"])
const input = {
notes: await (await import("node:fs/promises")).readFile("notes.md", "utf8"),
topic: "offscreen culling",
audience: "auto", // auto | users | engineers | internals
length: "standard", // short | standard | long
focus: "",
};
const est = await call("POST", "/estimate", input);
console.log(`reserves up to ${est.hold_credits} credits on ${est.model}`);
input := map[string]any{
"notes": string(notesBytes), "topic": "offscreen culling",
"audience": "auto", "length": "standard", "focus": "",
}
est, err := call("POST", "/estimate", input)
if err != nil { panic(err) }
fmt.Println("reserves up to", est["hold_credits"], "credits on", est["model"])
String input = """
{"notes": %s, "topic": "offscreen culling",
"audience": "auto", "length": "standard", "focus": ""}
""".formatted(jsonEscapedNotes);
System.out.println(Helper.call("POST", "/estimate", input));
input = { notes: File.read("notes.md"), topic: "offscreen culling",
audience: "auto", length: "standard", focus: "" }
est = call("POST", "/estimate", input)
puts "reserves up to #{est["hold_credits"]} credits on #{est["model"]}"
<?php
$input = ["notes" => file_get_contents("notes.md"), "topic" => "offscreen culling",
"audience" => "auto", "length" => "standard", "focus" => ""];
$est = call("POST", "/estimate", $input);
echo "reserves up to {$est["hold_credits"]} credits on {$est["model"]}";
var input = new {
notes = File.ReadAllText("notes.md"), topic = "offscreen culling",
audience = "auto", length = "standard", focus = ""
};
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"reserves up to {est.GetProperty("hold_credits")} credits");
4. Run and poll
Starts a billed job and returns a job_id to poll. Pass an Idempotency-Key header so a network retry can never double-bill. The terminal payload's output.output is one JSON object - the draft contract below.
JOB=$(curl -s https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-draft-attempt-1" \
-d '{"notes": "...", "topic": "offscreen culling", "audience": "auto",
"length": "standard", "focus": ""}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['job_id'])")
# Poll until terminal:
curl -s "https://api.skillsafe.ai/v1/app-api/runs/$JOB" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"data": {"status": "succeeded", "output": {"output": "{...the draft JSON...}"},
# "charged_credits": 812, "truncated": false}}
import time
job = call("POST", "/run", input) # add an Idempotency-Key header for safe retries
while True:
r = call("GET", "/runs/" + job["job_id"])
if r["status"] in ("succeeded", "failed"):
break
time.sleep(2)
result = json.loads(r["output"]["output"]) # the JSON contract below
print(result["title"], "-", result["suitability"]["verdict"])
const job = await call("POST", "/run", input); // add an Idempotency-Key header for safe retries
let r;
do {
await new Promise((ok) => setTimeout(ok, 2000));
r = await call("GET", `/runs/${job.job_id}`);
} while (r.status !== "succeeded" && r.status !== "failed");
const result = JSON.parse(r.output.output); // the JSON contract below
console.log(result.title, "-", result.suitability.verdict);
job, _ := call("POST", "/run", input) // add an Idempotency-Key header for safe retries
for {
r, err := call("GET", "/runs/"+job["job_id"].(string), nil)
if err != nil { panic(err) }
if s := r["status"]; s == "succeeded" || s == "failed" {
var result map[string]any
json.Unmarshal([]byte(r["output"].(map[string]any)["output"].(string)), &result)
fmt.Println(result["title"])
break
}
time.Sleep(2 * time.Second)
}
String job = Helper.call("POST", "/run", input); // parse data.job_id from the JSON
// then poll GET /runs/{job_id} every 2s until status is "succeeded" or "failed";
// data.output.output holds the draft JSON described below.
job = call("POST", "/run", input) # add an Idempotency-Key header for safe retries
loop do
r = call("GET", "/runs/#{job["job_id"]}")
if %w[succeeded failed].include?(r["status"])
result = JSON.parse(r.dig("output", "output"))
puts "#{result["title"]} - #{result.dig("suitability", "verdict")}"
break
end
sleep 2
end
<?php
$job = call("POST", "/run", $input); // add an Idempotency-Key header for safe retries
while (true) {
$r = call("GET", "/runs/" . $job["job_id"]);
if (in_array($r["status"], ["succeeded", "failed"])) {
$result = json_decode($r["output"]["output"], true);
echo $result["title"], " - ", $result["suitability"]["verdict"];
break;
}
sleep(2);
}
var job = await Api.Call("POST", "/run", input); // add an Idempotency-Key header
JsonElement r;
do {
await Task.Delay(2000);
r = await Api.Call("GET", $"/runs/{job.GetProperty("job_id").GetString()}");
} while (r.GetProperty("status").GetString() is not ("succeeded" or "failed"));
using var result = JsonDocument.Parse(r.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(result.RootElement.GetProperty("title").GetString());
5. Stream the run
The same billed run as step 4, but as server-sent events: delta frames with text as it generates, then one terminal done frame whose payload matches step 4's terminal response. This is what the app itself uses.
# Server-sent events: deltas arrive as they generate, then a terminal "done" event.
curl -N https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"notes": "...", "topic": "offscreen culling", "audience": "auto",
"length": "standard", "focus": ""}'
# event: delta data: {"text": "..."} (many)
# event: done data: {"status": "succeeded", "output": {...}, "charged_credits": ...}
# Streaming needs an SSE reader; simplest with the requests package
import requests
with requests.post("https://api.skillsafe.ai/v1/app-api/run-stream",
headers={"Authorization": "Bearer " + TOKEN,
"Accept": "text/event-stream"},
json=input, stream=True) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip()) # delta chunks, then the done payload
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(input),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process["stdout"].write(dec.decode(value)); // parse the SSE frames as they arrive
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewBuffer(bodyJSON))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
fmt.Println(sc.Text()) // "event: delta" / "data: {...}" frames
}
var req = HttpRequest.newBuilder(URI.create(Helper.BASE + "/run-stream"))
.header("Authorization", "Bearer " + Helper.TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HttpClient.newHttpClient().send(req,
HttpResponse.BodyHandlers.ofLines()).body().forEach(System.out::println);
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{TOKEN}",
"Content-Type" => "application/json", "Accept" => "text/event-stream")
req.body = input.to_json
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| print chunk } }
end
<?php
$ctx = stream_context_create(["http" => ["method" => "POST",
"header" => "Authorization: Bearer $TOKEN\r\n" .
"Content-Type: application/json\r\nAccept: text/event-stream",
"content" => json_encode($input)]]);
$fh = fopen(BASE . "/run-stream", "r", false, $ctx);
while (!feof($fh)) { echo fgets($fh); } // SSE frames as they arrive
fclose($fh);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is { } line) Console.WriteLine(line);
The draft contract
The run's output.output is one JSON object, no prose around it:
{
"title": "post title, sentence case",
"topic": "the topic as understood",
"audience": "users | engineers | internals",
"suitability": {"verdict": "strong | workable | weak", "note": "..."},
"angle": {"problem": "...", "insight": "...", "surprise": "..."},
"outline": [{"heading": "...", "purpose": "..."}],
"post_markdown": "the full draft in markdown; empty string when the verdict is weak",
"word_count": 1180,
"checklist": [
{"id": "opening", "label": "...", "pass": true, "note": "..."},
{"id": "insight", "label": "...", "pass": true, "note": "..."},
{"id": "specificity", "label": "...", "pass": true, "note": "..."},
{"id": "code", "label": "...", "pass": true, "note": "..."},
{"id": "tone", "label": "...", "pass": true, "note": "..."},
{"id": "links", "label": "...", "pass": false, "note": "..."},
{"id": "length", "label": "...", "pass": true, "note": "..."}
],
"next_steps": ["2-6 entries, always at least one"],
"summary": "2-4 sentences"
}
The checklist always carries exactly those seven ids in that order.
When suitability.verdict is weak, post_markdown is an empty
string and next_steps lists the research questions to answer before a post is worth
writing - treat that as a real result, not an error.