← Band Name Generator / API
Get a token

Drive Band Name Generator from your own code

Everything the web app does goes through one public surface. Base URL:

https://api.skillsafe.ai/v1/app-api

Every request carries Authorization: Bearer <token> and Content-Type: application/json. Every response is a JSON envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code":"...","message":"..."}} on failure. Read error.code, not the HTTP status alone.

The one thing to know before you automate this

The corpus check that makes this app worth using runs in the browser, not on the server. The API returns the model's own taken_risk guess and nothing more. If you drive this from code, you are responsible for your own collision checking, and taken_risk must never be treated as an availability signal.

Errors

CodeMeaningWhat to do
UNAUTHORIZEDMissing, malformed or expired bearer token.Mint a fresh token from the token page, or sign in again.
FORBIDDENThe token is valid but not for this app.Tokens are per-app. Use one issued for band-name-generator.
INSUFFICIENT_CREDITSBalance is below min_credits for this run.Top up. Between min_credits and hold_credits the run still executes with a reduced cap and returns truncated:true.
VALIDATION_ERRORThe request body was rejected.Check error.details. Note /estimate does NOT validate -- only /run and /run-stream do.
RATE_LIMITEDToo many requests.Back off and retry; do not tight-loop.
JOB_NOT_FOUNDUnknown job id on /jobs/{id}.Job ids are per-subject; confirm the same token that submitted it is polling.
INTERNALServer-side failure.Retry once with the SAME Idempotency-Key so a partial charge is not repeated.

The input object

Taken from app.js, which is what the web app actually submits.

FieldTypeNotes
taskstringEither "names" (a first batch) or "more" (a follow-up).
genrestringRequired. Free text; matched to one of 22 genre families, longest alias wins.
moodstringFree text. The emotional register the names should sit in.
reference_actsstring[]Up to 6. A direction, not a template -- echoing them is treated as a failure.
avoid_wordsstring[]Up to 12 words to keep out of the names.
countnumber3, 5 or 7. Always smaller than the device inventory, so covering the set is impossible.
brief.device_inventoryobject[]The genre's naming devices: id, label, hint, theme, shape.
brief.suggested_devicesstring[]Three ids drawn at random. Explicitly optional.
brief.avoid_openingsstring[]Openings already used this session, two content words deep.
previous_namesstring[]Only on task "more". The names already produced.
{
  "task": "names",
  "genre": "black metal",
  "mood": "funereal, remote, cold",
  "reference_acts": [],
  "avoid_words": [],
  "count": 5,
  "brief": {
    "genre_id": "black_metal",
    "genre_label": "black metal",
    "register": "Cold, remote and unwelcoming...",
    "avoid": "Anything cute, colloquial...",
    "device_inventory": [
      {
        "id": "bm_nordic",
        "label": "a Norwegian or Icelandic compound",
        "hint": "one compound word from a Nordic language, left untranslated",
        "theme": "geography",
        "shape": "foreign"
      }
    ],
    "suggested_devices": [
      "bm_nordic",
      "bm_cosmo",
      "bm_archaic"
    ],
    "count": 5,
    "seed": 123456,
    "avoid_openings": []
  }
}

1. A tiny client helper

Every call is a POST with a bearer token and a JSON body, and every response is the same {ok, data, error} envelope. Wrap that once.

# A shell helper. Keep the token out of your history: read it from a file
# or an exported variable rather than pasting it into the command.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"

call() {  # call <path> <json-body>
  curl -sS -X POST "$BASE$1" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$2"
}
import json, os, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("BAND_NAME_LAB_TOKEN", "YOUR_TOKEN")

def call(path, body=None, method="POST"):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        payload = json.load(r)
    if not payload.get("ok"):
        raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
    return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";

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

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

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

type envelope struct {
    OK    bool            `json:"ok"`
    Data  json.RawMessage `json:"data"`
    Error *struct {
        Code    string `json:"code"`
        Message string `json:"message"`
    } `json:"error"`
}

func call(path string, body any) (json.RawMessage, error) {
    token := os.Getenv("BAND_NAME_LAB_TOKEN")
    if token == "" {
        token = "YOUR_TOKEN"
    }
    var buf io.Reader
    if body != nil {
        b, _ := json.Marshal(body)
        buf = bytes.NewReader(b)
    }
    req, _ := http.NewRequest("POST", base+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 nil, err
    }
    defer res.Body.Close()
    var e envelope
    if err := json.NewDecoder(res.Body).Decode(&e); err != nil {
        return nil, err
    }
    if !e.OK {
        return nil, fmt.Errorf("%s: %s", e.Error.Code, e.Error.Message)
    }
    return e.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Optional;

public class BandNameGenerator {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN =
        Optional.ofNullable(System.getenv("BAND_NAME_LAB_TOKEN")).orElse("YOUR_TOKEN");
    static final HttpClient CLIENT = HttpClient.newHttpClient();

    static String call(String path, String jsonBody) throws Exception {
        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create(BASE + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(
                jsonBody == null ? "{}" : jsonBody))
            .build();
        HttpResponse<String> res =
            CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
        return res.body();   // parse the {ok,data,error} envelope with your JSON library
    }
}
require "json"
require "net/http"
require "uri"

BASE  = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("BAND_NAME_LAB_TOKEN", "YOUR_TOKEN")

def call(path, body = nil)
  uri = URI(BASE + path)
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  req.body = JSON.dump(body) unless body.nil?

  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
  payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";

function call(string $path, ?array $body = null): array {
    $token = getenv("BAND_NAME_LAB_TOKEN") ?: "YOUR_TOKEN";
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . $token,
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? "{}" : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new RuntimeException(
            $payload["error"]["code"] . ": " . $payload["error"]["message"]);
    }
    return $payload["data"];
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

public static class BandNameGenerator {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token =
        Environment.GetEnvironmentVariable("BAND_NAME_LAB_TOKEN") ?? "YOUR_TOKEN";
    static readonly HttpClient Client = new HttpClient();

    public static async Task<JsonElement> CallAsync(string path, object body = null) {
        var req = new HttpRequestMessage(HttpMethod.Post, Base + path);
        req.Headers.Add("Authorization", "Bearer " + Token);
        req.Content = new StringContent(
            body == null ? "{}" : JsonSerializer.Serialize(body),
            Encoding.UTF8, "application/json");

        var res = await Client.SendAsync(req);
        var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        var root = doc.RootElement;
        if (!root.GetProperty("ok").GetBoolean()) {
            var err = root.GetProperty("error");
            throw new Exception(err.GetProperty("code").GetString() + ": " +
                                err.GetProperty("message").GetString());
        }
        return root.GetProperty("data");
    }
}

2. Check who you are with /me

Confirms the token works and reports the balance. /me carries exactly three fields: subject_type, subject_id and credits. Signed in means subject_type is "user"; a guest token also returns 200 here, so read the field.

curl -sS "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $TOKEN"

# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
# subject_type is "user" when signed in and "guest" otherwise. Those three
# fields are the whole of /me -- there is no email, name or plan on it.
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])

# Signed in means subject_type == "user". A guest token also succeeds here,
# so check the field rather than treating a 200 as proof of sign-in.
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);

// Signed in means subject_type === "user"; a guest token also gets a 200.
data, err := call("/me", nil)
if err != nil {
    panic(err)
}
var me struct {
    SubjectType string `json:"subject_type"`
    SubjectID   string `json:"subject_id"`
    Credits     int    `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
String body = call("/me", null);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
// Parse the envelope, then read data.subject_type and data.credits.
System.out.println(body);
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"

# subject_type is "user" when signed in, "guest" otherwise.
<?php
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
// subject_type is "user" when signed in, "guest" otherwise.
var me = await BandNameGenerator.CallAsync("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());

3. Price it with /estimate

Free, and it creates no job. It returns the model binding and a hold_credits reservation priced at the full output cap — not the price. It performs no validation whatsoever on the body, so a clean estimate proves the model binding and nothing about your input shape.

call /estimate '{"task": "names", "genre": "black metal", "mood": "funereal, remote, cold", "count": 5, "brief": {"device_inventory": [{"id": "bm_nordic"}]}}'

# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":3120,"min_credits":640}}
#
# WARNING: /estimate does NO validation on the body. A bare string, a null or
# an empty array all return ok:true with a plausible hold. A clean estimate
# proves the model binding, NOT that your input shape is right.
est = call("/estimate", run_input)
print(est["hold_credits"], est["model"], est["model_alias"])

# hold_credits is a RESERVATION priced at the full output cap, not the price.
# What is actually charged is usually much lower and comes back on the job.
#
# /estimate validates nothing: passing "" or None or [] still returns ok:true
# with a sensible-looking hold. Validate the object yourself before sending.
const est = await call("/estimate", runInput);
console.log(est.hold_credits, est.model, est.model_alias);

// hold_credits is a reservation priced at the full output cap, not the price.
// /estimate performs no validation on the body -- a bare string returns ok:true
// with a plausible hold -- so check your own input shape before calling it.
data, err := call("/estimate", runInput)
if err != nil {
    panic(err)
}
var est struct {
    Model       string `json:"model"`
    ModelAlias  string `json:"model_alias"`
    MarkupBps   int    `json:"markup_bps"`
    HoldCredits int    `json:"hold_credits"`
    MinCredits  int    `json:"min_credits"`
}
json.Unmarshal(data, &est)
fmt.Println(est.HoldCredits, est.Model)
String est = call("/estimate", runInputJson);
// data.hold_credits is a reservation at the full output cap, not the price.
// data.model is "gpt-5.6-terra"; data.model_alias is "gpt-terra".
System.out.println(est);
est = call("/estimate", run_input)
puts "#{est["hold_credits"]} #{est["model"]}"

# hold_credits is a reservation, not the price. Note /estimate does not
# validate the body at all -- verify your own input before sending it.
<?php
$est = call("/estimate", $runInput);
echo $est["hold_credits"], " ", $est["model"], PHP_EOL;
// hold_credits reserves the full output cap; the charge is usually lower.
var est = await BandNameGenerator.CallAsync("/estimate", runInput);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("model").GetString());

4. Run it and poll

Submit to /run, then poll /jobs/{job_id} until status is succeeded or failed. Always send an Idempotency-Key so a network blip cannot double-bill.

# Submit, then poll. Send an Idempotency-Key so a retry cannot double-bill.
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: band-name-generator:names:$(date +%s)" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  R=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(printf '%s' "$R" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
  sleep 1
done
printf '%s' "$R"
import time, uuid

job = call("/run", run_input)          # add an Idempotency-Key header in real use
job_id = job["job_id"]

while True:
    j = call("/jobs/" + job_id, method="GET")
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(1)

result = json.loads(j["output"]["output"])   # the model's JSON object
print(result["names"][0]["name"], j.get("charged_credits"))
const job = await call("/run", runInput);   // send an Idempotency-Key in real use
let j;
for (;;) {
  j = await call(`/jobs/${job.job_id}`, undefined, "GET");
  if (j.status === "succeeded" || j.status === "failed") break;
  await new Promise(r => setTimeout(r, 1000));
}
const result = JSON.parse(j.output.output);
console.log(result.names[0].name, j.charged_credits);
data, _ := call("/run", runInput)
var job struct {
    JobID string `json:"job_id"`
}
json.Unmarshal(data, &job)

for {
    d, err := call("/jobs/"+job.JobID, nil)
    if err != nil {
        panic(err)
    }
    var j struct {
        Status         string `json:"status"`
        ChargedCredits int    `json:"charged_credits"`
        Output         struct {
            Output string `json:"output"`
        } `json:"output"`
    }
    json.Unmarshal(d, &j)
    if j.Status == "succeeded" || j.Status == "failed" {
        fmt.Println(j.Status, j.Output.Output)
        break
    }
    time.Sleep(time.Second)
}
String submitted = call("/run", runInputJson);
// read data.job_id from the envelope, then poll:
//   GET /jobs/{job_id} until data.status is "succeeded" or "failed"
// data.output.output holds the model's JSON object as a string.
// Always send an Idempotency-Key header so a retry cannot double-bill.
System.out.println(submitted);
job = call("/run", run_input)      # send an Idempotency-Key header in real use
loop do
  @j = call("/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 1
end
result = JSON.parse(@j["output"]["output"])
puts result["names"].first["name"]
<?php
$job = call("/run", $runInput);   // send an Idempotency-Key header in real use
do {
    sleep(1);
    $j = call("/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"], true));

$result = json_decode($j["output"]["output"], true);
echo $result["names"][0]["name"], PHP_EOL;
var job = await BandNameGenerator.CallAsync("/run", runInput);
var jobId = job.GetProperty("job_id").GetString();

JsonElement j;
while (true) {
    j = await BandNameGenerator.CallAsync("/jobs/" + jobId);
    var status = j.GetProperty("status").GetString();
    if (status == "succeeded" || status == "failed") break;
    await Task.Delay(1000);
}
var result = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString());
Console.WriteLine(result.RootElement.GetProperty("names")[0].GetProperty("name").GetString());

5. Or stream it with /run-stream

Server-sent events, so you can show progress while the batch is written.

curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: band-name-generator:names:run-1" \
  -d "$INPUT"

# Wire format -- one event per block, terminated by a blank line:
#
#   event: job
#   data: {"job_id":"job_..."}
#
#   event: delta
#   data: {"text":"{\"genre_read\":\"co"}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":2140,"truncated":false}
#
# Event names are: job, delta, done, pending, error. Concatenate the `text`
# field of every delta event to rebuild the model's JSON object.
req = urllib.request.Request(BASE + "/run-stream",
                             data=json.dumps(run_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "band-name-generator:names:run-1")

event, buf = None, ""
with urllib.request.urlopen(req) as r:
    for raw in r:
        line = raw.decode().rstrip("\n")
        if line.startswith("event: "):
            event = line[7:]
        elif line.startswith("data: "):
            payload = json.loads(line[6:])
            if event == "delta":
                buf += payload["text"]
            elif event == "done":
                print("charged", payload.get("charged_credits"))
        elif line == "":
            event = None

result = json.loads(buf)
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "band-name-generator:names:run-1"
  },
  body: JSON.stringify(runInput)
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let carry = "", event = null, buf = "";

for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  carry += decoder.decode(value, { stream: true });
  const lines = carry.split("\n");
  carry = lines.pop();
  for (const line of lines) {
    if (line.startsWith("event: ")) event = line.slice(7);
    else if (line.startsWith("data: ")) {
      const payload = JSON.parse(line.slice(6));
      if (event === "delta") buf += payload.text;
      if (event === "done") console.log("charged", payload.charged_credits);
    } else if (line === "") event = null;
  }
}
const result = JSON.parse(buf);
body, _ := json.Marshal(runInput)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "band-name-generator:names:run-1")

res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
    line := sc.Text()
    switch {
    case strings.HasPrefix(line, "event: "):
        event = strings.TrimPrefix(line, "event: ")
    case strings.HasPrefix(line, "data: "):
        var p struct {
            Text           string `json:"text"`
            ChargedCredits int    `json:"charged_credits"`
        }
        json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &p)
        if event == "delta" {
            buf += p.Text
        }
    case line == "":
        event = ""
    }
}
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create(BASE + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "band-name-generator:names:run-1")
    .POST(HttpRequest.BodyPublishers.ofString(runInputJson))
    .build();

StringBuilder buf = new StringBuilder();
String[] event = {null};
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.startsWith("event: ")) {
        event[0] = line.substring(7);
    } else if (line.startsWith("data: ") && "delta".equals(event[0])) {
        // append the "text" field of the JSON payload to buf
        buf.append(extractText(line.substring(6)));
    } else if (line.isEmpty()) {
        event[0] = null;
    }
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = "band-name-generator:names:run-1"
req.body = JSON.dump(run_input)

event = nil
buf   = +""
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.chomp
        if line.start_with?("event: ") then event = line[7..]
        elsif line.start_with?("data: ")
          payload = JSON.parse(line[6..])
          buf << payload["text"] if event == "delta"
        elsif line.empty? then event = nil
        end
      end
    end
  end
end
result = JSON.parse(buf)
<?php
$event = null;
$buf   = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . $token,
        "Content-Type: application/json",
        "Idempotency-Key: band-name-generator:names:run-1",
    ],
    CURLOPT_POSTFIELDS    => json_encode($runInput),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$buf) {
        foreach (explode("\n", $chunk) as $line) {
            $line = rtrim($line, "\r");
            if (str_starts_with($line, "event: ")) {
                $event = substr($line, 7);
            } elseif (str_starts_with($line, "data: ")) {
                $p = json_decode(substr($line, 6), true);
                if ($event === "delta") { $buf .= $p["text"]; }
            } elseif ($line === "") {
                $event = null;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($buf, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", "band-name-generator:names:run-1");
req.Content = new StringContent(JsonSerializer.Serialize(runInput),
                                Encoding.UTF8, "application/json");

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

string evt = null;
var buf = new StringBuilder();
while (!reader.EndOfStream) {
    var line = await reader.ReadLineAsync();
    if (line.StartsWith("event: ")) evt = line.Substring(7);
    else if (line.StartsWith("data: ")) {
        var p = JsonDocument.Parse(line.Substring(6)).RootElement;
        if (evt == "delta") buf.Append(p.GetProperty("text").GetString());
    } else if (line.Length == 0) evt = null;
}
var result = JsonDocument.Parse(buf.ToString());

6. Ask for more, without getting variations

A follow-up batch is told what the previous one already did, so it goes somewhere else rather than rewording what you rejected.

# A follow-up batch that avoids what the first one already did.
cat > more.json <<'JSON'
{
  "task": "more",
  "genre": "black metal",
  "mood": "funereal, remote, cold",
  "count": 5,
  "previous_names": ["Sepulchral Vow", "Frostmere"],
  "brief": {
    "device_inventory": [{"id": "bm_nordic"}],
    "avoid_openings": ["sepulchral vow", "frostmer"]
  }
}
JSON
call /run-stream "$(cat more.json)"

# Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
previous = [n["name"] for n in result["names"]]

more_input = dict(run_input)
more_input["task"] = "more"
more_input["previous_names"] = previous
more_input["brief"] = dict(run_input["brief"])
more_input["brief"]["avoid_openings"] = [opening_key(n) for n in previous]

more = call("/estimate", more_input)   # re-estimate: the shape changed
print(more["hold_credits"])

# Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
const previous = result.names.map(n => n.name);

const moreInput = {
  ...runInput,
  task: "more",
  previous_names: previous,
  brief: { ...runInput.brief, avoid_openings: previous.map(openingKey) }
};

const est = await call("/estimate", moreInput);   // re-estimate; the shape changed

// Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
moreInput := runInput
moreInput["task"] = "more"
moreInput["previous_names"] = previousNames

brief := moreInput["brief"].(map[string]any)
brief["avoid_openings"] = openings

// Re-estimate whenever the shape changes -- a "more" run prices differently
// from a first run because previous_names is carried in the body.
data, err := call("/estimate", moreInput)

// Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
// Build the follow-up body: task "more", previous_names holding the names you
// already received, and brief.avoid_openings holding their openings.
//
// Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
String moreJson = buildMoreInput(runInputJson, previousNames);
String est = call("/estimate", moreJson);
previous = result["names"].map { |n| n["name"] }

more_input = run_input.merge(
  "task"            => "more",
  "previous_names"  => previous,
  "brief"           => run_input["brief"].merge("avoid_openings" => previous.map { |n| opening_key(n) })
)

est = call("/estimate", more_input)   # re-estimate; the shape changed

# Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
<?php
$previous = array_map(fn($n) => $n["name"], $result["names"]);

$moreInput = $runInput;
$moreInput["task"]           = "more";
$moreInput["previous_names"] = $previous;
$moreInput["brief"]["avoid_openings"] = array_map("opening_key", $previous);

$est = call("/estimate", $moreInput);   // re-estimate; the shape changed

// Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.
var previous = result.RootElement.GetProperty("names")
    .EnumerateArray().Select(n => n.GetProperty("name").GetString()).ToArray();

var moreInput = new Dictionary<string, object>(runInput) {
    ["task"]           = "more",
    ["previous_names"] = previous
};

// Re-estimate: a "more" run prices differently from a first run.
var est = await BandNameGenerator.CallAsync("/estimate", moreInput);

// Set task to "more", pass the names you already have in previous_names, and put their openings in brief.avoid_openings. An opening is the first TWO content words, lowercased and lightly stemmed, with articles and prepositions dropped -- so "making a promise" and "making the promises" are the SAME opening. Comparing only first words is too coarse to catch a repeated frame.

7. Read the output

One JSON object. Every entry carries the name, the device it used, why it fits, how it behaves as a wordmark, and the model's own risk read.

# What comes back, and what it does not tell you.
{
  "genre_read": "one or two sentences on how this genre names itself",
  "expected_count": 5,
  "names": [
    {
      "name": "",
      "device": "bm_nordic",
      "why_it_fits": "",
      "on_a_shirt": "",
      "taken_risk": "low",
      "taken_note": ""
    }
  ],
  "notes": ""
}

# The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
result = json.loads(job["output"]["output"])

for n in result["names"]:
    print(n["name"], "|", n["device"], "|", n["taken_risk"])
    print("  ", n["why_it_fits"])
    print("  ", n["on_a_shirt"])

# The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
const result = JSON.parse(job.output.output);

for (const n of result.names) {
  console.log(n.name, "|", n.device, "|", n.taken_risk);
  console.log("  ", n.why_it_fits);
  console.log("  ", n.on_a_shirt);
}

// The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
var result struct {
    GenreRead string `json:"genre_read"`
    Names     []struct {
        Name       string `json:"name"`
        Device     string `json:"device"`
        WhyItFits  string `json:"why_it_fits"`
        OnAShirt   string `json:"on_a_shirt"`
        TakenRisk  string `json:"taken_risk"`
        TakenNote  string `json:"taken_note"`
    } `json:"names"`
    Notes string `json:"notes"`
}
json.Unmarshal([]byte(jobOutput), &result)

// The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
// The model returns a single JSON object:
// {
//   "genre_read": "one or two sentences on how this genre names itself",
//   "expected_count": 5,
//   "names": [
//     {
//       "name": "",
//       "device": "bm_nordic",
//       "why_it_fits": "",
//       "on_a_shirt": "",
//       "taken_risk": "low",
//       "taken_note": ""
//     }
//   ],
//   "notes": ""
// }
//
// The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
result = JSON.parse(job["output"]["output"])

result["names"].each do |n|
  puts "#{n["name"]} | #{n["device"]} | #{n["taken_risk"]}"
  puts "  #{n["why_it_fits"]}"
  puts "  #{n["on_a_shirt"]}"
end

# The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
<?php
$result = json_decode($job["output"]["output"], true);

foreach ($result["names"] as $n) {
    echo $n["name"], " | ", $n["device"], " | ", $n["taken_risk"], PHP_EOL;
    echo "  ", $n["why_it_fits"], PHP_EOL;
    echo "  ", $n["on_a_shirt"], PHP_EOL;
}

// The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.
var result = JsonDocument.Parse(jobOutput).RootElement;

foreach (var n in result.GetProperty("names").EnumerateArray()) {
    Console.WriteLine($"{n.GetProperty("name").GetString()} | " +
                      $"{n.GetProperty("device").GetString()} | " +
                      $"{n.GetProperty("taken_risk").GetString()}");
}

// The API returns the model's own taken_risk judgement. It does NOT run the corpus check -- that lives in the browser. If you are driving this from code you must do your own collision checking, and you must not treat taken_risk as an availability signal. It is the model's guess.

Output contract

A single JSON object. expected_count equals the count requested, so a client can detect a truncated reply by comparing it against names.length.

{
  "genre_read": "one or two sentences on how this genre names itself",
  "expected_count": 5,
  "names": [
    {
      "name": "",
      "device": "bm_nordic",
      "why_it_fits": "",
      "on_a_shirt": "",
      "taken_risk": "low",
      "taken_note": ""
    }
  ],
  "notes": ""
}