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.
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": …}.
| Status | Meaning |
|---|---|
400 | Malformed body, or an input field of the wrong type. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large paste). |
404 | Unknown job id, unknown data key, or a path with the slug wrongly inserted. |
429 | Rate limited — back off and retry. |
5xx | Transient 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.
| Field | Type | What it is |
|---|---|---|
brief | string | The 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. |
triggers | string | Example requests that should activate the skill, one per line. They are folded into the frontmatter description. Clipped at 2,000 characters. |
name | string | An optional working name. A non-kebab-case name is corrected and the correction is reported in the review. |
existing | string | A SKILL.md you already have. When present the result is a revision of it rather than a fresh skill. Clipped at 40,000 characters. |
mode | string | "Create" or "Review". "Review" means auditing the existing skill is the whole job. Defaults to "Create". |
lint | string[] | 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_note | string | Only 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>.
| Section | Required | Parsing rule |
|---|---|---|
NAME: | yes | The first line beginning NAME:; the remainder, trimmed. An empty name fails the parse. |
VERDICT: | yes | The first later line beginning VERDICT:. Must normalise to one of the three values or the parse fails. |
SUMMARY: | no | One line, no markdown. |
FRONTMATTER: | no | Everything between a line reading exactly FRONTMATTER: and the SKILL: line. Any --- rails and any code fence are stripped. |
SKILL: | yes | Everything between a line reading exactly SKILL: and RESOURCES: (or REVIEW:, or the end). An empty body fails the parse. |
RESOURCES: | no | Everything up to REVIEW:. |
REVIEW: | no | Everything 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
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
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
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.
Event Data Use
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.