Forge themes from your own code
Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS, so you can script it from any language. This page walks through each task with examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api. 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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. guests forging a custom theme). |
404 | Unknown job id. |
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.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a 15-line helper that adds the
auth header, sends JSON and unwraps the data envelope. The later steps
reuse this helper.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # see step 1
export TOKEN="$SKILLSAFE_TOKEN"
# every call looks like:
# curl -s "$API/…" -H "Authorization: Bearer $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 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"
)
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) 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")
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:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = 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))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances, estimate costs and run the built-in example
(free). To forge a theme from your own brief you need your personal token: open the
token page, sign in, and hit
"Copy shell export" — it puts export SKILLSAFE_TOKEN="…" on your clipboard,
which every example below reads. Treat the token like a password — it can spend your
credits. For fully headless scripts, POST /guest (below) mints a guest token
with no browser involved.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"theme-foundry"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "theme-foundry"})["token"]
const { token } = await api("POST", "/guest", { slug: "theme-foundry" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "theme-foundry"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"theme-foundry"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "theme-foundry" })["token"]
$token = api("POST", "/guest", ["slug" => "theme-foundry"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "theme-foundry" });
var token = guest.GetProperty("token").GetString();
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before an
expensive run.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — Estimate the cost
Send the same input you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created.
| Input field | Type | Notes |
|---|---|---|
brief | string, required | What is being styled, for whom, and the mood it should carry — the primary signal. |
artifact | string | "slides", "landing-page", "document",
"dashboard" or "report". |
mode | string | "light", "dark" or "either" (the model chooses and justifies it). |
seed_color | string, optional | A 6-digit hex ("#e8720c") the theme must be built around; it appears verbatim
as the primary role. |
avoid | string, optional | Colors, moods or clichés to stay away from. Honored literally; wins over the brief on conflict. |
start_from | string, optional | One of the ten preset names (Ocean Depths, Sunset Boulevard, Forest Canopy, Modern Minimalist, Golden Hour, Arctic Frost, Desert Rose, Tech Innovation, Botanical Garden, Midnight Galaxy) to adapt instead of inventing from scratch. |
contrast_report | object, optional | Locally computed WCAG ratio strings (e.g. {"seed_vs_white": "3.07:1 (AA large-text only)"})
the model must treat as ground truth. The web app computes this automatically; scripts may omit it. |
cat > input.json <<'JSON'
{
"brief": "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
"artifact": "landing-page",
"mode": "light",
"seed_color": "#e8720c"
}
JSON
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
INPUT = {
"brief": "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
"artifact": "landing-page",
"mode": "light",
"seed_color": "#e8720c",
}
est = api("POST", "/estimate", INPUT)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const input = {
brief: "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
artifact: "landing-page",
mode: "light",
seed_color: "#e8720c",
};
const est = await api("POST", "/estimate", input);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
input := map[string]any{
"brief": "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
"artifact": "landing-page",
"mode": "light",
"seed_color": "#e8720c",
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", input, &est)
static final String INPUT = """
{
"brief": "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
"artifact": "landing-page",
"mode": "light",
"seed_color": "#e8720c"
}""";
String envelope = api("POST", "/estimate", INPUT);
// worst-case cost is at data.hold_credits
INPUT = {
brief: "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
artifact: "landing-page",
mode: "light",
seed_color: "#e8720c",
}
est = api("POST", "/estimate", INPUT)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$input = [
"brief" => "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
"artifact" => "landing-page",
"mode" => "light",
"seed_color" => "#e8720c",
];
$est = api("POST", "/estimate", $input);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var input = new {
brief = "A landing page for a harbor-side kids' science museum — bright, warm, trustworthy, a bit playful but never clownish.",
artifact = "landing-page",
mode = "light",
seed_color = "#e8720c",
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
When you have a hard brand color, compute its WCAG ratios locally and pass them in
contrast_report — the model treats those numbers as ground truth instead of
estimating them, which is what keeps the returned pairings honest.
Step 4 — Forge the theme and wait for the result
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until
status is succeeded or failed (a run typically takes
20–60 s). Always send an Idempotency-Key header so a network retry can't
start a second, double-charged run. The theme is in output (sometimes
nested as output.output, and usually a JSON string — parse defensively).
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: run-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# output is a JSON string — fromjson turns it into the theme object
echo "$JOB" | jq -r '.data.output | if type == "string" then fromjson else . end
| {name, mode}'
import time
job_id = api("POST", "/run", INPUT,
**{"Idempotency-Key": "my-run-001"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
rec = json.loads(raw) if isinstance(raw, str) else raw
print(rec["name"], "(", rec["mode"], ")")
for sw in rec["palette"]:
print(sw["role"], sw["hex"])
const { job_id } = await api("POST", "/run", input,
{ "Idempotency-Key": crypto.randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
const rec = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(rec.name, `(${rec.mode})`);
for (const sw of rec.palette) console.log(sw.role, sw.hex);
var started struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", input, &started)
if err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
// job.Output holds the theme (may be {"output": …} or a JSON string —
// unwrap/unquote before unmarshalling into your own struct).
String envelope = api("POST", "/run", INPUT);
String jobId = /* data.job_id via your JSON library */;
while (true) {
String job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// the theme is at data.output (sometimes data.output.output, usually a
// JSON string — parse it again if so)
started = api("POST", "/run", INPUT)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
rec = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{rec["name"]} (#{rec["mode"]})"
$started = api("POST", "/run", $input);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$rec = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$rec['name']} ({$rec['mode']})\n";
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", input);
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(1500);
}
// the theme is at job.GetProperty("output") — sometimes nested under
// "output", usually a JSON string; parse defensively.
Once parsed, the theme object has this shape:
| Field | Type |
|---|---|
name | string — two-or-three-word theme name |
story | string — 3–5 sentences on what the theme evokes and why it fits |
mode | "light" | "dark" |
palette |
array of {role, hex, name, usage} — always includes the seven roles
background, surface, text, muted,
primary, accent, border (up to three extra roles allowed),
each with a valid 6-digit hex |
fonts |
{heading, body, mono}; each is {name, stack, weight} where
stack is a CSS font-family list ending in a generic family |
type_scale | array of {level, size_px, weight, use} — 4 rows |
pairings |
array of {bg_role, fg_role, purpose} — combinations the model claims are
WCAG-safe; the web app recomputes every ratio locally and flags disagreements |
usage_rules | string[] — at least 4 imperative rules |
closest_preset |
{name, why_different} — anchored to one of the ten preset themes |
accessibility | string — which pairings are AA-safe, which are large-text only |
summary | string — 2–3 sentence handoff note |
Every top-level key is always present. Guard anyway: verify all seven required palette
roles carry valid hex values before applying a theme, and recompute contrast ratios
yourself (WCAG 2.1 relative luminance) rather than trusting pairings blindly —
that is exactly what the web app does.
Step 5 — Stream the answer as it is written
Same body as /run, but the response is
text/event-stream: delta events carry
{"text": "…"} fragments of the JSON as the model writes it, an optional
job event announces the job_id, and a final done
event carries {job_id, status, charged_credits, output}. An
error event carries {code, message}. If the server answers with
JSON instead of an event stream (an idempotent replay), treat the body as a normal
/run response. Concatenating every delta gives you the same JSON
string as output.
curl -sN -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: stream-$(date +%s)" \
-d @input.json
with requests.post(API + "/run-stream", json=INPUT, stream=True, headers={
"Authorization": f"Bearer {TOKEN}",
"Accept": "text/event-stream",
"Idempotency-Key": "my-stream-001",
}) as res:
res.raise_for_status()
event, pieces, done = "message", [], None
for line in res.iter_lines(decode_unicode=True):
if line is None:
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
pieces.append(payload.get("text", ""))
elif event == "done":
done = payload
elif event == "error":
raise RuntimeError(payload.get("message", "run failed"))
rec = json.loads("".join(pieces))
print(rec["name"], "charged:", done["charged_credits"])
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(input),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const payload = JSON.parse(data);
if (event === "delta") text += payload.text ?? "";
else if (event === "done") done = payload;
else if (event === "error") throw new Error(payload.message ?? "run failed");
}
}
const rec = JSON.parse(text);
console.log(rec.name, "charged:", done.charged_credits);
body, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var out strings.Builder
event := "message"
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var payload struct {
Text string `json:"text"`
Message string `json:"message"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &payload)
if event == "delta" {
out.WriteString(payload.Text)
} else if event == "error" {
log.Fatal(payload.Message)
}
}
}
// out.String() is the theme JSON — unmarshal it into your struct.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(INPUT))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
var out = new StringBuilder();
var event = new String[] { "message" };
res.body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:") && event[0].equals("delta")) {
// data is {"text": "…"} — append the decoded text with your JSON library
out.append(textOf(line.substring(5).trim()));
}
});
// out.toString() is the theme JSON
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req.body = INPUT.to_json
out = +""
event = "message"
buffer = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
res.read_body do |chunk|
buffer << chunk
while (i = buffer.index("\n"))
line = buffer.slice!(0, i + 1).chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
payload = JSON.parse(line[5..].strip) rescue next
out << payload.fetch("text", "") if event == "delta"
raise payload["message"] if event == "error"
end
end
end
end
end
rec = JSON.parse(out)
puts rec["name"]
$out = "";
$event = "message";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out, &$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:") && $event === "delta") {
$payload = json_decode(trim(substr($line, 5)), true);
$out .= $payload["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$rec = json_decode($out, true);
echo $rec["name"] . "\n";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
{
Content = JsonContent.Create(input),
};
req.Headers.Accept.Add(new("text/event-stream"));
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new StringBuilder();
var evt = "message";
while (await reader.ReadLineAsync() is string line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
{
var payload = JsonSerializer.Deserialize<JsonElement>(line[5..].Trim());
text.Append(payload.GetProperty("text").GetString());
}
}
// text.ToString() is the theme JSON
var rec = JsonSerializer.Deserialize<JsonElement>(text.ToString());
Console.WriteLine(rec.GetProperty("name"));
Streaming charges the same credits as /run. Send an
Idempotency-Key here too — a dropped connection you retry without one is a
second, separately billed run.