The shape of it
Base URL https://api.skillsafe.ai/v1/app-api. Every response is a
{"data": ...} envelope on success and
{"error": {"code": ..., "message": ...}} on failure.
There is no /apps/{slug}/ segment in any path —
the app is bound to the token, not to the URL.
| Code | Meaning | What to do |
|---|---|---|
unauthorized | Missing or stale token | Mint a new one on the token page. |
payment_required | Balance below min_credits | Top up; check /estimate before running. |
not_found | Wrong path | You probably added an /apps/{slug}/ segment. Remove it. |
validation_error | Body was not the input object | POST the input object directly, not {"input": {...}}. |
rate_limited | Too many requests | Back off and retry; do not tight-loop. |
The input
Exactly the object the app itself submits — these five fields, all strings,
and nothing else. source is the only required one.
| Field | Required | What it is |
|---|---|---|
source | yes | The content to adapt, as pasted. The app clips anything over 40,000 characters from the middle, keeping both ends and announcing the cut in-band. |
voice | no | How the author writes — habits, tone, sample posts. Empty means the voice is inferred from the source. Clipped at 6,000 characters. |
platforms | yes | Comma-separated subset of X, LinkedIn, Threads, Bluesky, in that order. A section comes back for each, and only for those. |
context | no | Audience, which links are allowed, whether a thread is acceptable, sequencing. Clipped at 6,000 characters. |
facts | no | The browser linter's summary, passed as an untrusted hint the model is told to verify against the source. Safe to send as "". |
retry_note | no | Only sent on the app's automatic reformat retry, when a first reply broke the contract. It carries the required shape spelled out and asks for the reply again. Do not send it on a first attempt. |
{
"source": "Pinecrate 2.4 is out. Cold restore on our reference repo went from 19.4s to 6.8s...",
"voice": "Dry, blunt, short sentences. No exclamation marks.",
"platforms": "X, LinkedIn",
"context": "Only pincrate.dev/changelog may be linked. A thread on X is fine.",
"facts": "Browser lint (character arithmetic only - verify against the source): ..."
}
1. Get a token
Open the token page, sign in, and use
Copy shell export. It prints a ready
export SKILLSAFE_TOKEN="..." line. You never need the browser console,
and you should never paste a token into a shared script.
A guest token works too and is minted automatically, but guest runs are only free while sponsorship is on. Signed-in tokens bill your own balance.
2. Check the session and the balance
GET /me tells you whether the token is a personal or a guest one and what
it can spend.
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
import os, requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
H = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
me = requests.get(BASE + "/me", headers=H).json()["data"]
print(me["subject_type"], me["credits"])
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from the token page
const H = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const me = await fetch(`${BASE}/me`, { headers: H }).then(r => r.json());
console.log(me.data.subject_type, me.data.credits);
package main
import (
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func main() {
token := os.Getenv("SKILLSAFE_TOKEN")
req, _ := http.NewRequest("GET", base+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}
import java.net.URI;
import java.net.http.*;
public class Me {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_TOKEN");
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/me"))
.header("Authorization", "Bearer " + token)
.GET().build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
}
}
require "net/http"
require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
uri = URI("#{BASE}/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$base = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
$ch = curl_init("$base/me");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$me = json_decode(curl_exec($ch), true)["data"];
echo $me["subject_type"], " ", $me["credits"], "\n";
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static async Task Main() {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
Console.WriteLine(await http.GetStringAsync(Base + "/me"));
}
}
3. Estimate — free, no job, no charge
Assert the binding before you spend anything: model is
gpt-5.6-terra, model_alias is gpt-terra,
markup_bps is 1000. hold_credits is what gets
reserved, not what you pay; min_credits is the floor below which
the run will not start.
curl -s https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"source": "Pinecrate 2.4 is out. Cold restore on our reference repo went from 19.4s to 6.8s...",
"voice": "Dry, blunt, short sentences. No exclamation marks.",
"platforms": "X, LinkedIn",
"context": "Only pincrate.dev/changelog may be linked. A thread on X is fine.",
"facts": "Browser lint (character arithmetic only - verify against the source): ..."
}'
INPUT = {
"source": open("launch-note.txt").read(),
"voice": "Dry, blunt, short sentences. No exclamation marks.",
"platforms": "X, LinkedIn",
"context": "Only pincrate.dev/changelog may be linked.",
"facts": ""
}
est = requests.post(BASE + "/estimate", headers=H, json=INPUT).json()["data"]
print(est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"])
const input = {
source: sourceText,
voice: "Dry, blunt, short sentences. No exclamation marks.",
platforms: "X, LinkedIn",
context: "Only pincrate.dev/changelog may be linked.",
facts: ""
};
const est = await fetch(`${BASE}/estimate`, {
method: "POST", headers: H, body: JSON.stringify(input)
}).then(r => r.json());
console.log(est.data.model, est.data.hold_credits);
input := map[string]string{
"source": sourceText,
"voice": "Dry, blunt, short sentences. No exclamation marks.",
"platforms": "X, LinkedIn",
"context": "Only pincrate.dev/changelog may be linked.",
"facts": "",
}
body, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/estimate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
String input = """
{"source": "...", "voice": "Dry, blunt.", "platforms": "X, LinkedIn",
"context": "Only pincrate.dev/changelog may be linked.", "facts": ""}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
input = {
source: File.read("launch-note.txt"),
voice: "Dry, blunt, short sentences. No exclamation marks.",
platforms: "X, LinkedIn",
context: "Only pincrate.dev/changelog may be linked.",
facts: ""
}
uri = URI("#{BASE}/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = input.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]["hold_credits"]
$input = [
"source" => file_get_contents("launch-note.txt"),
"voice" => "Dry, blunt, short sentences. No exclamation marks.",
"platforms" => "X, LinkedIn",
"context" => "Only pincrate.dev/changelog may be linked.",
"facts" => ""
];
$ch = curl_init("$base/estimate");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $token", "Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$est = json_decode(curl_exec($ch), true)["data"];
echo $est["model"], " ", $est["hold_credits"], "\n";
var input = new {
source = sourceText,
voice = "Dry, blunt, short sentences. No exclamation marks.",
platforms = "X, LinkedIn",
context = "Only pincrate.dev/changelog may be linked.",
facts = ""
};
var payload = new StringContent(
JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
var est = await http.PostAsync(Base + "/estimate", payload);
Console.WriteLine(await est.Content.ReadAsStringAsync());
4. Run and poll
The body is the input object directly. Always send an
Idempotency-Key: a retried POST with the same key replays the first
result instead of billing a second run.
# The body is the input object DIRECTLY - not {"input": {...}}.
# Idempotency-Key makes a retried POST replay instead of billing twice.
JOB=$(curl -s https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crosspost-$(date +%s)" \
-d @input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import hashlib, json, time
key = "crosspost-" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:32]
job = requests.post(BASE + "/run", headers={**H, "Idempotency-Key": key},
json=INPUT).json()["data"]
while True:
j = requests.get(BASE + "/jobs/" + job["job_id"], headers=H).json()["data"]
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
reply = j["output"]["output"] # the plain-text reply; parse per step 6
const key = "crosspost-" + crypto.randomUUID();
const job = await fetch(`${BASE}/run`, {
method: "POST",
headers: { ...H, "Idempotency-Key": key },
body: JSON.stringify(input)
}).then(r => r.json());
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await fetch(`${BASE}/jobs/${job.data.job_id}`, { headers: H })
.then(r => r.json());
} while (!["succeeded", "failed"].includes(j.data.status));
const reply = j.data.output.output;
req, _ = http.NewRequest("POST", base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "crosspost-"+jobKey)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
// then poll base + "/jobs/" + jobID until status is succeeded or failed,
// and read data.output.output for the plain-text reply.
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "crosspost-" + jobKey)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
// then poll BASE + "/jobs/" + jobId until status is succeeded or failed.
req = Net::HTTP::Post.new(URI("#{BASE}/run"))
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "crosspost-#{job_key}"
req.body = input.to_json
# then poll "#{BASE}/jobs/#{job_id}" until status is succeeded or failed,
# and read data.output.output for the plain-text reply.
$ch = curl_init("$base/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: crosspost-$jobKey"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$job = json_decode(curl_exec($ch), true)["data"];
// then poll "$base/jobs/{$job['job_id']}" until it is succeeded or failed.
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
Content = payload
};
msg.Headers.Add("Idempotency-Key", "crosspost-" + jobKey);
var job = await http.SendAsync(msg);
// then poll Base + "/jobs/{jobId}" until status is succeeded or failed,
// and read data.output.output for the plain-text reply.
5. Stream it instead
POST /run-stream returns Server-Sent Events. The
done frame is authoritative — deltas can drop the tail, and an
idempotent replay returns plain JSON with no deltas at all, so read
output.output from done and treat the accumulated deltas as
a preview.
curl -N https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crosspost-$(date +%s)" \
-d @input.json
# event: job {"job_id": "..."}
# event: delta {"text": "PRIMARY: X\n"}
# event: done {"job_id": "...", "charged_credits": 812, "output": {"output": "..."}}
# event: error {"code": "...", "message": "..."}
with requests.post(BASE + "/run-stream", headers={**H, "Idempotency-Key": key},
json=INPUT, stream=True) as res:
event, reply = None, ""
for line in res.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
reply += payload.get("text", "")
elif event == "done":
reply = payload["output"]["output"] # authoritative
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { ...H, "Idempotency-Key": key },
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", reply = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const ev = /event:\s*(\S+)/.exec(frame);
const da = /data:\s*(.+)/.exec(frame);
if (!ev || !da) continue;
const payload = JSON.parse(da[1]);
if (ev[1] === "delta") reply += payload.text || "";
if (ev[1] === "done") reply = payload.output.output;
}
}
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "crosspost-"+jobKey)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
line := scanner.Text() // "event: delta" / "data: {...}"
fmt.Println(line)
}
HttpRequest stream = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "crosspost-" + jobKey)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HttpClient.newHttpClient()
.send(stream, HttpResponse.BodyHandlers.ofLines())
.body().forEach(System.out::println); // event: / data: frames
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "crosspost-#{job_key}"
req.body = input.to_json
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body { |chunk| print chunk } # event: / data: frames
end
end
$ch = curl_init("$base/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: crosspost-$jobKey"]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) {
echo $chunk; // event: / data: frames
return strlen($chunk);
});
curl_exec($ch);
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = payload
};
msg.Headers.Add("Idempotency-Key", "crosspost-" + jobKey);
var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string line;
while ((line = await reader.ReadLineAsync()) != null) {
Console.WriteLine(line); // event: / data: frames
}
6. Parse the reply
The reply is plain text, not JSON, in exactly this shape. The app discards anything
that breaks it and retries once with retry_note set.
PRIMARY: X | LinkedIn | Threads | Bluesky
VERDICT: Ready to publish | Needs your input | Not post-shaped
CONFIDENCE: <integer 0-100>
SUMMARY: <2-4 sentences, may wrap, ends at the first blank line>
## X
<the post text, ready to paste; thread posts separated by a line containing only --->
## LinkedIn
<the post text>
## What changed
- <one bullet per adaptation decision>
## Before you post
- <each unresolved decision, or the single bullet "Nothing unresolved.">
- One
##section per requested platform, in the canonical orderX, LinkedIn, Threads, Bluesky. - Platform sections carry the post text and nothing else — no preamble, no quotes, no code fence, no character counts.
## What changedalways carries at least one bullet.## Before you postcarries bullets, or exactly- Nothing unresolved.
Character limits worth enforcing on your side, as the app does: X 280 per post with every URL counted as 23 characters, LinkedIn 3,000, Threads 500 per post, Bluesky 300 per post.