← Lattice Swap
API
Tokens

Drive Lattice Swap from your own code

The one metered step - writing theme words and clues - is available over HTTP. A natural use is a puzzle newsletter or a classroom set: a script sends a week of themes, gets back word lists with clues, and builds a shuffled lattice per theme.

Be clear about the split first: the model writes words; the caller builds the puzzle. The model never draws a grid. The page fits the words into the six crossing slots (backtracking over the theme words plus a built-in list of everyday words), shuffles the solved board by swaps, and replays its own solution to set par - so every board is solvable. That code ships as plain scripts — lattice.js (Lattice.build, scramble, colors, normalize, reconcile) and words.js — you can load in node.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest call takes {"slug":"lattice-swap"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}} — the declared input schema warns unknown field 'input' and the model sees nothing.

StatusCodeMeaning
400validation_errorThe body is not a JSON object.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the word writer is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

The app has one task. Always send "task": "theme":

taskWhat comes back
themeA title, the theme restated, a one-line blurb, the difficulty, and 20–30 words of exactly five letters, each with a clue (at most 12 words, never containing the answer) and a short link to the theme.

Input fields

FieldTypeNotes
taskstring, requiredAlways theme.
themestring, requiredFree text, 3–160 characters. The page keeps the start and end of a longer theme with a [...] marker.
difficultystringeasy (direct clues, par 9) or tricky (misdirection, par 11).
countnumberHow many words to aim for; the page sends 24.
avoidstringOptional. JSON-encoded array of words from the previous round, so "new words, same theme" brings fresh ones. The schema carries scalars only, so arrays travel encoded.
seed_wordsstringOptional. JSON-encoded array of the user's own five-letter words; they steer the theme and are added by the page, not returned.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object: {task, title, theme, blurb, difficulty, words: [{word, clue, link}]}. Parse it, then check it — the page re-checks every word (five letters A–Z, no repeats, nothing from avoid) and hides a clue that contains its own answer.

Worked examples

Real replies, produced against the app's prompt (word lists shortened here; the full lists ship in example-result.js). The page replays each of them free from its Try buttons.

theme: "fall baking" - easy clues

Input (as sent):

{
  "task": "theme",
  "theme": "fall baking",
  "difficulty": "easy",
  "count": 24
}

Reply:

{
  "task": "theme",
  "title": "Fall Baking Delights",
  "theme": "cozy autumn baking",
  "blurb": "Warm up your kitchen and swap letters to uncover the cozy flavors of fall baking, from spiced pies to fresh bread.",
  "difficulty": "easy",
  "words": [
    {
      "word": "GOURD",
      "clue": "Hard-shelled squash often roasted this time of year",
      "link": "Classic autumn harvest vegetable"
    },
    {
      "word": "CIDER",
      "clue": "Warm spiced apple drink served in fall",
      "link": "Signature autumn beverage"
    },
    {
      "word": "MAPLE",
      "clue": "Syrup tapped from trees, drizzled on treats",
      "link": "Sweetens many fall desserts"
    },
    {
      "word": "SPICE",
      "clue": "Cinnamon or nutmeg added to batter",
      "link": "Gives fall treats their warmth"
    },
    {
      "word": "CRUST",
      "clue": "Flaky bottom layer of a pie",
      "link": "Holds the pie filling"
    },
    {
      "word": "DOUGH",
      "clue": "Mixed flour and butter before baking",
      "link": "Base of breads and pastries"
    },
    {
      "...": "18 more words"
    }
  ]
}

theme: "the deep sea" - tricky clues

{
  "task": "theme",
  "theme": "the deep sea",
  "difficulty": "tricky",
  "count": 24
}

Reply:

{
  "task": "theme",
  "title": "Secrets of the Deep Sea",
  "theme": "the mysterious deep ocean",
  "blurb": "Descend into murky, pressure-crushing waters and swap letters to surface the hidden creatures of the deep sea.",
  "difficulty": "tricky",
  "words": [
    {
      "word": "WHALE",
      "clue": "The biggest mammal holds its breath down here",
      "link": "Largest deep-diving ocean mammal"
    },
    {
      "word": "SQUID",
      "clue": "Shoots ink and has eight arms plus two",
      "link": "Deep-sea cephalopod with many arms"
    },
    {
      "word": "SHARK",
      "clue": "Keeps swimming or it cannot breathe",
      "link": "Apex predator cruising the depths"
    },
    {
      "word": "CORAL",
      "clue": "A living reef, not just a color",
      "link": "Builds reefs in shallow and deep water"
    },
    {
      "word": "OCEAN",
      "clue": "Covers most of the planet, deeply",
      "link": "The vast body holding this world"
    },
    {
      "word": "DEPTH",
      "clue": "How far down you'd have to go",
      "link": "Measures how low the sea goes"
    },
    {
      "...": "18 more words"
    }
  ]
}

A second round on the same theme

"New words, same theme" sends the previous words as avoid (JSON-encoded), so the next lattice is built from fresh words:

{
  "task": "theme",
  "theme": "fall baking",
  "difficulty": "easy",
  "count": 24,
  "avoid": "[\"gourd\",\"cider\",\"maple\",\"spice\",\"crust\",\"dough\",\"yeast\",\"batch\",\"baked\",\"sugar\"]"
}

Build the puzzle yourself

Load the scripts in node (global.window = {}; require("./words.js"); require("./lattice.js")), then:

const res = Lattice.normalize(Lattice.parseResult(text));
const lat = Lattice.build({ dict: LatticeWords, pool: res.words.map(w => w.word), seed: "my-theme" });
// lat.words = {a0, a2, a4, d0, d2, d4}, lat.solution = 21 letters, lat.themedCount = theme words placed (0-6)
const shuffled = Lattice.scramble(lat.solution, { seed: "my-theme:board", par: 10 });
// shuffled.board = 21 letters, shuffled.par = a swap count that was actually replayed
Lattice.reconcile(res, { dict: LatticeWords, lattice: lat }).notes;  // dropped words, clue leaks, how themed it is

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://lattice-swap.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"lattice-swap"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "theme", "theme": "fall baking", "difficulty": "easy", "count": 24}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses lattice-swap:<task>:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="lattice-swap:theme:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: lattice-swap:theme:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json