← One Match Left
API

Drive One Match Left from your own code

The one metered task, sharpen, is available over HTTP. It takes a very short scary story - two sentences is the form - with the page's split into sentences, the words of the last sentence numbered from 1, the tone wanted, what to leave out, the word the writer marked as the turn and the page's cue-word facts. It returns one verdict (make_it_two, named_too_soon, no_turn, cut_the_tail or strike_it), whether the first line gives it away and whether the last sentence turns, the turn word and how many words follow it, what the first line sets up and what the turn changes, 1 to 4 notes that quote the draft, a sharpened two-sentence version with its own turn word, what changed, a title and a closing line.

The division of labour first: the page splits the draft and numbers the words; the model reads the meaning; the page then re-checks the reply against the draft - the verdict against the order, the turn word against the word it points at, the words after it, quotes copied word for word, and a sharpened version of exactly two sentences with its turn in its last 4 words. A game for fun, not literary or professional advice.

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":"one-match-left"}), 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, story, sentences, last_words, tone, keep_out, my_turn, page_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 editor's reading 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 and the verdicts

Every run sends task: sharpen (the one task is sharpen); a missing or different one is still answered as sharpen. The verdict field holds one of five ids, by a fixed check order that stops at the first that fits: make_it_two when the page's sentence count is not 2; named_too_soon when gives_it_away is true; no_turn when turns is false; cut_the_tail when more than 3 words of the last sentence follow the turn word; otherwise strike_it. The page recomputes the verdict from the reply's own fields and flags any mismatch.

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways sharpen.
storyyesThe draft as typed, whitespace squashed, up to 700 characters (cut back to whole sentences past that).
sentencesyesThe page's split, one per line as s | sentence, numbered from 1. Everything before the last is the first line.
last_wordsyesThe words of the last sentence as 1 word | 2 word | ...; punctuation is not counted and a dash splits words.
toneyeschilling, quiet dread or darkly funny.
keep_outyesThings to leave out, up to 80 characters, or nothing named.
my_turnyesThe word the writer marked, as word 7 (blew), or not marked.
page_factsyesOne key | ... per line: sentence_count, words, last_sentence_words, the cue-word hints and verdict_rule.
clip_notenoWhat the page left out, if anything.
retry_notenoOnly on the page's one reformat retry, after an unusable reply.

Reply (one JSON object)

{"task": "sharpen", "verdict": "strike_it", "verdict_reason": "...",
 "gives_it_away": false, "turns": true, "turn_at": 7, "turn_word": "blew", "words_after_turn": 3,
 "first_line_sets_up": "...", "last_line_changes": "...",
 "notes": [{"kind": "other", "quote": "...", "fix": "..."}],
 "sharpened": ["...", "..."], "sharpened_turn_at": 9, "sharpened_turn_word": "...",
 "what_changed": "...", "title": "...", "closing": "..."}
FieldWhat it holds
verdict, verdict_reasonThe verdict id by the check order above; at most 30 words on what decided it.
gives_it_away, turnsBooleans: the first line names the threat; the last sentence changes what the first meant.
turn_at, turn_word, words_after_turnThe number in last_words of the word where the meaning flips, that word, and how many words follow it (0, "" and 0 when there is no turn).
first_line_sets_up, last_line_changesAt most 16 words each: the ordinary picture, and what the turn makes the listener realise.
notes1 to 4 notes, each with kind (giveaway, telling, explaining, tail, cliche or other), a quote of 1 to 8 words copied from the story and a fix of at most 20 words.
sharpened, sharpened_turn_at, sharpened_turn_wordExactly 2 sentences, at most 45 words, in the tone; the number and word of its turn in the second sentence, with at most 3 words after it.
what_changed, title, closingAt most 30 words on what the sharpened version does differently; a 2 to 4 word title; a closing line of at most 16 words.

The page then checks the reply (MatchKit.check): the verdict against its own fields, the turn word against the word turn_at points at, the count of words after it, quotes copied word for word, the note count and word limits, a sharpened version of exactly 2 sentences with its turn word where it says and at most 3 words after it, no telling, explaining or waking-up words in it, no new names, no guessed he or she, stray numbers and leaked field ids. It also reconciles its own cue words and the writer's mark: a threat word in the first line that the reply waves through, explaining or telling words with no matching note, and a mark that differs from the reply's turn.

Worked examples

These are the five free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover all five verdicts - a porch light that turns on its last words (strike it), an attic story that says ghost in its first line (named too soon), a dark house that never turns (no turn yet), a window story that keeps explaining after its turn (cut the tail), and a slipper story told in four sentences (make it two).

The porch light - strike it

Input:

{
 "task": "sharpen",
 "story": "For a week I have thanked my husband for leaving the porch light on when I get home late. Tonight he told me the bulb blew out on Monday.",
 "sentences": "1 | For a week I have thanked my husband for leaving the porch light on when I get home late.\n2 | Tonight he told me the bulb blew out on Monday.",
 "last_words": "1 Tonight | 2 he | 3 told | 4 me | 5 the | 6 bulb | 7 blew | 8 out | 9 on | 10 Monday",
 "tone": "quiet dread",
 "keep_out": "nothing named",
 "my_turn": "word 7 (blew)",
 "page_facts": "sentence_count | 2\nwords | 29 in all; sentence 1: 19, sentence 2: 10\nlast_sentence_words | 10\nthreat_words_in_first_line | none\ntelling_words | none\nexplaining_words_in_last_sentence | none\nwaking_or_dream_words | none\nverdict_rule | make_it_two when sentence_count is not 2; else named_too_soon when the first line already names the threat or says what is wrong; else no_turn when the last sentence does not change what the first line meant; else cut_the_tail when more than 3 words follow the turn word; else strike_it"
}

Reply:

{
 "task": "sharpen",
 "verdict": "strike_it",
 "verdict_reason": "2 sentences, nothing gives it away, the ending turns the meaning, and only 3 words follow the turn - strike it.",
 "gives_it_away": false,
 "turns": true,
 "turn_at": 7,
 "turn_word": "blew",
 "words_after_turn": 3,
 "first_line_sets_up": "For a week, the narrator has thanked the husband for leaving the porch light on.",
 "last_line_changes": "The husband could not have turned on a light whose bulb already failed.",
 "notes": [
  {
   "kind": "other",
   "quote": "the bulb blew out on Monday",
   "fix": "Keep this reveal exactly as it is; it already lands without any help."
  }
 ],
 "sharpened": [
  "For a week I have thanked my husband for leaving the porch light on when I get home late.",
  "Tonight he told me the bulb blew out on Monday."
 ],
 "sharpened_turn_at": 7,
 "sharpened_turn_word": "blew",
 "what_changed": "Nothing needed changing - your two sentences already build quiet dread and land the turn precisely.",
 "title": "The Porch Light",
 "closing": "This draft already strikes true - trust the quiet reveal you built."
}

The attic - named too soon

Input:

{
 "task": "sharpen",
 "story": "The ghost in our attic has knocked on the ceiling every night since we moved in. Last night the knocking came from under our bed instead.",
 "sentences": "1 | The ghost in our attic has knocked on the ceiling every night since we moved in.\n2 | Last night the knocking came from under our bed instead.",
 "last_words": "1 Last | 2 night | 3 the | 4 knocking | 5 came | 6 from | 7 under | 8 our | 9 bed | 10 instead",
 "tone": "chilling",
 "keep_out": "nothing named",
 "my_turn": "not marked",
 "page_facts": "sentence_count | 2\nwords | 26 in all; sentence 1: 16, sentence 2: 10\nlast_sentence_words | 10\nthreat_words_in_first_line | ghost\ntelling_words | none\nexplaining_words_in_last_sentence | none\nwaking_or_dream_words | none\nverdict_rule | make_it_two when sentence_count is not 2; else named_too_soon when the first line already names the threat or says what is wrong; else no_turn when the last sentence does not change what the first line meant; else cut_the_tail when more than 3 words follow the turn word; else strike_it"
}

Reply:

{
 "task": "sharpen",
 "verdict": "named_too_soon",
 "verdict_reason": "Named too soon: with 2 sentences present, the first line already says ghost, giving the threat away.",
 "gives_it_away": true,
 "turns": true,
 "turn_at": 7,
 "turn_word": "under",
 "words_after_turn": 3,
 "first_line_sets_up": "A ghost knocking on the attic ceiling every night since we moved in.",
 "last_line_changes": "The ghost is no longer in the attic; it's under the bed instead.",
 "notes": [
  {
   "kind": "giveaway",
   "quote": "The ghost in our attic",
   "fix": "Cut 'ghost' from the opening line and let the knocking alone set the scene."
  },
  {
   "kind": "other",
   "quote": "under our bed instead",
   "fix": "Keep this close, physical turn; it's doing the real scaring here."
  }
 ],
 "sharpened": [
  "Something has knocked on our attic ceiling every night since we moved in.",
  "Last night the knocking came from under our bed instead."
 ],
 "sharpened_turn_at": 7,
 "sharpened_turn_word": "under",
 "what_changed": "You dropped ghost from the opening so the knocking alone sets the scene, letting the under-the-bed turn land as a surprise.",
 "title": "The Nightly Knock",
 "closing": "Drop the ghost and let the shift from ceiling to bed do the work."
}

The house on the lane - no turn yet

Input:

{
 "task": "sharpen",
 "story": "The old house at the end of the lane was dark and silent as I walked up the path. Inside, the floorboards creaked and a cold wind moved through the empty rooms.",
 "sentences": "1 | The old house at the end of the lane was dark and silent as I walked up the path.\n2 | Inside, the floorboards creaked and a cold wind moved through the empty rooms.",
 "last_words": "1 Inside | 2 the | 3 floorboards | 4 creaked | 5 and | 6 a | 7 cold | 8 wind | 9 moved | 10 through | 11 the | 12 empty | 13 rooms",
 "tone": "chilling",
 "keep_out": "nothing named",
 "my_turn": "not marked",
 "page_facts": "sentence_count | 2\nwords | 32 in all; sentence 1: 19, sentence 2: 13\nlast_sentence_words | 13\nthreat_words_in_first_line | none\ntelling_words | none\nexplaining_words_in_last_sentence | none\nwaking_or_dream_words | none\nverdict_rule | make_it_two when sentence_count is not 2; else named_too_soon when the first line already names the threat or says what is wrong; else no_turn when the last sentence does not change what the first line meant; else cut_the_tail when more than 3 words follow the turn word; else strike_it"
}

Reply:

{
 "task": "sharpen",
 "verdict": "no_turn",
 "verdict_reason": "No turn yet: the second sentence just adds more of the same quiet, empty house, nothing flips the picture.",
 "gives_it_away": false,
 "turns": false,
 "turn_at": 0,
 "turn_word": "",
 "words_after_turn": 0,
 "first_line_sets_up": "An old house at a lane's end, dark and silent as you approach.",
 "last_line_changes": "Nothing changes yet: the sounds only confirm the house is as empty as it looked.",
 "notes": [
  {
   "kind": "other",
   "quote": "a cold wind moved through the empty rooms",
   "fix": "Give the creak or wind a reason that breaks the quiet, not just more of it."
  },
  {
   "kind": "other",
   "quote": "the floorboards creaked",
   "fix": "Let that creak follow you instead of just happening, so the quiet turns personal."
  }
 ],
 "sharpened": [
  "The old house at the end of the lane stood dark and silent as I climbed the path.",
  "Inside, the floorboards creaked behind me, though I had not moved yet."
 ],
 "sharpened_turn_at": 11,
 "sharpened_turn_word": "moved",
 "what_changed": "You added a turn: the creak keeps going after you stop moving, so the house is not as empty as it looked.",
 "title": "The Empty House",
 "closing": "You're close, just give the creak behind you a reason to linger."
}

The upstairs window - cut the tail

Input:

{
 "task": "sharpen",
 "story": "My wife waved at me from the upstairs window as I pulled into the driveway. Then my phone rang, and it was my wife saying her train was late and she would not be home for an hour, so I realised that whoever was at the window was not her.",
 "sentences": "1 | My wife waved at me from the upstairs window as I pulled into the driveway.\n2 | Then my phone rang, and it was my wife saying her train was late and she would not be home for an hour, so I realised that whoever was at the window was not her.",
 "last_words": "1 Then | 2 my | 3 phone | 4 rang | 5 and | 6 it | 7 was | 8 my | 9 wife | 10 saying | 11 her | 12 train | 13 was | 14 late | 15 and | 16 she | 17 would | 18 not | 19 be | 20 home | 21 for | 22 an | 23 hour | 24 so | 25 I | 26 realised | 27 that | 28 whoever | 29 was | 30 at | 31 the | 32 window | 33 was | 34 not | 35 her",
 "tone": "chilling",
 "keep_out": "no blood",
 "my_turn": "not marked",
 "page_facts": "sentence_count | 2\nwords | 50 in all; sentence 1: 15, sentence 2: 35\nlast_sentence_words | 35\nthreat_words_in_first_line | none\ntelling_words | none\nexplaining_words_in_last_sentence | i realised\nwaking_or_dream_words | none\nverdict_rule | make_it_two when sentence_count is not 2; else named_too_soon when the first line already names the threat or says what is wrong; else no_turn when the last sentence does not change what the first line meant; else cut_the_tail when more than 3 words follow the turn word; else strike_it"
}

Reply:

{
 "task": "sharpen",
 "verdict": "cut_the_tail",
 "verdict_reason": "Cut the tail: 15 words follow the turn.",
 "gives_it_away": false,
 "turns": true,
 "turn_at": 20,
 "turn_word": "home",
 "words_after_turn": 15,
 "first_line_sets_up": "Your wife waving from the upstairs window as you pull into the driveway.",
 "last_line_changes": "The person waving from the window could not have been your wife after all.",
 "notes": [
  {
   "kind": "explaining",
   "quote": "so I realised that",
   "fix": "Cut this explaining and let the window detail speak for itself."
  },
  {
   "kind": "tail",
   "quote": "was not her",
   "fix": "Delete this; the reveal already landed without it."
  }
 ],
 "sharpened": [
  "My wife waved at me from the upstairs window as I pulled into the driveway.",
  "Then my phone rang: it was my wife, saying her train was late and she would not be home for an hour."
 ],
 "sharpened_turn_at": 19,
 "sharpened_turn_word": "home",
 "what_changed": "You cut the closing explanation and stop right after she says she won't be home, letting readers feel the shift themselves.",
 "title": "The Window Wave",
 "closing": "Trim the ending and let this quiet reveal stand on its own."
}

The slippers - make it two

Input:

{
 "task": "sharpen",
 "story": "I live alone. Every night I leave my slippers by the front door. Every morning they are beside my bed. This morning they were by the front door again, and they were still warm.",
 "sentences": "1 | I live alone.\n2 | Every night I leave my slippers by the front door.\n3 | Every morning they are beside my bed.\n4 | This morning they were by the front door again, and they were still warm.",
 "last_words": "1 This | 2 morning | 3 they | 4 were | 5 by | 6 the | 7 front | 8 door | 9 again | 10 and | 11 they | 12 were | 13 still | 14 warm",
 "tone": "quiet dread",
 "keep_out": "nothing named",
 "my_turn": "word 14 (warm)",
 "page_facts": "sentence_count | 4\nwords | 34 in all; sentence 1: 3, sentence 2: 10, sentence 3: 7, sentence 4: 14\nlast_sentence_words | 14\nthreat_words_in_first_line | none\ntelling_words | none\nexplaining_words_in_last_sentence | none\nwaking_or_dream_words | none\nverdict_rule | make_it_two when sentence_count is not 2; else named_too_soon when the first line already names the threat or says what is wrong; else no_turn when the last sentence does not change what the first line meant; else cut_the_tail when more than 3 words follow the turn word; else strike_it"
}

Reply:

{
 "task": "sharpen",
 "verdict": "make_it_two",
 "verdict_reason": "4 sentences, not 2; the format asks for exactly two.",
 "gives_it_away": false,
 "turns": true,
 "turn_at": 14,
 "turn_word": "warm",
 "words_after_turn": 0,
 "first_line_sets_up": "Someone living alone, slippers left by the front door, found beside the bed each morning.",
 "last_line_changes": "The slippers are back at the door and still warm, meaning something was just there.",
 "notes": [
  {
   "kind": "other",
   "quote": "I live alone",
   "fix": "merge this into one setup sentence with the nightly habit."
  },
  {
   "kind": "other",
   "quote": "they are beside my bed",
   "fix": "fold this habit into the same opening sentence instead of its own line."
  }
 ],
 "sharpened": [
  "I live alone and leave my slippers by the front door every night, finding them beside my bed every morning.",
  "This morning they were by the front door again, still warm."
 ],
 "sharpened_turn_at": 11,
 "sharpened_turn_word": "warm",
 "what_changed": "You compressed 4 sentences into 2, keeping the same routine and the same warm-slippers turn, just tighter.",
 "title": "By the Door",
 "closing": "Trim it to two sentences and you'll have exactly the shape this game wants."
}

Build the input yourself

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

const K = MatchKit;
const built = K.buildInput({ story: "For a week I have thanked my husband for leaving the porch light on. Tonight he told me the bulb blew out on Monday.",
  tone: "quiet dread", keep_out: "", my_turn: 7 });
built.parsed.count;              // 2 sentences
built.parsed.lastWords;          // the matches: ["Tonight", "he", "told", ...]
built.prescan.myTurn;            // { at: 7, word: "blew", after: 3 }
const wire = K.toWire(built.input);            // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.verdictFrom(reply, built.parsed)   // the verdict the reply's own fields give
// K.check(reply, built).notes          // the page's re-check of the reply
// K.md(built, reply)                   // the reading as Markdown

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-match-left.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-match-left"}' | 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": "sharpen", "story": "...", "sentences": "1 | ...\n2 | ...", "last_words": "1 Tonight | 2 he | ...", "tone": "quiet dread", "keep_out": "nothing named", "my_turn": "not marked", "page_facts": "sentence_count | 2\n..."} - 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-match-left:<task>:<inputHash>:a<attempt> (the one task is sharpen), 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-match-left:sharpen:$(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-match-left:sharpen:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json