Blog Forge - API

Drive the drafting agent from your own code.

Open the app Token page

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 codeMeaning
unauthorizedMissing or expired token - get a fresh one on the token page.
payment_requiredBalance below min_credits - top up at skillsafe.ai.
validation_errorThe input JSON is malformed or missing notes.
rate_limitedToo many requests - back off and retry.
run_failedThe 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:

FieldWhen the app sends itWhat it does
previous_draftOn 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_noteOn 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_noteOnly 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

POST https://api.skillsafe.ai/v1/app-api/guest

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

GET https://api.skillsafe.ai/v1/app-api/me

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

POST https://api.skillsafe.ai/v1/app-api/estimate

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

POST https://api.skillsafe.ai/v1/app-api/run

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

POST https://api.skillsafe.ai/v1/app-api/run-stream

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.