Theme Foundry — API

Design themes over plain JSON

API tokens Open the app

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.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. guests forging a custom theme).
404Unknown job id.
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.

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

POST /guest

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

GET /me

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

POST /estimate

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 fieldTypeNotes
briefstring, required What is being styled, for whom, and the mood it should carry — the primary signal.
artifactstring "slides", "landing-page", "document", "dashboard" or "report".
modestring "light", "dark" or "either" (the model chooses and justifies it).
seed_colorstring, optional A 6-digit hex ("#e8720c") the theme must be built around; it appears verbatim as the primary role.
avoidstring, optional Colors, moods or clichés to stay away from. Honored literally; wins over the brief on conflict.
start_fromstring, 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_reportobject, 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

POST /run
GET /jobs/{job_id}

/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:

FieldType
namestring — two-or-three-word theme name
storystring — 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_scalearray 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_rulesstring[] — at least 4 imperative rules
closest_preset {name, why_different} — anchored to one of the ten preset themes
accessibilitystring — which pairings are AA-safe, which are large-text only
summarystring — 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

POST /run-stream

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.