Drive Baby Name Generator from your own code
Everything the web page does is available over HTTP: send a brief describing the name someone is after — origins, sound, length, the surname it has to sit against, the siblings it has to live beside — and get the same structured shortlist back. Each candidate carries its meaning, its origin, an English pronunciation respelling, the short forms it takes, the initials and monogram it produces, and a note on how the whole name scans.
One thing to read before you build on it: a name's meaning and origin are factual claims the reader cannot check and will act on permanently. That is the reason every candidate carries an attestation label and the reason meaning_note is mandatory whenever the derivation is not settled. If you render this output for other people, carry the attestation through to them. A shortlist that shows eight clean etymologies and hides which two are disputed is worse than no shortlist, because it reads as more certain than it is.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:
{ "data": { ... } }
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "status": 422, "details": { ... } } }
Send your token as Authorization: Bearer … on every call. There is no X-App-Slug header — not on any endpoint. The token is already scoped to this app, and the one call that has to name the app carries the slug in its body: POST /guest with {"slug": "baby-name-generator"}. A slug header is accepted and ignored, which is exactly why it is worth saying out loud: a sample that sends one looks correct and proves nothing.
The other thing worth saying before any code: the run body is the input object itself. Do not wrap it in {"input": {...}}. The wrapper is not rejected — the call returns 200 with a plausible job — and the model then never sees a single one of your fields. You get a shortlist built from nothing, which is much harder to notice than an error.
Error codes
| code | status | what to do |
|---|---|---|
UNAUTHORIZED | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
FORBIDDEN | 403 | The token is valid but not for this app, or a guest token tried a metered run. Guests may call /me and /estimate; /run and /run-stream need a signed-in token. |
VALIDATION_ERROR | 422 | A field is the wrong type, or the brief is empty — every field is optional, but at least one of origins, sound, themes, siblings or notes has to carry something, or there is nothing to work from. A body that is not valid JSON at all comes back as a 400. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. Call /estimate first and top up. |
RATE_LIMITED | 429 | Too many requests. Back off and retry; do not tight-loop a poll. |
NOT_FOUND | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
INTERNAL | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that adds the headers, unwraps data and raises on error. Every later step uses it. For the token itself the shortest path is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Put this in your shell profile and the samples below
# read as "call estimate", "call run".
export SKILLSAFE_TOKEN="YOUR_TOKEN" # from /tokens.html
export SS_BASE="https://api.skillsafe.ai/v1/app-api"
call() {
# call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$SS_BASE/$1" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$SS_BASE/$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
fi
}
import json, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from /tokens.html
class SkillSafeError(RuntimeError):
def __init__(self, err):
self.code = err.get("code")
self.status = err.get("status")
super().__init__(f"{self.code}: {err.get('message')}")
def call(path, body=None, idempotency_key=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}/{path}", data=data,
method="POST" if data else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
if idempotency_key:
req.add_header("Idempotency-Key", idempotency_key)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if payload.get("error"):
raise SkillSafeError(payload["error"])
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
async function call(path, body, idempotencyKey) {
const headers = {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (payload.error) {
const e = payload.error;
throw Object.assign(new Error(`${e.code}: ${e.message}`), e);
}
return payload.data;
}
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from /tokens.html
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
func call(path string, body any, idempotencyKey string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html, or System.getenv("SKILLSAFE_TOKEN")
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody, String idempotencyKey) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (idempotencyKey != null) b = b.header("Idempotency-Key", idempotencyKey);
b = jsonBody == null ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("app-api " + res.statusCode() + ": " + res.body());
}
return res.body(); // {"data":{...}}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SKILLSAFE_TOKEN"] || "YOUR_TOKEN" # from /tokens.html
def call(path, body = nil, idempotency_key: nil)
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key if idempotency_key
req.body = JSON.generate(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.dig('error', 'code')}: #{payload.dig('error', 'message')}" if payload["error"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
function call(string $path, ?array $body = null, ?string $idempotencyKey = null): array {
$headers = ["Authorization: Bearer " . TOKEN, "Content-Type: application/json"];
if ($idempotencyKey !== null) {
$headers[] = "Idempotency-Key: " . $idempotencyKey;
}
$ch = curl_init(BASE . "/" . $path);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
static readonly HttpClient Http = new();
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static async Task<JsonElement> Call(string path, object body = null, string idempotencyKey = null) {
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);
if (body is not null) {
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var payload = JsonSerializer.Deserialize<JsonElement>(await res.Content.ReadAsStringAsync());
if (payload.TryGetProperty("error", out var err)) {
throw new Exception($"{err.GetProperty("code")}: {err.GetProperty("message")}");
}
return payload.GetProperty("data");
}
2. A guest token
POST /guest takes no auth and answers 201 with {token, guest_id, expires_at}. The slug goes in the body.
A guest token is enough to look around: it can call /me and /estimate. It cannot run a metered app — /run and /run-stream answer FORBIDDEN for a guest. Asking for a shortlist costs credits, so it needs a personal token from signing in on the token page.
# The slug goes in the BODY - there is no X-App-Slug header on any endpoint.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"baby-name-generator"}'
# 201 {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
#
# A guest can browse: /me and /estimate work. /run and /run-stream do not.
# A guest token can call /me and /estimate but cannot run a metered shortlist.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "baby-name-generator"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
GUEST_TOKEN = json.load(r)["data"]["token"]
// A guest token can call /me and /estimate but cannot run a metered shortlist.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "baby-name-generator" }),
});
const GUEST_TOKEN = (await res.json()).data.token;
// A guest token can call /me and /estimate but cannot run a metered shortlist.
guestBody := []byte(`{"slug":"baby-name-generator"}`)
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// A guest token can call /me and /estimate but cannot run a metered shortlist.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"baby-name-generator\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"data":{"token":"aut_...","guest_id":"gst_..."}}
# A guest token can call /me and /estimate but cannot run a metered shortlist.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "baby-name-generator" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
GUEST_TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// A guest token can call /me and /estimate but cannot run a metered shortlist.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "baby-name-generator"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// A guest token can call /me and /estimate but cannot run a metered shortlist.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"baby-name-generator\"}",
Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Who is calling, and what is the balance
GET /me returns exactly three fields — subject_type, subject_id and credits. There is no email, no name and no id, so the signed-in test is subject_type === "user" and nothing else. A guest reads subject_type === "guest". Checking for a field that is not in the payload is the standard way to get a signed-in user rendered as a stranger.
call me
# {"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
# Those three keys are the whole payload. There is no email and no name.
me = call("me")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user" # the only correct test
const me = await call("me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user"; // the only correct test
raw, err := call("me", nil, "")
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
signedIn := me.SubjectType == "user"
System.out.println(call("me", null, null));
// {"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
// subject_type is the signed-in test; there is no email or name field.
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
signed_in = me["subject_type"] == "user" # the only correct test
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
$signedIn = $me["subject_type"] === "user"; // the only correct test
var me = await Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt64());
var signedIn = me.GetProperty("subject_type").GetString() == "user";
4. Price the run — free
The input object is exactly what the app's own form submits. Every field is optional, but at least one of origins, sound, themes, siblings or notes has to carry something — those are the fields that describe what is wanted, and with all five empty there is no brief to answer.
| field | type | meaning |
|---|---|---|
surname | string | The surname the given name has to sit against. This is the field that drives the rhyme, seam and monogram checks: it is what makes Harper Harper-ish and A.S.S. catchable before anyone engraves anything. May be absent, and the checks that depend on it are then simply not run. |
middle | string | A middle name already decided. Fixes the centre of every monogram and adds a beat to every with_surname line. |
origins | string | Origins, languages or heritages the reader is drawn to. Free text — German and Irish on one side reads better than a list of ISO codes. |
sound | string | The texture wanted: soft, short and hard, vowel-heavy, ends on a consonant. Prose, not an enum. |
themes | string | Meanings or associations wanted — the sea, light, weather, a grandmother called Rose. |
syllables | string | "any", or "1", "2", "3", "4". A string, not a number. When it is numeric this is a hard constraint: every candidate must have exactly that many, and the browser re-counts each one and shows any that does not. |
usage | string | any · unisex · traditionally-feminine · traditionally-masculine. With any, the shortlist spans the range and is deliberately not sorted or captioned by gender. |
rarity | string | any, or one of the familiarity bands: very-familiar, familiar, uncommon, rare, very-rare. Aims the shortlist; it is not a filter applied afterwards. |
siblings | string | Comma-separated names already in the family. The most useful single field after surname: it is what turns “harmonises with” from a word the model defines for itself into a measured syllable range, a shared initial and a shared ending. |
avoid_names | string | Comma-separated names ruled out. Anything differing only in spelling is out too. |
avoid_initials | string | Comma-separated initial strings to avoid, e.g. "ASS, PIG". Two to four letters each. Checked against both the speaking order and the engraved monogram order, which are not the same string. |
notes | string | Free text. Read carefully by the model; the real constraint is very often here rather than in the structured fields. |
count | number | How many candidates to return. Default 8. Fewer come back only when the brief is narrow enough that fewer honest answers exist, and brief_read then says so. |
computed_facts | object | Facts the browser measured from the letters before the call. See below. |
refine | object | {of, goal, previous} — a second pass over the same brief. goal is what should change ("shorter", "less floral", "go rarer", "keep Ottilie, drop the rest") and previous is a compact form of the last shortlist. Everything the goal does not name is held steady, and refine_note comes back saying what moved and what was deliberately left alone. |
computed_facts is measured, not guessed
Before the browser calls the API it computes, from the spelling alone: the syllable count and split of the surname and of every sibling name, the opening sound and rime of the surname, each sibling's initial and ending, the shared shape across the siblings, the initial strings that are ruled out, and the syllable count the brief asks for. All of it is deterministic, it costs nothing, and it goes in computed_facts so the model is answering against measurements rather than its own impression of how many beats Ottilie has.
The shape is {facts: [{id, kind, text}], surname: {…}|null, siblings: […], sibling_shape: {…}|null}. Each fact carries an id (F01, F02, …) and a kind — surname, middle, siblings, sibling-precedent, avoid, avoid-initials, syllables — and a sentence of plain text the model reads directly.
Then the interesting half: the app re-derives every one of those quantities from the reply. Each returned candidate's syllable count is recounted, its pronunciation chunks are counted against it, its initials and engraved monogram are recomputed, its rhyme and seam against the surname are re-checked, and its nicknames are compared with the short forms the rules actually produce. Every disagreement is displayed next to the name it belongs to, never swallowed. A stated disagreement in scan_note is a fine answer; a silent one is a visible error.
So: an API caller who omits computed_facts still gets a working shortlist — the field is optional and the model reads surname, siblings and the rest either way. What you lose is the grounding. Send it if you can compute it, and if you cannot, at least know that the syllable counts coming back are unchecked assertions rather than checked ones.
/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held while the run is in flight, min_credits is the balance you must clear to start at all, and sponsor_enabled says whether the app is covering the run. The hold prices the full output cap, so the charged_credits you see after settlement is usually far lower. Budget against hold_credits, report against charged_credits.
INPUT='{
"surname": "Marsh",
"middle": "Mae",
"origins": "German and Irish on one side, nothing fixed on the other",
"sound": "soft, vowel-heavy, not landing on a hard consonant",
"themes": "light, weather, a grandmother called Rose",
"syllables": "3",
"usage": "traditionally-feminine",
"rarity": "uncommon",
"siblings": "Otto, Elsie",
"avoid_names": "Aurora, Matilda",
"avoid_initials": "ASS, PIG",
"notes": "The surname is one beat and ends hard, so nothing that rhymes with it. We say every name out loud across a garden before we decide.",
"count": 8,
"computed_facts": {
"facts": [
{"id": "F01", "kind": "surname",
"text": "The surname is Marsh: 1 syllable, read Marsh, starting on m and ending on the rime arsh."},
{"id": "F02", "kind": "middle",
"text": "The middle name is fixed as Mae (1 syllable), so every monogram already has its centre."},
{"id": "F03", "kind": "siblings",
"text": "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2."},
{"id": "F04", "kind": "avoid",
"text": "Ruled out by name: Aurora, Matilda. Anything differing only by spelling is also out."},
{"id": "F05", "kind": "avoid-initials", "text": "Initial strings to avoid: ASS, PIG."},
{"id": "F06", "kind": "syllables",
"text": "The brief asks for 3-syllable given names; each answer is checked against that."}
],
"surname": {"name": "Marsh", "syllables": 1, "split": "Marsh",
"initial": "M", "onset": "m", "rime": "arsh", "ending": "sh"},
"siblings": [
{"name": "Otto", "syllables": 2, "split": "Ot-to", "initial": "O", "rime": "to"},
{"name": "Elsie", "syllables": 2, "split": "El-sie", "initial": "E", "rime": "sie"}
],
"sibling_shape": {"count": 2, "syllable_min": 2, "syllable_max": 2,
"shared_initial": "", "shared_ending": "", "initials": "O, E"}
}
}'
call estimate "$INPUT"
# {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":2140,"min_credits":320,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.
INPUT = {
"surname": "Marsh",
"middle": "Mae",
"origins": "German and Irish on one side, nothing fixed on the other",
"sound": "soft, vowel-heavy, not landing on a hard consonant",
"themes": "light, weather, a grandmother called Rose",
"syllables": "3",
"usage": "traditionally-feminine",
"rarity": "uncommon",
"siblings": "Otto, Elsie",
"avoid_names": "Aurora, Matilda",
"avoid_initials": "ASS, PIG",
"notes": "The surname is one beat and ends hard, so nothing that rhymes with it. "
"We say every name out loud across a garden before we decide.",
"count": 8,
"computed_facts": {
"facts": [
{"id": "F01", "kind": "surname",
"text": "The surname is Marsh: 1 syllable, read Marsh, starting on m and ending on the rime arsh."},
{"id": "F02", "kind": "middle",
"text": "The middle name is fixed as Mae (1 syllable), so every monogram already has its centre."},
{"id": "F03", "kind": "siblings",
"text": "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2."},
{"id": "F04", "kind": "avoid",
"text": "Ruled out by name: Aurora, Matilda. Anything differing only by spelling is also out."},
{"id": "F05", "kind": "avoid-initials", "text": "Initial strings to avoid: ASS, PIG."},
{"id": "F06", "kind": "syllables",
"text": "The brief asks for 3-syllable given names; each answer is checked against that."},
],
"surname": {"name": "Marsh", "syllables": 1, "split": "Marsh",
"initial": "M", "onset": "m", "rime": "arsh", "ending": "sh"},
"siblings": [
{"name": "Otto", "syllables": 2, "split": "Ot-to", "initial": "O", "rime": "to"},
{"name": "Elsie", "syllables": 2, "split": "El-sie", "initial": "E", "rime": "sie"},
],
"sibling_shape": {"count": 2, "syllable_min": 2, "syllable_max": 2,
"shared_initial": "", "shared_ending": "", "initials": "O, E"},
},
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
const INPUT = {
surname: "Marsh",
middle: "Mae",
origins: "German and Irish on one side, nothing fixed on the other",
sound: "soft, vowel-heavy, not landing on a hard consonant",
themes: "light, weather, a grandmother called Rose",
syllables: "3",
usage: "traditionally-feminine",
rarity: "uncommon",
siblings: "Otto, Elsie",
avoid_names: "Aurora, Matilda",
avoid_initials: "ASS, PIG",
notes: "The surname is one beat and ends hard, so nothing that rhymes with it. " +
"We say every name out loud across a garden before we decide.",
count: 8,
computed_facts: {
facts: [
{ id: "F01", kind: "surname",
text: "The surname is Marsh: 1 syllable, read Marsh, starting on m and ending on the rime arsh." },
{ id: "F02", kind: "middle",
text: "The middle name is fixed as Mae (1 syllable), so every monogram already has its centre." },
{ id: "F03", kind: "siblings",
text: "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2." },
{ id: "F04", kind: "avoid",
text: "Ruled out by name: Aurora, Matilda. Anything differing only by spelling is also out." },
{ id: "F05", kind: "avoid-initials", text: "Initial strings to avoid: ASS, PIG." },
{ id: "F06", kind: "syllables",
text: "The brief asks for 3-syllable given names; each answer is checked against that." },
],
surname: { name: "Marsh", syllables: 1, split: "Marsh",
initial: "M", onset: "m", rime: "arsh", ending: "sh" },
siblings: [
{ name: "Otto", syllables: 2, split: "Ot-to", initial: "O", rime: "to" },
{ name: "Elsie", syllables: 2, split: "El-sie", initial: "E", rime: "sie" },
],
sibling_shape: { count: 2, syllable_min: 2, syllable_max: 2,
shared_initial: "", shared_ending: "", initials: "O, E" },
},
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// estimate is free: no job is created and nothing is charged.
input := map[string]any{
"surname": "Marsh",
"middle": "Mae",
"origins": "German and Irish on one side, nothing fixed on the other",
"sound": "soft, vowel-heavy, not landing on a hard consonant",
"themes": "light, weather, a grandmother called Rose",
"syllables": "3", // a STRING, not a number
"usage": "traditionally-feminine",
"rarity": "uncommon",
"siblings": "Otto, Elsie",
"avoid_names": "Aurora, Matilda",
"avoid_initials": "ASS, PIG",
"notes": "The surname is one beat and ends hard, so nothing that rhymes with it.",
"count": 8,
"computed_facts": map[string]any{
"facts": []map[string]string{
{"id": "F01", "kind": "surname",
"text": "The surname is Marsh: 1 syllable, read Marsh, starting on m and ending on the rime arsh."},
{"id": "F03", "kind": "siblings",
"text": "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2."},
{"id": "F06", "kind": "syllables",
"text": "The brief asks for 3-syllable given names; each answer is checked against that."},
},
"surname": map[string]any{"name": "Marsh", "syllables": 1, "split": "Marsh",
"initial": "M", "onset": "m", "rime": "arsh", "ending": "sh"},
"siblings": []map[string]any{
{"name": "Otto", "syllables": 2, "split": "Ot-to", "initial": "O", "rime": "to"},
{"name": "Elsie", "syllables": 2, "split": "El-sie", "initial": "E", "rime": "sie"},
},
"sibling_shape": map[string]any{"count": 2, "syllable_min": 2, "syllable_max": 2,
"shared_initial": "", "shared_ending": "", "initials": "O, E"},
},
}
raw, err := call("estimate", input, "")
if err != nil {
panic(err)
}
fmt.Println(string(raw)) // model, model_alias, markup_bps, hold_credits, min_credits
// Build the input with your JSON library of choice; the shape is in the table above.
String input = """
{
"surname": "Marsh",
"middle": "Mae",
"origins": "German and Irish on one side, nothing fixed on the other",
"sound": "soft, vowel-heavy, not landing on a hard consonant",
"themes": "light, weather, a grandmother called Rose",
"syllables": "3",
"usage": "traditionally-feminine",
"rarity": "uncommon",
"siblings": "Otto, Elsie",
"avoid_names": "Aurora, Matilda",
"avoid_initials": "ASS, PIG",
"notes": "The surname is one beat and ends hard, so nothing that rhymes with it.",
"count": 8,
"computed_facts": {
"facts": [
{"id": "F01", "kind": "surname",
"text": "The surname is Marsh: 1 syllable, read Marsh, ending on the rime arsh."},
{"id": "F03", "kind": "siblings",
"text": "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2."},
{"id": "F06", "kind": "syllables",
"text": "The brief asks for 3-syllable given names; each answer is checked."}
],
"surname": {"name": "Marsh", "syllables": 1, "split": "Marsh",
"initial": "M", "onset": "m", "rime": "arsh", "ending": "sh"},
"siblings": [
{"name": "Otto", "syllables": 2, "split": "Ot-to", "initial": "O", "rime": "to"},
{"name": "Elsie", "syllables": 2, "split": "El-sie", "initial": "E", "rime": "sie"}
],
"sibling_shape": {"count": 2, "syllable_min": 2, "syllable_max": 2,
"shared_initial": "", "shared_ending": "", "initials": "O, E"}
}
}
""";
System.out.println(call("estimate", input, null));
// {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":2140,"min_credits":320,"sponsor_enabled":false}}
INPUT = {
surname: "Marsh",
middle: "Mae",
origins: "German and Irish on one side, nothing fixed on the other",
sound: "soft, vowel-heavy, not landing on a hard consonant",
themes: "light, weather, a grandmother called Rose",
syllables: "3", # a String, not an Integer
usage: "traditionally-feminine",
rarity: "uncommon",
siblings: "Otto, Elsie",
avoid_names: "Aurora, Matilda",
avoid_initials: "ASS, PIG",
notes: "The surname is one beat and ends hard, so nothing that rhymes with it. " \
"We say every name out loud across a garden before we decide.",
count: 8,
computed_facts: {
facts: [
{ id: "F01", kind: "surname",
text: "The surname is Marsh: 1 syllable, read Marsh, ending on the rime arsh." },
{ id: "F03", kind: "siblings",
text: "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2." },
{ id: "F06", kind: "syllables",
text: "The brief asks for 3-syllable given names; each answer is checked against that." }
],
surname: { name: "Marsh", syllables: 1, split: "Marsh",
initial: "M", onset: "m", rime: "arsh", ending: "sh" },
siblings: [
{ name: "Otto", syllables: 2, split: "Ot-to", initial: "O", rime: "to" },
{ name: "Elsie", syllables: 2, split: "El-sie", initial: "E", rime: "sie" }
],
sibling_shape: { count: 2, syllable_min: 2, syllable_max: 2,
shared_initial: "", shared_ending: "", initials: "O, E" }
}
}
est = call("estimate", INPUT)
puts "#{est['model']} #{est['model_alias']} #{est['markup_bps']}"
puts "#{est['hold_credits']} #{est['min_credits']}"
<?php
$input = [
"surname" => "Marsh",
"middle" => "Mae",
"origins" => "German and Irish on one side, nothing fixed on the other",
"sound" => "soft, vowel-heavy, not landing on a hard consonant",
"themes" => "light, weather, a grandmother called Rose",
"syllables" => "3", // a string, not an int
"usage" => "traditionally-feminine",
"rarity" => "uncommon",
"siblings" => "Otto, Elsie",
"avoid_names" => "Aurora, Matilda",
"avoid_initials" => "ASS, PIG",
"notes" => "The surname is one beat and ends hard, so nothing that rhymes with it.",
"count" => 8,
"computed_facts" => [
"facts" => [
["id" => "F01", "kind" => "surname",
"text" => "The surname is Marsh: 1 syllable, ending on the rime arsh."],
["id" => "F03", "kind" => "siblings",
"text" => "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2."],
["id" => "F06", "kind" => "syllables",
"text" => "The brief asks for 3-syllable given names; each answer is checked."],
],
"surname" => ["name" => "Marsh", "syllables" => 1, "split" => "Marsh",
"initial" => "M", "onset" => "m", "rime" => "arsh", "ending" => "sh"],
"siblings" => [
["name" => "Otto", "syllables" => 2, "split" => "Ot-to", "initial" => "O", "rime" => "to"],
["name" => "Elsie", "syllables" => 2, "split" => "El-sie", "initial" => "E", "rime" => "sie"],
],
"sibling_shape" => ["count" => 2, "syllable_min" => 2, "syllable_max" => 2,
"shared_initial" => "", "shared_ending" => "", "initials" => "O, E"],
],
];
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], PHP_EOL;
var input = new {
surname = "Marsh",
middle = "Mae",
origins = "German and Irish on one side, nothing fixed on the other",
sound = "soft, vowel-heavy, not landing on a hard consonant",
themes = "light, weather, a grandmother called Rose",
syllables = "3", // a string, not an int
usage = "traditionally-feminine",
rarity = "uncommon",
siblings = "Otto, Elsie",
avoid_names = "Aurora, Matilda",
avoid_initials = "ASS, PIG",
notes = "The surname is one beat and ends hard, so nothing that rhymes with it.",
count = 8,
computed_facts = new {
facts = new[] {
new { id = "F01", kind = "surname",
text = "The surname is Marsh: 1 syllable, ending on the rime arsh." },
new { id = "F03", kind = "siblings",
text = "Existing names to sit beside: Otto (2), Elsie (2). Syllable range 2-2." },
new { id = "F06", kind = "syllables",
text = "The brief asks for 3-syllable given names; each answer is checked." },
},
surname = new { name = "Marsh", syllables = 1, split = "Marsh",
initial = "M", rime = "arsh", ending = "sh" },
siblings = new[] {
new { name = "Otto", syllables = 2, split = "Ot-to", initial = "O", rime = "to" },
new { name = "Elsie", syllables = 2, split = "El-sie", initial = "E", rime = "sie" },
},
sibling_shape = new { count = 2, syllable_min = 2, syllable_max = 2,
shared_initial = "", shared_ending = "", initials = "O, E" },
},
};
var est = await Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("min_credits").GetInt32());
5. Run it, then poll
POST /run takes the input object itself as the body — the same object you just priced. It answers {job_id}; poll GET /jobs/{job_id} until status is succeeded or failed.
The shortlist arrives as a JSON string inside the envelope, at output.output. Parse it a second time. Reading job.output as if it were the object gets you a string where you expected candidates.
Send an Idempotency-Key on every run. Step 7 says why and how to build one.
KEY="baby-name-generator:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
# The body is the INPUT OBJECT ITSELF. Not {"input": {...}} - that wrapper
# returns 200 and hides every field from the model.
JOB=$(curl -sS -X POST "$SS_BASE/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# poll to a terminal state
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { echo "$OUT"; exit 1; }
sleep 2
done
# the shortlist JSON is a STRING inside the envelope, so unwrap twice
printf '%s' "$OUT" | python3 -c '
import json, sys
job = json.load(sys.stdin)["data"]
result = json.loads(job["output"]["output"])
for c in result["candidates"]:
print(c["id"], c["name"], c["syllables"], c["attestation"], "-", c["meaning"])
print("charged", job.get("charged_credits"))'
import hashlib, json, time
key = "baby-name-generator:" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
job = call("run", INPUT, idempotency_key=key) # the input object IS the body
job_id = job["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
# The shortlist is a JSON string inside the envelope: unwrap twice.
result = json.loads(job["output"]["output"])
print(result["brief_read"])
for c in result["candidates"]:
print(c["id"], c["name"], f'({c["syllables"]})', c["pronunciation"],
"-", c["meaning"], f'[{c["attestation"]}]')
print("charged", job.get("charged_credits"), "of a", job.get("hold_credits"), "hold")
import { createHash } from "node:crypto";
const key = "baby-name-generator:" +
createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16) + ":a1";
let job = await call("run", INPUT, key); // the input object IS the body
const jobId = job.job_id;
while (true) {
job = await call(`jobs/${jobId}`);
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");
// The shortlist is a JSON string inside the envelope: unwrap twice.
const result = JSON.parse(job.output.output);
console.log(result.brief_read);
for (const c of result.candidates) {
console.log(c.id, c.name, `(${c.syllables})`, c.pronunciation,
"-", c.meaning, `[${c.attestation}]`);
}
console.log("charged", job.charged_credits);
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
key := "baby-name-generator:" + hex.EncodeToString(sum[:])[:16] + ":a1"
raw, err := call("run", input, key) // the input object IS the body
if err != nil {
panic(err)
}
var started struct{ JobID string `json:"job_id"` }
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, err = call("jobs/"+started.JobID, nil, "")
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
// The shortlist is a JSON string inside the envelope: unwrap twice.
var result struct {
BriefRead string `json:"brief_read"`
Candidates []struct {
ID string `json:"id"`
Name string `json:"name"`
Syllables int `json:"syllables"`
Meaning string `json:"meaning"`
Attestation string `json:"attestation"`
} `json:"candidates"`
}
_ = json.Unmarshal([]byte(job.Output.Output), &result)
for _, c := range result.Candidates {
fmt.Printf("%s %s (%d) %s [%s]\n", c.ID, c.Name, c.Syllables, c.Meaning, c.Attestation)
}
fmt.Println("charged", job.ChargedCredits)
var digest = MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8));
var key = "baby-name-generator:" + HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var started = call("run", input, key); // the input object IS the body
// {"data":{"job_id":"job_..."}}
var jobId = started.split("\"job_id\":\"")[1].split("\"")[0];
String job;
while (true) {
job = call("jobs/" + jobId, null, null);
if (job.contains("\"status\":\"succeeded\"") || job.contains("\"status\":\"failed\"")) break;
Thread.sleep(2000);
}
// data.output.output is a JSON STRING holding the shortlist: parse it a second
// time with your JSON library, then walk result.candidates.
System.out.println(job);
require "digest"
key = "baby-name-generator:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
job = call("run", INPUT, idempotency_key: key) # the input object IS the body
job_id = job["job_id"]
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
raise(job["error"] || "run failed") if job["status"] == "failed"
# The shortlist is a JSON string inside the envelope: unwrap twice.
result = JSON.parse(job.dig("output", "output"))
puts result["brief_read"]
result["candidates"].each do |c|
puts "#{c['id']} #{c['name']} (#{c['syllables']}) #{c['meaning']} [#{c['attestation']}]"
end
puts "charged #{job['charged_credits']}"
<?php
$key = "baby-name-generator:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = call("run", $input, $key); // the input object IS the body
$jobId = $job["job_id"];
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded" || $job["status"] === "failed") {
break;
}
sleep(2);
}
if ($job["status"] === "failed") {
throw new RuntimeException($job["error"] ?? "run failed");
}
// The shortlist is a JSON string inside the envelope: unwrap twice.
$result = json_decode($job["output"]["output"], true);
echo $result["brief_read"], PHP_EOL;
foreach ($result["candidates"] as $c) {
echo $c["id"], " ", $c["name"], " (", $c["syllables"], ") ",
$c["meaning"], " [", $c["attestation"], "]", PHP_EOL;
}
echo "charged ", $job["charged_credits"], PHP_EOL;
var json = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLower();
var key = $"baby-name-generator:{hash}:a1";
var started = await Call("run", input, key); // the input object IS the body
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true) {
job = await Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
// The shortlist is a JSON string inside the envelope: unwrap twice.
var resultJson = job.GetProperty("output").GetProperty("output").GetString();
var result = JsonSerializer.Deserialize<JsonElement>(resultJson);
Console.WriteLine(result.GetProperty("brief_read").GetString());
foreach (var c in result.GetProperty("candidates").EnumerateArray()) {
Console.WriteLine($"{c.GetProperty("name").GetString()} " +
$"({c.GetProperty("syllables").GetInt32()}) " +
$"{c.GetProperty("meaning").GetString()} " +
$"[{c.GetProperty("attestation").GetString()}]");
}
6. Or stream it
POST /run-stream is the same body and the same Idempotency-Key, answered as server-sent events. Three event types arrive:
event: job data: {"job_id":"job_..."}
event: delta data: {"text":"{\"brief_read\":\"You are after a three-beat"}
event: delta data: {"text":" name with a soft landing, against a one-beat"}
...
event: done data: {"status":"succeeded","charged_credits":806,"truncated":false}
Concatenate every delta.text in arrival order and parse the result once the stream closes; that concatenation is the same string you would have found at output.output. The job event arrives first and gives you the id to poll if the connection drops. The done event carries the settled charged_credits and the truncated flag.
A shortlist takes long enough that progress is worth showing. The web page watches the growing buffer for "candidates" and then counts occurrences of "id": "NM- to display names arriving one at a time, which is cheap and needs no partial-JSON parser.
curl -sS -N -X POST "$SS_BASE/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"brief_read\":\"You are after a three-beat"}
# event: delta data: {"text":" name with a soft landing...\",\"candidates\":["}
# ...
# event: done data: {"status":"succeeded","charged_credits":806,"truncated":false}
import json, urllib.request
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(),
method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if not line.startswith("data:"):
continue
payload = json.loads(line[5:].strip())
if "text" in payload:
raw += payload["text"]
# cheap progress: how many candidate ids have appeared so far
print(f"\r{raw.count(chr(34) + 'NM-')} names, {len(raw)} chars", end="")
elif payload.get("status"):
print("\n", payload["status"], payload.get("charged_credits"))
result = json.loads(raw)
print(result["summary"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = JSON.parse(line.slice(5).trim());
if (payload.text) raw += payload.text;
else if (payload.status) console.log(payload.status, payload.charged_credits);
}
}
const result = JSON.parse(raw);
console.log(result.summary, result.candidates.length, "candidates");
body, _ := json.Marshal(input)
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var payload struct {
Text string `json:"text"`
Status string `json:"status"`
}
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &payload) == nil {
raw.WriteString(payload.Text)
if payload.Status != "" {
fmt.Println("\n" + payload.Status)
}
}
}
fmt.Println(raw.Len(), "chars of shortlist JSON")
var body = HttpRequest.BodyPublishers.ofString(input);
var req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(body)
.build();
var raw = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> {
var payload = l.substring(5).trim();
int i = payload.indexOf("\"text\":\"");
if (i >= 0) raw.append(payload.substring(i + 8, payload.lastIndexOf('"')));
});
System.out.println(raw.length() + " chars of shortlist JSON");
// Unescape and parse raw with your JSON library, then read result.candidates.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(INPUT)
raw = +""
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|
next unless line.start_with?("data:")
payload = JSON.parse(line[5..].strip) rescue next
raw << payload["text"] if payload["text"]
puts payload["status"] if payload["status"]
end
end
end
end
result = JSON.parse(raw)
puts "#{result['summary']} (#{result['candidates'].length} candidates)"
<?php
$raw = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (strncmp($line, "data:", 5) !== 0) {
continue;
}
$payload = json_decode(trim(substr($line, 5)), true);
if (isset($payload["text"])) {
$raw .= $payload["text"];
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
echo $result["summary"], " (", count($result["candidates"]), " candidates)", PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is { } line) {
if (!line.StartsWith("data:")) continue;
var payload = JsonSerializer.Deserialize<JsonElement>(line[5..].Trim());
if (payload.TryGetProperty("text", out var text)) raw.Append(text.GetString());
}
var result = JsonSerializer.Deserialize<JsonElement>(raw.ToString());
Console.WriteLine(result.GetProperty("summary").GetString());
The output contract
One JSON object, returned as a string in output.output. Every key below is always present — arrays are [] when empty and strings are "" when empty, never null and never missing. Read the contract from here rather than from a sample response; this is what the app's own normalizer guarantees, and it is what the renderer relies on.
| field | type | meaning |
|---|---|---|
brief_read | string | Two to four sentences saying what the brief was understood to be after and which constraint was treated as the hard one. If there is a tension in the brief — rare and easy to spell, three beats and soft — it is named here rather than quietly resolved. |
shape_note | string | What shape of name the surname and the siblings actually want, in beats and endings, using the computed facts. |
candidates | array of objects | The shortlist. Fields below. |
sibling_note | string | How the shortlist sits beside the existing names — what pattern was followed and whether it was deliberately broken. "" when there are no siblings. |
shortlist_advice | string | Two or three practical sentences on narrowing down: what to say out loud, what to write, what to check. |
ruled_out | array of objects | {name, reason} — names an informed reader would expect on the list and why they are not. A name here never also appears in candidates. |
attestation_summary | string | How much of this shortlist rests on settled etymology and how much is qualified, so the reader knows how far to trust the meanings above. This is the field to surface if you surface only one. |
assumptions | array of strings | Each thing that had to be assumed because the brief did not say. |
open_questions | array of strings | Each question whose answer would change the shortlist — the natural source of a follow-up refine. |
refine_note | string | "" on a first run; on a refine, one sentence saying what changed and what was deliberately held steady. |
summary | string | One sentence a person could read on its own and know what came back. |
A candidate
| field | type | meaning |
|---|---|---|
id | string | NM-01, NM-02, sequential, no gaps. |
name | string | The given name alone, correctly capitalised. No surname. |
also_spelled | array of strings | Established variants only, never invented respellings. |
pronunciation | string | An English respelling, hyphenated one chunk per syllable, the stressed chunk in CAPITALS: OT-il-ee, o-LIV-ee-uh, REN. Not IPA. The number of chunks equals syllables — the browser counts them and reports any mismatch. |
pronunciation_note | string | Second pronunciations in circulation, and which country says which. |
syllables | integer | Counted as an English speaker would say it. Re-counted from the letters and displayed if it disagrees. |
origin | string | The language or culture, plainly: German, Irish, Yoruba, Hebrew, English (place name). Not a sentence. |
origin_note | string | The element or root the name comes from, where that is known. |
meaning | string | A short lower-case phrase, no full stop: pearl, small bird, from the meadow of yew trees. A name with no meaning beyond itself gets no meaning beyond the name rather than an invention. |
attestation | enum | How settled that meaning is. Five values, table below. Required on every candidate. |
meaning_note | string | Required whenever attestation is not well-attested, and it must name the competing derivations or say plainly what is unknown. "" is only legitimate alongside well-attested. |
usage | enum | traditionally-feminine · traditionally-masculine · unisex · varies-by-country. An observation about custom, never a rule about who may have the name. |
usage_note | string | Where usage differs sharply between countries, what the difference is — rather than picking one and calling it the answer. |
familiarity | enum | very-familiar · familiar · uncommon · rare · very-rare. A band, never a rank. |
familiarity_note | string | Framed as an impression. A direction without a number is allowed (“has been rising for a decade and no longer reads as unusual”); a chart position is not. |
era_note | string | Stable historical association — “strongly associated with women born in Britain in the 1930s and 40s”. A decade is allowed here because it does not go stale the way a ranking does. |
nicknames | array of strings | Short forms actually in use. [] is a legitimate answer for a short name. The browser derives the rule-based forms itself and marks anything it cannot corroborate. |
with_surname | string | The full name as written: given + middle (if any) + surname. With no surname in the brief, the given name alone. |
initials | string | Dotted, in speaking order: O.M.M. Recomputed and checked, as is the engraved monogram — which puts the surname initial in the centre and therefore spells a different string. |
scan_note | string | How the full name reads: beats, where the stress lands, whether it runs together at the join. Any rhyme, seam or monogram the computed facts flagged is addressed here or in watch. |
why | string | One or two sentences on why this one answers this brief. |
watch | string | The honest downside: spelling, mispronunciation, an unfortunate monogram, a rhyme, a nickname they may not want. Eight candidates with eight blank watch fields is a sales brochure, not a shortlist. |
The three enums
| field | values |
|---|---|
attestation | well-attested — settled and uncontroversial across reference works · disputed — two or more serious derivations compete and specialists have not settled it · folk — the meaning in wide circulation is a later back-formation that scholarship does not support · modern-coinage — invented in the modern era, or a place name, surname, word or plant pressed into use, with no ancient meaning at all · uncertain — genuinely old, origin not known. |
familiarity | very-familiar · familiar · uncommon · rare · very-rare |
usage | traditionally-feminine · traditionally-masculine · unisex · varies-by-country |
Why attestation is a field at all
Because a name's meaning is a factual claim the reader cannot check and acts on permanently. Someone reading a recipe can taste the result. Someone reading a poem can decide they dislike it. Someone naming a child reads “Ottilie: German, prosperous in battle”, believes it, and repeats that story for the next forty years. Nothing downstream catches an invented etymology — not the reader, not the app, not the child.
So the model is not allowed to give a meaning without saying what kind of claim it is. A settled derivation, a live scholarly dispute, a folk etymology everybody repeats, and a modern coinage that has no ancient meaning are four different things, and flattening them into one confident sentence is the failure this app is built to prevent. "Origin uncertain; commonly given as X" is a better answer than a clean fabrication, and that is why meaning_note is mandatory the moment attestation leaves well-attested. If you render candidates in your own interface, put the attestation next to the meaning, not in a tooltip and not in a footer.
Why there is no popularity rank
Popularity rankings are re-cut every year, and a stale number reads exactly as confident as a fresh one — the same failure mode as a fabricated etymology, in a field people trust more because it looks quantitative. So the contract has no rank, no chart position, no “top 50”, no “Nth most common” anywhere. What you get instead is the familiarity band, which is an impression of how often an English-speaking reader has met the name, plus familiarity_note for direction without a number.
The app enforces this rather than trusting it: the reply is scanned for rank-shaped claims and any that slip through are flagged next to the name. If you are building your own view, do not go and join the output against a naming-statistics table and present the result as though it came from here — that is the stale number arriving by another door.
Invariants worth asserting in your own code
The web page checks all of these and shows the reader every disagreement. If you build on the API, these are the assertions that catch a bad reply before someone writes a name on a form:
candidates[].idisNM-01,NM-02, … in order, with no gaps.len(candidates) == count(default 8), unlessbrief_readexplains why fewer honest answers exist.- Every
attestationis one of the five values; everyfamiliarityone of the five bands; everyusageone of the four. attestation != "well-attested"implies a non-emptymeaning_note. This is the single most valuable assertion on the page.- The number of hyphen-separated chunks in
pronunciationequalssyllables, and exactly one chunk is in capitals. - When the brief set a numeric
syllables, every candidate matches it — check against your own count, not the returned one. initialsis derived fromwith_surname, and neither it nor the engraved monogram matches anything inavoid_initials.- No candidate name appears twice, matches a sibling, or matches anything in
avoid_names— including as a spelling variant. - No name in
ruled_outalso appears incandidates. - No string anywhere in the reply matches a rank-shaped pattern:
#followed by digits, “top N”, “Nth most”.
7. Idempotency-Key on every run
Send an Idempotency-Key header on every /run and /run-stream call. Not on the retry — on the first attempt, because by the time you know you need it, the request that would have been de-duplicated has already gone.
The key the app uses is baby-name-generator:<hash of the input>:a<attempt>, and both halves earn their place. The hash of the input means a network timeout, a dropped connection or a 5xx can be retried with the identical key and returns the same job instead of billing a second time — which matters here, because the failure mode of a naive retry is not a duplicate row in a table, it is a second charge for a shortlist the reader will never see. Replaying a key with a different body is a conflict (409) rather than a silent overwrite, so the hash also protects you from reusing a key you meant to rotate.
The attempt counter is the other half. When a reply comes back that does not parse, or comes back truncated, you genuinely do want a fresh run — so bump a1 to a2 and send the same input again. The counter makes “retry the transport” and “ask again” two different operations with two different keys, which is the distinction that stops a formatting blip either billing twice or looping forever on a cached failure. A refine pass is a different run against a different body, so it gets its own key too.
idem() {
# idem <json-body> <attempt>
printf 'baby-name-generator:%s:a%s' \
"$(printf '%s' "$1" | shasum -a 256 | cut -c1-16)" "$2"
}
KEY=$(idem "$INPUT" 1)
# Transport failed? SAME key - you get the same job back, billed once.
curl -sS -X POST "$SS_BASE/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# Reply did not parse, or came back truncated? BUMP the attempt: a1 -> a2.
KEY=$(idem "$INPUT" 2)
import hashlib, json
def idem(body, attempt=1):
"""baby-name-generator:<hash of the input>:a<attempt>
Same input, same attempt -> same key -> a retried request returns the
original job instead of billing twice. Bump the attempt only when you
genuinely want a new answer: an unparseable or truncated reply.
"""
h = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
return f"baby-name-generator:{h}:a{attempt}"
key = idem(INPUT, 1)
try:
job = call("run", INPUT, idempotency_key=key)
except SkillSafeError as e:
if e.code in ("INTERNAL", "RATE_LIMITED"):
job = call("run", INPUT, idempotency_key=key) # SAME key: billed once
else:
raise
import { createHash } from "node:crypto";
// baby-name-generator:<hash of the input>:a<attempt>
// Same input, same attempt -> same key -> a retried request returns the
// original job instead of billing twice. Bump the attempt only when you
// genuinely want a new answer: an unparseable or truncated reply.
function idem(body, attempt = 1) {
const h = createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16);
return `baby-name-generator:${h}:a${attempt}`;
}
const key = idem(INPUT, 1);
let job;
try {
job = await call("run", INPUT, key);
} catch (e) {
if (e.code === "INTERNAL" || e.code === "RATE_LIMITED") {
job = await call("run", INPUT, key); // SAME key: billed once
} else throw e;
}
// baby-name-generator:<hash of the input>:a<attempt>
//
// Same input, same attempt -> same key -> a retried request returns the
// original job instead of billing twice. Bump the attempt only when you
// genuinely want a new answer: an unparseable or truncated reply.
func idem(body any, attempt int) string {
b, _ := json.Marshal(body)
sum := sha256.Sum256(b)
return fmt.Sprintf("baby-name-generator:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
}
key := idem(input, 1)
raw, err := call("run", input, key)
if err != nil {
// A transport error or a 5xx: retry with the SAME key, billed once.
raw, err = call("run", input, key)
if err != nil {
panic(err)
}
}
// baby-name-generator:<hash of the input>:a<attempt>
//
// Same input, same attempt -> same key -> a retried request returns the
// original job instead of billing twice. Bump the attempt only when you
// genuinely want a new answer: an unparseable or truncated reply.
static String idem(String jsonBody, int attempt) throws Exception {
var digest = MessageDigest.getInstance("SHA-256").digest(jsonBody.getBytes(UTF_8));
return "baby-name-generator:" + HexFormat.of().formatHex(digest).substring(0, 16)
+ ":a" + attempt;
}
var key = idem(input, 1);
String job;
try {
job = call("run", input, key);
} catch (RuntimeException e) {
job = call("run", input, key); // SAME key on a transport failure: billed once
}
require "digest"
# baby-name-generator:<hash of the input>:a<attempt>
#
# Same input, same attempt -> same key -> a retried request returns the
# original job instead of billing twice. Bump the attempt only when you
# genuinely want a new answer: an unparseable or truncated reply.
def idem(body, attempt = 1)
"baby-name-generator:#{Digest::SHA256.hexdigest(JSON.generate(body))[0, 16]}:a#{attempt}"
end
key = idem(INPUT, 1)
begin
job = call("run", INPUT, idempotency_key: key)
rescue StandardError
job = call("run", INPUT, idempotency_key: key) # SAME key: billed once
end
<?php
// baby-name-generator:<hash of the input>:a<attempt>
//
// Same input, same attempt -> same key -> a retried request returns the
// original job instead of billing twice. Bump the attempt only when you
// genuinely want a new answer: an unparseable or truncated reply.
function idem(array $body, int $attempt = 1): string {
return "baby-name-generator:" . substr(hash("sha256", json_encode($body)), 0, 16)
. ":a" . $attempt;
}
$key = idem($input, 1);
try {
$job = call("run", $input, $key);
} catch (RuntimeException $e) {
$job = call("run", $input, $key); // SAME key: billed once
}
// baby-name-generator:<hash of the input>:a<attempt>
//
// Same input, same attempt -> same key -> a retried request returns the
// original job instead of billing twice. Bump the attempt only when you
// genuinely want a new answer: an unparseable or truncated reply.
static string Idem(object body, int attempt = 1) {
var json = JsonSerializer.Serialize(body);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16]
.ToLower();
return $"baby-name-generator:{hash}:a{attempt}";
}
var key = Idem(input, 1);
JsonElement started;
try {
started = await Call("run", input, key);
} catch (Exception) {
started = await Call("run", input, key); // SAME key: billed once
}
8. Putting it together
A whole brief, submitted and printed. Note what the print line does: it puts the attestation next to the meaning and prints the watch line under every name. That is not decoration. A shortlist that shows only names and meanings is exactly the artefact this app exists to avoid producing — it reads as eight settled facts when two of them are qualified and one is a modern coinage with no ancient meaning at all.
#!/usr/bin/env bash
# Uses call(), $INPUT and $KEY from the steps above.
set -euo pipefail
JOB=$(curl -sS -X POST "$SS_BASE/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { echo "$OUT" >&2; exit 1; }
sleep 2
done
printf '%s' "$OUT" | python3 -c '
import json, sys
job = json.load(sys.stdin)["data"]
r = json.loads(job["output"]["output"])
print(r["brief_read"], "\n")
for c in r["candidates"]:
# attestation next to the meaning, every time
print("%-12s %d syl %-16s %s [%s]" % (
c["name"], c["syllables"], c["pronunciation"], c["meaning"], c["attestation"]))
if c["attestation"] != "well-attested":
print(" note: " + c["meaning_note"])
print(" watch: " + c["watch"])
print("\n" + r["attestation_summary"])
print("charged", job.get("charged_credits"))'
"""Submit a brief and print the shortlist. Uses call(), INPUT and idem() above."""
import json, time
job = call("run", INPUT, idempotency_key=idem(INPUT, 1))
job_id = job["job_id"]
while job["status"] not in ("succeeded", "failed"):
time.sleep(2)
job = call(f"jobs/{job_id}")
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
r = json.loads(job["output"]["output"])
print(r["brief_read"], "\n")
print(r["shape_note"], "\n")
for c in r["candidates"]:
# The attestation label goes NEXT TO the meaning, never in a footnote.
print(f'{c["name"]:<12} {c["syllables"]} syl {c["pronunciation"]:<16} '
f'{c["meaning"]} [{c["attestation"]}]')
if c["attestation"] != "well-attested":
print(f'{"":<12} note: {c["meaning_note"]}')
print(f'{"":<12} watch: {c["watch"]}')
print("\n" + r["attestation_summary"])
print(r["shortlist_advice"])
for q in r["open_questions"]:
print(" ?", q)
print("charged", job.get("charged_credits"))
// Submit a brief and print the shortlist. Uses call(), INPUT and idem() above.
let job = await call("run", INPUT, idem(INPUT, 1));
const jobId = job.job_id;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${jobId}`);
}
if (job.status === "failed") throw new Error(job.error || "run failed");
const r = JSON.parse(job.output.output);
console.log(r.brief_read, "\n");
console.log(r.shape_note, "\n");
for (const c of r.candidates) {
// The attestation label goes NEXT TO the meaning, never in a footnote.
console.log(`${c.name.padEnd(12)} ${c.syllables} syl ` +
`${c.pronunciation.padEnd(16)} ${c.meaning} [${c.attestation}]`);
if (c.attestation !== "well-attested") {
console.log(`${"".padEnd(12)} note: ${c.meaning_note}`);
}
console.log(`${"".padEnd(12)} watch: ${c.watch}`);
}
console.log("\n" + r.attestation_summary);
console.log(r.shortlist_advice);
console.log("charged", job.charged_credits);
// Submit a brief and print the shortlist. Uses call(), input and idem() above.
type candidate struct {
Name string `json:"name"`
Syllables int `json:"syllables"`
Pronunciation string `json:"pronunciation"`
Meaning string `json:"meaning"`
Attestation string `json:"attestation"`
MeaningNote string `json:"meaning_note"`
Watch string `json:"watch"`
}
type shortlist struct {
BriefRead string `json:"brief_read"`
ShapeNote string `json:"shape_note"`
Candidates []candidate `json:"candidates"`
AttestationSummary string `json:"attestation_summary"`
}
raw, err := call("run", input, idem(input, 1))
if err != nil {
panic(err)
}
var started struct{ JobID string `json:"job_id"` }
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
for job.Status != "succeeded" && job.Status != "failed" {
time.Sleep(2 * time.Second)
raw, _ = call("jobs/"+started.JobID, nil, "")
_ = json.Unmarshal(raw, &job)
}
var r shortlist
_ = json.Unmarshal([]byte(job.Output.Output), &r)
fmt.Println(r.BriefRead)
fmt.Println(r.ShapeNote)
for _, c := range r.Candidates {
// The attestation label goes NEXT TO the meaning, never in a footnote.
fmt.Printf("%-12s %d syl %-16s %s [%s]\n",
c.Name, c.Syllables, c.Pronunciation, c.Meaning, c.Attestation)
if c.Attestation != "well-attested" {
fmt.Printf("%-12s note: %s\n", "", c.MeaningNote)
}
fmt.Printf("%-12s watch: %s\n", "", c.Watch)
}
fmt.Println(r.AttestationSummary)
fmt.Println("charged", job.ChargedCredits)
// Submit a brief and print the shortlist. Uses call(), input and idem() above.
// Parse data.output.output with your JSON library of choice; the sketch below
// uses a Jackson-style ObjectMapper called M.
var started = call("run", input, idem(input, 1));
var jobId = M.readTree(started).at("/data/job_id").asText();
com.fasterxml.jackson.databind.JsonNode job;
do {
Thread.sleep(2000);
job = M.readTree(call("jobs/" + jobId, null, null)).get("data");
} while (!job.get("status").asText().equals("succeeded")
&& !job.get("status").asText().equals("failed"));
var r = M.readTree(job.at("/output/output").asText());
System.out.println(r.get("brief_read").asText());
System.out.println(r.get("shape_note").asText());
for (var c : r.get("candidates")) {
// The attestation label goes NEXT TO the meaning, never in a footnote.
System.out.printf("%-12s %d syl %-16s %s [%s]%n",
c.get("name").asText(), c.get("syllables").asInt(),
c.get("pronunciation").asText(), c.get("meaning").asText(),
c.get("attestation").asText());
if (!c.get("attestation").asText().equals("well-attested")) {
System.out.printf("%-12s note: %s%n", "", c.get("meaning_note").asText());
}
System.out.printf("%-12s watch: %s%n", "", c.get("watch").asText());
}
System.out.println(r.get("attestation_summary").asText());
# Submit a brief and print the shortlist. Uses call(), INPUT and idem() above.
job = call("run", INPUT, idempotency_key: idem(INPUT, 1))
job_id = job["job_id"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job_id}")
end
raise(job["error"] || "run failed") if job["status"] == "failed"
r = JSON.parse(job.dig("output", "output"))
puts r["brief_read"], "", r["shape_note"], ""
r["candidates"].each do |c|
# The attestation label goes NEXT TO the meaning, never in a footnote.
puts format("%-12s %d syl %-16s %s [%s]",
c["name"], c["syllables"], c["pronunciation"], c["meaning"], c["attestation"])
puts format("%-12s note: %s", "", c["meaning_note"]) unless c["attestation"] == "well-attested"
puts format("%-12s watch: %s", "", c["watch"])
end
puts "", r["attestation_summary"], r["shortlist_advice"]
puts "charged #{job['charged_credits']}"
<?php
// Submit a brief and print the shortlist. Uses call(), $input and idem() above.
$job = call("run", $input, idem($input, 1));
$jobId = $job["job_id"];
while ($job["status"] !== "succeeded" && $job["status"] !== "failed") {
sleep(2);
$job = call("jobs/" . $jobId);
}
if ($job["status"] === "failed") {
throw new RuntimeException($job["error"] ?? "run failed");
}
$r = json_decode($job["output"]["output"], true);
echo $r["brief_read"], PHP_EOL, PHP_EOL, $r["shape_note"], PHP_EOL, PHP_EOL;
foreach ($r["candidates"] as $c) {
// The attestation label goes NEXT TO the meaning, never in a footnote.
printf("%-12s %d syl %-16s %s [%s]\n", $c["name"], $c["syllables"],
$c["pronunciation"], $c["meaning"], $c["attestation"]);
if ($c["attestation"] !== "well-attested") {
printf("%-12s note: %s\n", "", $c["meaning_note"]);
}
printf("%-12s watch: %s\n", "", $c["watch"]);
}
echo PHP_EOL, $r["attestation_summary"], PHP_EOL, $r["shortlist_advice"], PHP_EOL;
echo "charged ", $job["charged_credits"], PHP_EOL;
// Submit a brief and print the shortlist. Uses Call(), input and Idem() above.
var started = await Call("run", input, Idem(input, 1));
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
string status;
do {
await Task.Delay(2000);
job = await Call($"jobs/{jobId}");
status = job.GetProperty("status").GetString();
} while (status is not ("succeeded" or "failed"));
if (status == "failed") throw new Exception("run failed");
var r = JsonSerializer.Deserialize<JsonElement>(
job.GetProperty("output").GetProperty("output").GetString());
Console.WriteLine(r.GetProperty("brief_read").GetString());
Console.WriteLine(r.GetProperty("shape_note").GetString());
foreach (var c in r.GetProperty("candidates").EnumerateArray()) {
var attestation = c.GetProperty("attestation").GetString();
// The attestation label goes NEXT TO the meaning, never in a footnote.
Console.WriteLine("{0,-12} {1} syl {2,-16} {3} [{4}]",
c.GetProperty("name").GetString(),
c.GetProperty("syllables").GetInt32(),
c.GetProperty("pronunciation").GetString(),
c.GetProperty("meaning").GetString(),
attestation);
if (attestation != "well-attested") {
Console.WriteLine("{0,-12} note: {1}", "", c.GetProperty("meaning_note").GetString());
}
Console.WriteLine("{0,-12} watch: {1}", "", c.GetProperty("watch").GetString());
}
Console.WriteLine(r.GetProperty("attestation_summary").GetString());
Truncation and partial replies
If the balance sits between min_credits and hold_credits, the run still executes with a reduced output cap and the job comes back with "truncated": true. What arrives is then a valid prefix rather than a valid object: usually a few complete candidates and one cut off mid-field. Render what parsed and say it was cut short. A truncated shortlist presented as a whole one is a list whose last two names were silently dropped, and nothing in the output says which.
On a reply that does not parse at all, resend the same input with the attempt counter bumped — :a1 to :a2. That is the case the counter exists for.