Crosspost Studio API

Adapt one piece of content for X, LinkedIn, Threads and Bluesky from your own code.

Back to the app

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.

CodeMeaningWhat to do
unauthorizedMissing or stale tokenMint a new one on the token page.
payment_requiredBalance below min_creditsTop up; check /estimate before running.
not_foundWrong pathYou probably added an /apps/{slug}/ segment. Remove it.
validation_errorBody was not the input objectPOST the input object directly, not {"input": {...}}.
rate_limitedToo many requestsBack 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.

FieldRequiredWhat it is
sourceyesThe 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.
voicenoHow the author writes — habits, tone, sample posts. Empty means the voice is inferred from the source. Clipped at 6,000 characters.
platformsyesComma-separated subset of X, LinkedIn, Threads, Bluesky, in that order. A section comes back for each, and only for those.
contextnoAudience, which links are allowed, whether a thread is acceptable, sequencing. Clipped at 6,000 characters.
factsnoThe browser linter's summary, passed as an untrusted hint the model is told to verify against the source. Safe to send as "".
retry_notenoOnly 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.">

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.