← One Across
API

Drive One Across from your own code

The one metered task, write-clues, is available over HTTP. It takes what the puzzle is about, a clue style (straight or playful) and four to ten answers, each with a short true note. It returns one fair clue per answer built only from that answer's note (a plain definition when there is no note), a title and a one-sentence intro.

The division of labour first: the page lays out the grid; the model writes the clues. The page reads the answers, counts their letters, places them in a seeded, validated crossing grid, numbers it and writes its own clue from each note with the answer blanked out. After the reply it checks every clue against your words - no clue may hold its answer, another answer, or a time, number, pronoun or judgement you did not give - and keeps its own clue wherever one gives the answer away. Reshuffling the grid never needs another run.

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": "..." } }

The token is minted for this app (the guest call takes {"slug":"one-across"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, about, style, words, facts) is missing.
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 reasons are metered and need a personal sign-in.
404not_foundUnknown run id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field and the input

Every run sends task: write-clues (the one task is write-clues); a missing or different one is still answered as write-clues. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways write-clues.
aboutyesWho or what the puzzle is about, up to 80 characters.
styleyesstraight or playful.
wordsyes4 to 10 answers, numbered, one per line: the answer in capitals (3 to 13 letters, up to three words), its letter count in brackets, a dash, then the note (up to 100 characters) or no note.
factsyesThe page's lines: how many answers were sent and anything it left out.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

The reply

One JSON object, no prose around it:

{"task": "write-clues", "title": "...", "intro": "...", "clues": [{"answer": "...", "clue": "..."}]}
FieldWhat it holds
clues[]One per answer, same order; answer as sent, clue 3 to 12 words with no letter count (the page adds it).
title2 to 6 words from the about line.
introOne sentence, 8 to 25 words, saying what the puzzle is about.

The page then checks the reply (AcrossKit.check) - a clue for every answer, no clue that gives its own or another answer away, and no time word, number, pronoun, judgement or contact detail you did not give - and lists what slipped under "The page's check". A missing or leaking clue is replaced by the page's own clue.

Worked examples - both clue styles

These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover the straight and playful styles, answers with no note and two-word answers.

Nan's kitchen - straight style, 6 answers, 1 with no note

Input:

{
 "task": "write-clues",
 "about": "Nan's 80th: things from her kitchen",
 "style": "straight",
 "words": "1. TEAPOT (6) - the brown one with the chipped lid\n2. SCONES (6) - she bakes them every Sunday\n3. APRON (5) - the one with strawberries on it\n4. RADIO (5) - always tuned to the cricket\n5. BISCUIT TIN (7,3) - where the buttons are kept, not biscuits\n6. MARMALADE (9) - no note",
 "facts": "6 answers sent, numbered in the order typed. 1 answer came with no note. Write one clue for every numbered answer."
}

Reply:

{
 "task": "write-clues",
 "title": "Things from her kitchen",
 "intro": "This is about things from Nan's kitchen for her 80th.",
 "clues": [
  {
   "answer": "TEAPOT",
   "clue": "The brown one with the chipped lid"
  },
  {
   "answer": "SCONES",
   "clue": "She bakes them every Sunday"
  },
  {
   "answer": "APRON",
   "clue": "The one with strawberries on it"
  },
  {
   "answer": "RADIO",
   "clue": "Always tuned to the cricket"
  },
  {
   "answer": "BISCUIT TIN",
   "clue": "Where the buttons are kept"
  },
  {
   "answer": "MARMALADE",
   "clue": "A sweet preserve made from citrus fruit"
  }
 ]
}

Our first flat - playful style, 7 answers

Input:

{
 "task": "write-clues",
 "about": "Our first flat above the launderette",
 "style": "playful",
 "words": "1. KETTLE (6) - took ten minutes to boil\n2. DAMP (4) - the corner of the bathroom ceiling\n3. STAIRS (6) - four flights and no lift\n4. FUTON (5) - our only sofa and the guest bed\n5. PIGEONS (7) - lived on the window ledge\n6. PIZZA (5) - from the place across the road\n7. BOILER (6) - broke the week we moved in",
 "facts": "7 answers sent, numbered in the order typed. Write one clue for every numbered answer."
}

Reply:

{
 "task": "write-clues",
 "title": "Our first flat above the launderette",
 "intro": "This mini crossword is about our first flat above the launderette.",
 "clues": [
  {
   "answer": "KETTLE",
   "clue": "Which container took ten minutes to boil?"
  },
  {
   "answer": "DAMP",
   "clue": "What's in the corner of the bathroom ceiling?"
  },
  {
   "answer": "STAIRS",
   "clue": "What's four flights and no lift?"
  },
  {
   "answer": "FUTON",
   "clue": "What's our only sofa and the guest bed?"
  },
  {
   "answer": "PIGEONS",
   "clue": "Who lived on the window ledge?"
  },
  {
   "answer": "PIZZA",
   "clue": "Which dish is from the place across the road?"
  },
  {
   "answer": "BOILER",
   "clue": "Which appliance broke the week we moved in?"
  }
 ]
}

A wet camping week - straight style, 7 answers, 1 with no note

Input:

{
 "task": "write-clues",
 "about": "A week of camping in the rain",
 "style": "straight",
 "words": "1. TENT (4) - leaked on the second night\n2. WELLIES (7) - we wore them everywhere, even to the pub\n3. SHEEP (5) - woke us up at dawn\n4. KAYAK (5) - the one dry afternoon\n5. CHIPS (5) - dinner three nights running\n6. TORCH (5) - no note\n7. CARD GAMES (4,5) - what we played when the rain came in",
 "facts": "7 answers sent, numbered in the order typed. 1 answer came with no note. Write one clue for every numbered answer."
}

Reply:

{
 "task": "write-clues",
 "title": "A week of camping",
 "intro": "This puzzle is all about a week of camping in the rain.",
 "clues": [
  {
   "answer": "TENT",
   "clue": "The shelter that leaked on the second night"
  },
  {
   "answer": "WELLIES",
   "clue": "Boots we wore everywhere, even to the pub"
  },
  {
   "answer": "SHEEP",
   "clue": "An animal that woke us up at dawn"
  },
  {
   "answer": "KAYAK",
   "clue": "A boat for the one dry afternoon"
  },
  {
   "answer": "CHIPS",
   "clue": "Dinner three nights running"
  },
  {
   "answer": "TORCH",
   "clue": "A portable light you carry by hand"
  },
  {
   "answer": "CARD GAMES",
   "clue": "What we played when the rain came in"
  }
 ]
}

Build the input yourself

Load the engine in node (global.window = global; require("./acrosskit.js")), then:

const K = AcrossKit;
const form = K.form({ about: "Grandad's shed", style: "straight", words: "FLASK - sweet tea, no milk\nLAWNMOWER - starts on the third pull\nRADIO\nTOFFEE TIN - full of odd screws" });
const built = K.buildInput(form);          // built.facts holds the answers, letter counts and notes
const wire = K.toWire(built.input);        // POST this to /estimate and /run
const grid = K.layout(built.facts.words, 1); // the same answers and seed always give the same grid
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes              // the page's re-check of the reply
// K.puzzleOf(built.facts, grid, reply)     // the numbered puzzle (page clues fill any gap)

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://one-across.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":"one-across"}' | 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": "write-clues", "about": "A week of camping in the rain", "style": "straight", "words": "1. TENT (4) - leaked on the second night
..."", "facts": "...", ...}
# - see the field table
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 one-across:<task>:<inputHash>:a<attempt> (the one task is write-clues), 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="one-across:write-clues:$(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: one-across:write-clues:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json