Skill Studio — API

Describe the job, get the agent skill.

API tokens Open the app

Write agent skills from your own tools

Send a plain-language brief describing a capability you want an agent to have — or an existing SKILL.md you want audited and revised — and get back a complete agent skill as plain text: a two-field frontmatter whose description carries the triggers, an imperative body written to the 500-line budget, a plan for the scripts, references and assets worth bundling, and a review that names its assumptions and its open questions instead of inventing the facts it was not given. Everything the browser app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can generate a skill from a ticket, regenerate one in CI when the house rules change, or lint a repository full of them. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug skill-studio. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The skill is written by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one brief in, one skill out — with no follow-up calls and no session state to carry.

POST /guest
GET /me
POST /estimate
POST /run
GET /jobs/{job_id}
POST /run-stream
GET /data/{key}

There is no /apps/skill-studio/ path segment anywhere in this API. The slug is bound to the token when the token is created, so every route is exactly as written above; adding the slug to the path returns 404 not_found. Note also that the /run body is the input object itself, not {"input": …}.

StatusMeaning
400Malformed body, or an input field of the wrong type.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large paste).
404Unknown job id, unknown data key, or a path with the slug wrongly inserted.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

The input

These are the exact fields the browser app sends, taken from its readForm(). Every field is optional individually, but a request needs at least one of brief or existing to be meaningful.

FieldTypeWhat it is
briefstringThe job, in your words: what the skill should do, who uses it, the house rules that matter. The primary input. The app clips it at 20,000 characters.
triggersstringExample requests that should activate the skill, one per line. They are folded into the frontmatter description. Clipped at 2,000 characters.
namestringAn optional working name. A non-kebab-case name is corrected and the correction is reported in the review.
existingstringA SKILL.md you already have. When present the result is a revision of it rather than a fresh skill. Clipped at 40,000 characters.
modestring"Create" or "Review". "Review" means auditing the existing skill is the whole job. Defaults to "Create".
lintstring[]Findings from a client-side linter on existing, passed as untrusted hints the model re-checks against the real text. Each entry reads rule @ line N: excerpt. Send it or omit it.
retry_notestringOnly sent when a previous reply failed to parse: it says what was wrong with the shape. Reuse the same idempotency key base so the retry is not billed as new work.

Both long fields are clipped in the middle, not from the end. A brief opens with the job and closes with the constraints you cannot supply; a SKILL.md opens with the frontmatter that carries its trigger and closes with its output-format section. Cutting either end changes the answer, so keep both ends and say in the text where the cut is — the app inserts a bracketed marker the model can read.

The output

The run returns plain text in a fixed section shape, not JSON. Read it from output.output. This is the exact contract the app's parser decodes:

NAME: <kebab-case skill name>
VERDICT: <Ready to package|Needs your input|Not skill-shaped>
SUMMARY: <one line>

FRONTMATTER:
name: <same name>
description: <one flowing description that states what the skill does AND when to use it>

SKILL:
<the complete SKILL.md body markdown, headings starting at ##>

RESOURCES:
<the bundled-resource plan: one bullet per scripts/, references/ or assets/ file,
 or the single line "None - SKILL.md alone covers it.">

REVIEW:
**Assumptions:** ...
**Open questions:** ...
**Trigger check:** ...
**Revision notes:** ...   (only when `existing` was sent)
Skill confidence: NN%, <short clause>.
SectionRequiredParsing rule
NAME:yesThe first line beginning NAME:; the remainder, trimmed. An empty name fails the parse.
VERDICT:yesThe first later line beginning VERDICT:. Must normalise to one of the three values or the parse fails.
SUMMARY:noOne line, no markdown.
FRONTMATTER:noEverything between a line reading exactly FRONTMATTER: and the SKILL: line. Any --- rails and any code fence are stripped.
SKILL:yesEverything between a line reading exactly SKILL: and RESOURCES: (or REVIEW:, or the end). An empty body fails the parse.
RESOURCES:noEverything up to REVIEW:.
REVIEW:noEverything after. The trailing Skill confidence: NN% line is read bottom-up.

Two consistency rules worth asserting on in your own pipeline, because the app surfaces them to the user: VERDICT: Needs your input requires at least one bullet under **Open questions:**, and VERDICT: Ready to package requires that list to be None.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, os, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")  # see step 1

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any, extra map[string]string) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	for k, v := range extra {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if env.Error != nil {
		return fmt.Errorf("%s", env.Error.Message)
	}
	if out != nil {
		return json.Unmarshal(env.Data, out)
	}
	return nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;

class SkillSafe {
  static final String API = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
  static final HttpClient CLIENT = HttpClient.newHttpClient();

  static String api(String method, String path, String jsonBody, Map<String,String> extra)
      throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Authorization", "Bearer " + TOKEN)
        .header("Content-Type", "application/json")
        .method(method, jsonBody == null
            ? HttpRequest.BodyPublishers.noBody()
            : HttpRequest.BodyPublishers.ofString(jsonBody));
    if (extra != null) extra.forEach(b::header);
    HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() >= 400) throw new RuntimeException(res.body());
    return res.body();   // {"data": ...} — unwrap with your JSON library
  }
}
require "json"
require "net/http"
require "uri"

API   = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")   # see step 1

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  extra.each { |k, v| req[k] = v }
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise payload.dig("error", "message").to_s unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";   // see step 1

function api(string $method, string $path, ?array $body = null, array $extra = []): array {
    global $TOKEN;
    $headers = ["Authorization: Bearer $TOKEN", "Content-Type: application/json"];
    foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); }
    $raw  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $json = json_decode($raw, true);
    if ($code >= 400) { throw new RuntimeException($json["error"]["message"] ?? "http $code"); }
    return $json["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class SkillSafe {
    const string API = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token =
        Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> ApiAsync(
        HttpMethod method, string path, object? body = null,
        IDictionary<string, string>? extra = null) {
        var req = new HttpRequestMessage(method, API + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (body is not null)
            req.Content = new StringContent(JsonSerializer.Serialize(body),
                                            Encoding.UTF8, "application/json");
        if (extra is not null)
            foreach (var kv in extra) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
        var res = await Http.SendAsync(req);
        var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        if (!res.IsSuccessStatusCode)
            throw new Exception(doc.RootElement.GetProperty("error").GetProperty("message").GetString());
        return doc.RootElement.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

Two ways in. For a script, mint a guest token with POST /guest — it needs no sign-in and is enough for /me and the free /estimate. For runs billed to your own account, use your personal token: open the token page, sign in, and press Copy shell export. That page also reveals, replaces and forgets the token — you never need the browser's developer console.

curl -s "$API/guest" -H "Content-Type: application/json" \
  -d '{"slug":"skill-studio"}' | jq -r '.data.token'
# export SKILLSAFE_TOKEN="the value printed above"
guest = requests.post(API + "/guest", json={"slug": "skill-studio"}).json()["data"]
TOKEN = guest["token"]        # replaces the placeholder from step 0
print(guest["guest_id"], guest["token"][:8] + "...")
const guestRes = await fetch(`${API}/guest`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "skill-studio" }),
});
const guest = (await guestRes.json()).data;   // { token, guest_id, ... }
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}
// /guest is the one call that needs no token yet.
if err := call("POST", "/guest", map[string]string{"slug": "skill-studio"}, &guest, nil); err != nil {
	panic(err)
}
token = guest.Token
String guest = SkillSafe.api("POST", "/guest", "{\"slug\":\"skill-studio\"}", null);
// guest -> {"data":{"token":"...","guest_id":"..."}}
// Pull data.token with your JSON library and use it as the bearer token.
guest = api("POST", "/guest", { "slug" => "skill-studio" })
token = guest["token"]     # use in place of the step-0 placeholder
puts guest["guest_id"]
$guest = api("POST", "/guest", ["slug" => "skill-studio"]);
$TOKEN = $guest["token"];          // replaces the step-0 placeholder
echo $guest["guest_id"], PHP_EOL;
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "skill-studio" });
var token = guest.GetProperty("token").GetString();
Console.WriteLine(guest.GetProperty("guest_id").GetString());

Guest wallets are small and per-device. If a run returns 402, that is the signal to sign in and use a personal token rather than to retry.

Step 2 — Check the session and the balance

GET /me

Free. Returns subject_type ("user" or "guest"), subject_id and credits. Call it before a run and compare the balance against the estimate from step 3 — discovering a 402 after submitting is a failure of the client, not of the user.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# { "subject_type": "user", "subject_id": "...", "credits": 250000 }
me = api("GET", "/me")
print(me["subject_type"], me["credits"], "credits")
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits, "credits");
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, &me, nil); err != nil {
	panic(err)
}
fmt.Println(me.SubjectType, me.Credits, "credits")
String me = SkillSafe.api("GET", "/me", null, null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":250000,...}}
me = api("GET", "/me")
puts "#{me["subject_type"]} — #{me["credits"]} credits"
$me = api("GET", "/me");
echo $me["subject_type"], " — ", $me["credits"], " credits", PHP_EOL;
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} — " +
                  $"{me.GetProperty("credits").GetInt64()} credits");

Step 3 — Price the run before you make it

POST /estimate

Free, and it creates no job. Send the same input object you would send to /run and get back hold_credits (the worst-case reserve, priced at the full output cap), min_credits (the floor below which the run cannot start), model and model_alias. Show hold_credits as reserved, never as the price — the settled charge is usually far lower.

cat > input.json <<'JSON'
{
  "brief": "We ship a Postgres billing service and migrations are the scariest part of every deploy. I want a skill that makes the coding agent review migrations before merge. Three house rules: every migration is a numbered up/down pair and a missing down file is an automatic reject; tables are plural snake_case, FK columns end in _id, indexes are idx__; anything rewriting a table over a million rows ships across two deploys. I cannot share our full style guide or the real schema.",
  "triggers": "Review this migration before I merge it\nIs this ALTER TABLE going to lock the orders table\nWrite a rollback note for migration 0142",
  "name": "postgres-migration-reviewer",
  "existing": "",
  "mode": "Create"
}
JSON

curl -s "$API/estimate" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -d @input.json | jq '.data'
# { "hold_credits": 1595, "min_credits": 167,
#   "model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000 }
payload = {
    "brief": "We ship a Postgres billing service and migrations are the scariest part of "
             "every deploy. I want a skill that makes the coding agent review migrations "
             "before merge. Three house rules: numbered up/down pairs (a missing down file "
             "is an automatic reject); plural snake_case tables, _id foreign keys, "
             "idx_
_ indexes; anything rewriting a table over a million rows " "ships across two deploys. I cannot share our full style guide or the schema.", "triggers": "Review this migration before I merge it\n" "Is this ALTER TABLE going to lock the orders table", "name": "postgres-migration-reviewer", "existing": "", "mode": "Create", } est = api("POST", "/estimate", payload) print(est["hold_credits"], "reserved on", est["model_alias"], "->", est["model"]) if api("GET", "/me")["credits"] < est["min_credits"]: raise SystemExit("top up before running")
const payload = {
  brief: "We ship a Postgres billing service and migrations are the scariest part of every " +
         "deploy. I want a skill that makes the coding agent review migrations before merge. " +
         "Three house rules: numbered up/down pairs; plural snake_case tables with _id foreign " +
         "keys; anything rewriting a table over a million rows ships across two deploys.",
  triggers: "Review this migration before I merge it\nIs this ALTER TABLE going to lock orders",
  name: "postgres-migration-reviewer",
  existing: "",
  mode: "Create",
};

const est = await api("POST", "/estimate", payload);
console.log(`${est.hold_credits} reserved on ${est.model_alias} -> ${est.model}`);
payload := map[string]any{
	"brief":    "We ship a Postgres billing service and migrations are the scariest part of every deploy. I want a skill that reviews migrations before merge...",
	"triggers": "Review this migration before I merge it",
	"name":     "postgres-migration-reviewer",
	"existing": "",
	"mode":     "Create",
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
}
if err := call("POST", "/estimate", payload, &est, nil); err != nil {
	panic(err)
}
fmt.Printf("%d reserved on %s -> %s\n", est.HoldCredits, est.ModelAlias, est.Model)
String payload = """
  {"brief":"We ship a Postgres billing service and migrations are the scariest part of every deploy. I want a skill that reviews migrations before merge...",
   "triggers":"Review this migration before I merge it",
   "name":"postgres-migration-reviewer",
   "existing":"",
   "mode":"Create"}
  """;

String est = SkillSafe.api("POST", "/estimate", payload, null);
System.out.println(est);   // data.hold_credits, data.min_credits, data.model, data.model_alias
payload = {
  "brief"    => "We ship a Postgres billing service and migrations are the scariest part of " \
                "every deploy. I want a skill that reviews migrations before merge...",
  "triggers" => "Review this migration before I merge it",
  "name"     => "postgres-migration-reviewer",
  "existing" => "",
  "mode"     => "Create"
}

est = api("POST", "/estimate", payload)
puts "#{est["hold_credits"]} reserved on #{est["model_alias"]} -> #{est["model"]}"
$payload = [
    "brief"    => "We ship a Postgres billing service and migrations are the scariest part "
                . "of every deploy. I want a skill that reviews migrations before merge...",
    "triggers" => "Review this migration before I merge it",
    "name"     => "postgres-migration-reviewer",
    "existing" => "",
    "mode"     => "Create",
];

$est = api("POST", "/estimate", $payload);
printf("%d reserved on %s -> %s\n", $est["hold_credits"], $est["model_alias"], $est["model"]);
var payload = new {
    brief = "We ship a Postgres billing service and migrations are the scariest part of every " +
            "deploy. I want a skill that reviews migrations before merge...",
    triggers = "Review this migration before I merge it",
    name = "postgres-migration-reviewer",
    existing = "",
    mode = "Create"
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits").GetInt64()} reserved on " +
                  $"{est.GetProperty("model_alias").GetString()}");

Step 4 — Write the skill

POST /run
GET /jobs/{job_id}

Metered. The body is the input object itself. The call returns a job_id; poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed, then read the text from output.output. Always send an Idempotency-Key: derive it from a hash of the input plus an attempt counter so a dropped connection, or a retry after a malformed reply, can never be billed twice.

JOB_ID=$(curl -s "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: skill-studio:9f2ab13c:a1" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

echo "$JOB" | jq -r '.data.output.output' > SKILL.md
echo "$JOB" | jq -r '.data.charged_credits'
import hashlib, time

def idem(payload, attempt=1):
    basis = "".join(str(payload.get(k, "")) for k in
                    ("brief", "triggers", "name", "existing", "mode"))
    return f"skill-studio:{hashlib.sha256(basis.encode()).hexdigest()[:12]}:a{attempt}"

job_id = api("POST", "/run", payload, **{"Idempotency-Key": idem(payload)})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

if job["status"] == "failed":
    raise SystemExit(job.get("error", "run failed"))

text = job["output"]["output"]
open("SKILL.md", "w").write(text)
print(job["charged_credits"], "credits charged")
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";

const idem = (p, attempt = 1) => {
  const basis = ["brief", "triggers", "name", "existing", "mode"].map((k) => p[k] ?? "").join("");
  return `skill-studio:${createHash("sha256").update(basis).digest("hex").slice(0, 12)}:a${attempt}`;
};

const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": idem(payload) });

let job;
for (;;) {
  job = await api("GET", `/jobs/${job_id}`);
  if (job.status === "succeeded" || job.status === "failed") break;
  await new Promise((r) => setTimeout(r, 2000));
}
if (job.status === "failed") throw new Error(job.error ?? "run failed");

await writeFile("SKILL.md", job.output.output);
console.log(job.charged_credits, "credits charged");
var started struct{ JobID string `json:"job_id"` }
headers := map[string]string{"Idempotency-Key": "skill-studio:9f2ab13c:a1"}
if err := call("POST", "/run", payload, &started, headers); err != nil {
	panic(err)
}

var job struct {
	Status         string `json:"status"`
	ChargedCredits int64  `json:"charged_credits"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job, nil); err != nil {
		panic(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
os.WriteFile("SKILL.md", []byte(job.Output.Output), 0o644)
fmt.Println(job.ChargedCredits, "credits charged")
String started = SkillSafe.api("POST", "/run", payload,
    java.util.Map.of("Idempotency-Key", "skill-studio:9f2ab13c:a1"));
String jobId = /* data.job_id via your JSON library */ "";

String job;
while (true) {
  job = SkillSafe.api("GET", "/jobs/" + jobId, null, null);
  // read data.status; break on "succeeded" or "failed"
  if (job.contains("\"status\":\"succeeded\"") || job.contains("\"status\":\"failed\"")) break;
  Thread.sleep(2000);
}
// The skill text is data.output.output — write it to SKILL.md.
System.out.println(job);
require "digest"

def idem(payload, attempt = 1)
  basis = %w[brief triggers name existing mode].map { |k| payload[k].to_s }.join
  "skill-studio:#{Digest::SHA256.hexdigest(basis)[0, 12]}:a#{attempt}"
end

started = api("POST", "/run", payload, { "Idempotency-Key" => idem(payload) })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 2
end
abort(job["error"].to_s) if job["status"] == "failed"

File.write("SKILL.md", job["output"]["output"])
puts "#{job["charged_credits"]} credits charged"
function idem(array $payload, int $attempt = 1): string {
    $basis = "";
    foreach (["brief", "triggers", "name", "existing", "mode"] as $k) {
        $basis .= (string)($payload[$k] ?? "");
    }
    return "skill-studio:" . substr(hash("sha256", $basis), 0, 12) . ":a$attempt";
}

$started = api("POST", "/run", $payload, ["Idempotency-Key" => idem($payload)]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));

if ($job["status"] === "failed") { throw new RuntimeException($job["error"] ?? "run failed"); }

file_put_contents("SKILL.md", $job["output"]["output"]);
echo $job["charged_credits"], " credits charged", PHP_EOL;
using System.Security.Cryptography;

static string Idem(object p, int attempt = 1) {
    var basis = JsonSerializer.Serialize(p);
    var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(basis)))[..12];
    return $"skill-studio:{hash.ToLowerInvariant()}:a{attempt}";
}

var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload,
    new Dictionary<string, string> { ["Idempotency-Key"] = Idem(payload) });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true) {
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(2000);
}
await File.WriteAllTextAsync("SKILL.md",
    job.GetProperty("output").GetProperty("output").GetString());

If truncated comes back true, the reply hit the run's output cap because the balance sat between min_credits and hold_credits. Treat that text as incomplete — the later sections (RESOURCES:, REVIEW:) are the ones that go missing — rather than presenting it as a finished skill.

Step 5 — Stream it instead

POST /run-stream

Same body, same billing, same Idempotency-Key, but the response is text/event-stream and the sections arrive as they are written. This is what the browser app uses, and it is what lets a client show real progress: the section markers (FRONTMATTER:, SKILL:, RESOURCES:, REVIEW:) arrive in order, so each one is a genuine progress signal rather than a spinner.

EventDataUse
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A fragment of the reply. Append it; watch for the section markers.
done{job_id, status, charged_credits, truncated, output}The final, authoritative result — read the skill from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message, job_id}The run failed. Anything already appended from delta events is still worth keeping.
curl -N -s "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: skill-studio:9f2ab13c:a1" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
# event: delta
# data: {"text":"NAME: postgres-migration-reviewer\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":412,"output":{"output":"NAME: ..."}}
import json, requests

with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": f"Bearer {TOKEN}",
                            "Idempotency-Key": idem(payload)}) as res:
    res.raise_for_status()
    event, text = None, ""
    for line in res.iter_lines(decode_unicode=True):
        if line is None or line == "":
            continue
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data = json.loads(line[5:].strip())
            if event == "delta":
                text += data.get("text", "")
            elif event == "done":
                text = data["output"]["output"]      # authoritative
                print(data["charged_credits"], "credits charged")
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

open("SKILL.md", "w").write(text)
const res = await fetch(`${API}/run-stream`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idem(payload),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "";

for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  let i;
  while ((i = buffer.indexOf("\n\n")) >= 0) {
    const frame = buffer.slice(0, i);
    buffer = buffer.slice(i + 2);
    const event = /^event:\s*(.*)$/m.exec(frame)?.[1];
    const raw = /^data:\s*(.*)$/m.exec(frame)?.[1];
    if (!raw) continue;
    const data = JSON.parse(raw);
    if (event === "delta") text += data.text ?? "";
    else if (event === "done") text = data.output.output;
    else if (event === "error") throw new Error(data.message);
  }
}
console.log(text.split("\n", 1)[0]);   // NAME: ...
import "bufio"

req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewBufferString(bodyJSON))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "skill-studio:9f2ab13c:a1")

res, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer res.Body.Close()

scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
var event, text string
for scanner.Scan() {
	line := scanner.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var d struct {
			Text   string `json:"text"`
			Output struct{ Output string `json:"output"` } `json:"output"`
		}
		json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d)
		if event == "delta" {
			text += d.Text
		} else if event == "done" {
			text = d.Output.Output
		}
	}
}
os.WriteFile("SKILL.md", []byte(text), 0o644)
HttpRequest req = HttpRequest.newBuilder(URI.create(SkillSafe.API + "/run-stream"))
    .header("Authorization", "Bearer " + SkillSafe.TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "skill-studio:9f2ab13c:a1")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

StringBuilder text = new StringBuilder();
SkillSafe.CLIENT.send(req, HttpResponse.BodyHandlers.ofLines())
    .body()
    .forEach(line -> {
        // Frames are "event: <name>" then "data: {json}" then a blank line.
        // Append data.text on delta; replace with data.output.output on done.
        if (line.startsWith("data:")) System.out.println(line);
    });
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = idem(payload)
req.body = JSON.dump(payload)

text  = +""
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|
        line = line.strip
        next if line.empty?
        if line.start_with?("event:")
          event = line[6..].strip
        elsif line.start_with?("data:")
          data = JSON.parse(line[5..].strip)
          text << data["text"].to_s   if event == "delta"
          text = data["output"]["output"] if event == "done"
        end
      end
    end
  end
end
File.write("SKILL.md", text)
$ch = curl_init(API . "/run-stream");
$text  = "";
$event = null;
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: " . idem($payload),
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { $text .= $data["text"] ?? ""; }
                if ($event === "done")  { $text  = $data["output"]["output"]; }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
file_put_contents("SKILL.md", $text);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.TryAddWithoutValidation("Idempotency-Key", Idem(payload));
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

using var res = await new HttpClient().SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null;
var text = new StringBuilder();
while (await reader.ReadLineAsync() is string line) {
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); }
    else if (line.StartsWith("data:")) {
        var data = JsonDocument.Parse(line[5..].Trim()).RootElement;
        if (evt == "delta") text.Append(data.GetProperty("text").GetString());
        else if (evt == "done")
            text = new StringBuilder(data.GetProperty("output").GetProperty("output").GetString());
    }
}
await File.WriteAllTextAsync("SKILL.md", text.ToString());

Step 6 — Read back the skills you have written

GET /data/history_v1

The browser app keeps your past skills on your account rather than in one browser, under the per-user key history_v1. The same token reads it, so a script can pick up where the web app left off — fetch the last skill, revise it, and write the result back to your repository. Each entry carries name, verdict, summary, frontmatter, skill, resources, review, the input that produced it, and ts. A 404 simply means nothing has been saved yet.

curl -s "$API/data/history_v1" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq -r '.data.value[0] | "\(.name)  \(.verdict)"'

# Feed the most recent skill straight back in as a revision:
curl -s "$API/data/history_v1" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq '{brief: .data.value[0].input.brief,
         existing: (.data.value[0].frontmatter | "---\n" + . + "\n---\n") + .data.value[0].skill,
         mode: "Review"}' > revision.json
history = api("GET", "/data/history_v1")["value"] or []
for entry in history:
    print(entry["ts"], entry["name"], "-", entry["verdict"])

if history:
    latest = history[0]
    revision = {
        "brief": (latest["input"] or {}).get("brief", ""),
        "existing": f"---\n{latest['frontmatter']}\n---\n\n{latest['skill']}",
        "mode": "Review",
    }
    # revision is now a valid /estimate and /run body
const { value: history = [] } = await api("GET", "/data/history_v1");
for (const e of history) console.log(new Date(e.ts).toISOString(), e.name, e.verdict);

const latest = history[0];
const revision = latest && {
  brief: latest.input?.brief ?? "",
  existing: `---\n${latest.frontmatter}\n---\n\n${latest.skill}`,
  mode: "Review",
};
var store struct {
	Value []struct {
		TS          int64  `json:"ts"`
		Name        string `json:"name"`
		Verdict     string `json:"verdict"`
		Frontmatter string `json:"frontmatter"`
		Skill       string `json:"skill"`
	} `json:"value"`
}
if err := call("GET", "/data/history_v1", nil, &store, nil); err != nil {
	panic(err) // a 404 here just means nothing has been saved yet
}
for _, e := range store.Value {
	fmt.Println(e.Name, "-", e.Verdict)
}
String history = SkillSafe.api("GET", "/data/history_v1", null, null);
// {"data":{"value":[{"name":"...","verdict":"...","frontmatter":"...","skill":"..."}, ...]}}
// Rebuild a revision body as: {"existing": "---\n" + frontmatter + "\n---\n\n" + skill,
//                              "mode": "Review"}
System.out.println(history);
history = api("GET", "/data/history_v1")["value"] || []
history.each { |e| puts "#{e["name"]} — #{e["verdict"]}" }

latest = history.first
revision = latest && {
  "brief"    => latest.dig("input", "brief").to_s,
  "existing" => "---\n#{latest["frontmatter"]}\n---\n\n#{latest["skill"]}",
  "mode"     => "Review"
}
$history = api("GET", "/data/history_v1")["value"] ?? [];
foreach ($history as $e) {
    printf("%s — %s\n", $e["name"], $e["verdict"]);
}

$latest = $history[0] ?? null;
$revision = $latest ? [
    "brief"    => $latest["input"]["brief"] ?? "",
    "existing" => "---\n" . $latest["frontmatter"] . "\n---\n\n" . $latest["skill"],
    "mode"     => "Review",
] : null;
var store = await SkillSafe.ApiAsync(HttpMethod.Get, "/data/history_v1");
foreach (var e in store.GetProperty("value").EnumerateArray()) {
    Console.WriteLine($"{e.GetProperty("name").GetString()} — " +
                      $"{e.GetProperty("verdict").GetString()}");
}

var latest = store.GetProperty("value")[0];
var revision = new {
    existing = $"---\n{latest.GetProperty("frontmatter").GetString()}\n---\n\n" +
               latest.GetProperty("skill").GetString(),
    mode = "Review"
};

A skill is a directory, not a lone file. The RESOURCES: section names the other files — scripts/…, references/….md, assets/… — that the body links to, so a packaging script should create the tree, not just write SKILL.md. The web app's "Download the skill folder" button does exactly this, emitting a stub for every planned path; the plan deliberately does not contain those files' contents, because inventing them is the failure mode this app exists to avoid.