← Baby Name Generator / API
Tokens

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

codestatuswhat to do
UNAUTHORIZED401The token is missing, malformed or expired. Get a new one from the token page.
FORBIDDEN403The 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_ERROR422A 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_CREDITS402The balance is below min_credits. Call /estimate first and top up.
RATE_LIMITED429Too many requests. Back off and retry; do not tight-loop a poll.
NOT_FOUND404Unknown job id, unknown collection, or the app slug does not exist.
INTERNAL5xxA 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
}

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.

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.

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.

fieldtypemeaning
surnamestringThe 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.
middlestringA middle name already decided. Fixes the centre of every monogram and adds a beat to every with_surname line.
originsstringOrigins, languages or heritages the reader is drawn to. Free text — German and Irish on one side reads better than a list of ISO codes.
soundstringThe texture wanted: soft, short and hard, vowel-heavy, ends on a consonant. Prose, not an enum.
themesstringMeanings or associations wanted — the sea, light, weather, a grandmother called Rose.
syllablesstring"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.
usagestringany · unisex · traditionally-feminine · traditionally-masculine. With any, the shortlist spans the range and is deliberately not sorted or captioned by gender.
raritystringany, or one of the familiarity bands: very-familiar, familiar, uncommon, rare, very-rare. Aims the shortlist; it is not a filter applied afterwards.
siblingsstringComma-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_namesstringComma-separated names ruled out. Anything differing only in spelling is out too.
avoid_initialsstringComma-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.
notesstringFree text. Read carefully by the model; the real constraint is very often here rather than in the structured fields.
countnumberHow 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_factsobjectFacts the browser measured from the letters before the call. See below.
refineobject{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 kindsurname, 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.

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"))'

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}

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.

fieldtypemeaning
brief_readstringTwo 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_notestringWhat shape of name the surname and the siblings actually want, in beats and endings, using the computed facts.
candidatesarray of objectsThe shortlist. Fields below.
sibling_notestringHow the shortlist sits beside the existing names — what pattern was followed and whether it was deliberately broken. "" when there are no siblings.
shortlist_advicestringTwo or three practical sentences on narrowing down: what to say out loud, what to write, what to check.
ruled_outarray 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_summarystringHow 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.
assumptionsarray of stringsEach thing that had to be assumed because the brief did not say.
open_questionsarray of stringsEach question whose answer would change the shortlist — the natural source of a follow-up refine.
refine_notestring"" on a first run; on a refine, one sentence saying what changed and what was deliberately held steady.
summarystringOne sentence a person could read on its own and know what came back.

A candidate

fieldtypemeaning
idstringNM-01, NM-02, sequential, no gaps.
namestringThe given name alone, correctly capitalised. No surname.
also_spelledarray of stringsEstablished variants only, never invented respellings.
pronunciationstringAn 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_notestringSecond pronunciations in circulation, and which country says which.
syllablesintegerCounted as an English speaker would say it. Re-counted from the letters and displayed if it disagrees.
originstringThe language or culture, plainly: German, Irish, Yoruba, Hebrew, English (place name). Not a sentence.
origin_notestringThe element or root the name comes from, where that is known.
meaningstringA 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.
attestationenumHow settled that meaning is. Five values, table below. Required on every candidate.
meaning_notestringRequired 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.
usageenumtraditionally-feminine · traditionally-masculine · unisex · varies-by-country. An observation about custom, never a rule about who may have the name.
usage_notestringWhere usage differs sharply between countries, what the difference is — rather than picking one and calling it the answer.
familiarityenumvery-familiar · familiar · uncommon · rare · very-rare. A band, never a rank.
familiarity_notestringFramed 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_notestringStable 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.
nicknamesarray of stringsShort 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_surnamestringThe full name as written: given + middle (if any) + surname. With no surname in the brief, the given name alone.
initialsstringDotted, 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_notestringHow 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.
whystringOne or two sentences on why this one answers this brief.
watchstringThe 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

fieldvalues
attestationwell-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.
familiarityvery-familiar · familiar · uncommon · rare · very-rare
usagetraditionally-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:

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)

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"))'

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.