← First and Last
API

Drive First and Last from your own code

The one metered task, order, is available over HTTP. It takes what a short list is for and who will hear or see it, plus the list itself - three to ten numbered lines - and returns which lines matter most for that goal, whether the order may change, and an order that puts what matters in the strong spots at the start and the end, with a reason per move, an opening line to say first and a recap to say last.

The division of labour first: the page draws the washing line and works out the strong spots and the placement; the model judges which lines matter, whether the order can move, and words the reasons, opener and recap. The page reads the list (bullets and numbering stripped, a * at either end of a line stars it), works out the strong spots of a list that long and the order they fill in, and sends both. After the reply it places the lines itself from the reply's key lines - whatever order the reply wrote - and re-checks every part against the list. Your code can do the same with the engine files the page loads (see "Build the input yourself").

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":"first-and-last"}), 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, goal, items, item_count, starred, order_hint, strong_spots, fill_order, pick_count) 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 placement run is metered and needs a personal sign-in.
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, the input and the rules

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

FieldRequiredWhat it holds
taskyesAlways order.
goalyesWhat the list is for and who will hear or see it, in the person's words: 8 to 160 characters.
itemsyesNumbered lines N. the item, three to ten, at most 80 characters each, in the order the person wrote them. Build it with ListKit.buildInput, which strips bullets and numbering and reads the stars.
item_countyesHow many lines items has, as a string.
starredyesThe line numbers the person starred as must-remember, comma-separated (1, 6), or none. At most as many as the list has strong spots.
order_hintyesfixed (keep my order), free (the order can change) or unsure (let the run decide).
strong_spotsyesThe strong spots of a list this long, as positions (see the table below), e.g. 1, 2, 6, 7.
fill_orderyesThe order those spots fill in: first, last, second, second-to-last, e.g. 1, 7, 2, 6.
pick_countyesHow many lines to pick when nothing is starred: 2 for five lines or fewer, else 3.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

The strong spots by list length (the first and last places, where things stick; the middle is where they get lost):

Linesstrong_spotsfill_orderpick_count
31, 31, 32
41, 41, 42
51, 2, 51, 5, 22
61, 2, 5, 61, 6, 2, 53
71, 2, 6, 71, 7, 2, 63
81, 2, 7, 81, 8, 2, 73
91, 2, 8, 91, 9, 2, 83
101, 2, 9, 101, 10, 2, 93

The reply takes one verdict: bookend when the order must stay (steps, a time order, or order_hint is fixed) - the lines keep their order and the opener and recap carry what matters; keep when every key line already sits in a strong spot; else reorder - the key lines go into the strong spots in fill order and every other line keeps its relative order in the middle.

The reply

One JSON object:

{"task": "order", "verdict": "reorder", "key": [3, 7, 1], "order": [3, 1, 2, 4, 5, 6, 7], "moves": [{"n": 3, "why": "..."}], "drop": [{"n": 5, "why": "..."}], "opener": "...", "recap": "...", "note": "..."}
FieldWhat it holds
verdictbookend, keep or reorder.
keyThe line numbers that matter most for the goal, most important first: the starred lines when there are any, else pick_count of them.
orderThe reply's own order, every line number once. The page does not trust it: it recomputes the placement.
moves[]For reorder: {n, why}, one short reason per line that moves; empty otherwise.
drop[]At most two {n, why}: lines that could be left out. Never a key or starred line.
openerA line to say first that names what matters, in the list's own words.
recapA line to say last that repeats what matters.
noteOne sentence on why the verdict fits.

The page reads the reply with ReplyKit.normalize, places the lines with ReplyKit.orderFor(reply, count, fixed) (the placement rule over the key lines for reorder; the person's own order for bookend, keep, an unreadable verdict, or whenever order_hint is fixed), and checks it with ReplyKit.check(reply, built), which lists, under "The page's check", a missing verdict, key lines the list does not have, starred lines left out of what matters, a keep with a key line in the middle, a fixed order the reply did not keep, a reply order that is not the placement rule's, moves without a reason, a drop of a starred or key line, a missing opener or recap or one that does not name the key lines, and any number or internal field name the list never gives.

Worked examples - every sample

These are the free samples on the page: the exact input the page sends, the reply that replays for free, and what the page does with it - the order its line shows and its own check. Together they cover the verdicts reorder, keep, bookend.

A doorstep handover to a flat-sitter

Input:

{
 "task": "order",
 "goal": "Telling the neighbour who is minding our flat for the weekend what to do, in a quick chat on the doorstep",
 "items": "1. water the basil on the windowsill\n2. the post goes on the hall table\n3. the cat gets one tablet hidden in her breakfast\n4. the Wi-Fi password is on the fridge\n5. we got new curtains in the bedroom\n6. help yourself to anything in the biscuit tin\n7. double-lock the front door when you leave",
 "item_count": "7",
 "starred": "none",
 "order_hint": "unsure",
 "strong_spots": "1, 2, 6, 7",
 "fill_order": "1, 7, 2, 6",
 "pick_count": "3"
}

Reply:

{
 "task": "order",
 "verdict": "reorder",
 "key": [
  3,
  7,
  1
 ],
 "order": [
  3,
  1,
  2,
  4,
  5,
  6,
  7
 ],
 "moves": [
  {
   "n": 3,
   "why": "The cat's tablet is hidden in her breakfast, an easy detail to miss during a quick chat."
  },
  {
   "n": 1,
   "why": "The basil on the windowsill needs watering, something easy to forget over the weekend."
  },
  {
   "n": 2,
   "why": "This one shifts to make space and is mentioned in the middle of the chat instead."
  }
 ],
 "drop": [
  {
   "n": 5,
   "why": "The new curtains are background news and do not help with any task this weekend."
  }
 ],
 "opener": "Three things matter most: the cat's tablet hidden in her breakfast, double-locking the front door when you leave, and watering the basil on the windowsill.",
 "recap": "To finish, the cat needs her tablet in breakfast, the front door gets double-locked, and the basil wants watering.",
 "note": "The cat's tablet and the basil both moved to the start, and the front door stays at the end."
}

The page's line (ReplyKit.orderFor gives [3, 1, 2, 4, 5, 6, 7]):

  1. the cat gets one tablet hidden in her breakfast - strong spot
  2. water the basil on the windowsill - strong spot
  3. the post goes on the hall table
  4. the Wi-Fi password is on the fridge
  5. we got new curtains in the bedroom
  6. help yourself to anything in the biscuit tin - strong spot
  7. double-lock the front door when you leave - strong spot

The page's check: kept.

No notes.

A sign for a car-boot stall

Input:

{
 "task": "order",
 "goal": "A chalkboard sign at our car-boot stall listing what we sell, read by people walking past",
 "items": "1. vinyl records\n2. children's books\n3. kitchen odds and ends\n4. board games\n5. lamps\n6. camping gear",
 "item_count": "6",
 "starred": "1, 6",
 "order_hint": "free",
 "strong_spots": "1, 2, 5, 6",
 "fill_order": "1, 6, 2, 5",
 "pick_count": "3"
}

Reply:

{
 "task": "order",
 "verdict": "keep",
 "key": [
  1,
  6
 ],
 "order": [
  1,
  2,
  3,
  4,
  5,
  6
 ],
 "moves": [],
 "drop": [],
 "opener": "Two things matter most on this sign: vinyl records and camping gear.",
 "recap": "Vinyl records and camping gear remain the two things that matter most here.",
 "note": "This order stays because vinyl records and camping gear sit at the start and the end of your sign."
}

The page's line (ReplyKit.orderFor gives [1, 2, 3, 4, 5, 6]):

  1. vinyl records (starred) - strong spot
  2. children's books - strong spot
  3. kitchen odds and ends
  4. board games
  5. lamps - strong spot
  6. camping gear (starred) - strong spot

The page's check: kept.

No notes.

Restarting the router over the phone

Input:

{
 "task": "order",
 "goal": "Talking my dad through restarting the home Wi-Fi router over the phone",
 "items": "1. unplug the router at the wall\n2. wait a full minute before plugging it back in\n3. plug it back in\n4. wait until the light on the front goes steady green\n5. then try the internet on the laptop again",
 "item_count": "5",
 "starred": "none",
 "order_hint": "unsure",
 "strong_spots": "1, 2, 5",
 "fill_order": "1, 5, 2",
 "pick_count": "2"
}

Reply:

{
 "task": "order",
 "verdict": "bookend",
 "key": [
  2,
  4
 ],
 "order": [
  1,
  2,
  3,
  4,
  5
 ],
 "moves": [],
 "drop": [],
 "opener": "Two points matter most: wait a full minute before plugging the router back in, and wait until the light on the front goes steady green.",
 "recap": "Leave the router unplugged for the full minute, then check that the light on the front turns steady green.",
 "note": "Each step depends on the step before, so the order stays, and the opener and recap carry what matters most to the start and the end for you."
}

The page's line (ReplyKit.orderFor gives [1, 2, 3, 4, 5]):

  1. unplug the router at the wall - strong spot
  2. wait a full minute before plugging it back in - strong spot
  3. plug it back in
  4. wait until the light on the front goes steady green
  5. then try the internet on the laptop again - strong spot

The page's check: kept.

No notes.

Build the input yourself

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

const K = ListKit, R = ReplyKit;
const built = K.buildInput(K.form({ goal: "Telling the flat-sitter what to do this weekend", items: "water the basil\n* feed the cat\ndouble-lock the door", order: "unsure" }));  // built.ok is false, with built.note, until there is a goal and three lines
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.orderFor(reply, built.n, wire.order_hint === "fixed")   // the line numbers in the page's order
// R.check(reply, built).notes                              // the page's re-check of every part

Form keys: goal (what the list is for and who hears or sees it), items (one per line, three to ten, 80 characters each; a * at either end stars a line) and order (unsure, free or fixed).

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://first-and-last.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":"first-and-last"}' | 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": "order", "goal": "Telling the flat-sitter what to do this weekend", "items": "1. water the basil\n2. feed the cat\n3. double-lock the door", "item_count": "3", "starred": "none", "order_hint": "unsure", "strong_spots": "1, 3", "fill_order": "1, 3", "pick_count": "2"}
# - 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 first-and-last:<task>:<inputHash>:a<attempt> (the one task is order), 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="first-and-last:order:$(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: first-and-last:order:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json