← First Mile
First Mile API
Tokens

Drive First Mile from your own code

Both metered lanes are available over HTTP. A natural use is a club's sign-up form or newsletter tool: your code sends the club's post and the organiser's notes with the page's reading, and gets back a newcomer walk of the night, or the four messages a first-timer should get around their first run.

The division of labour first: the page reads and flags; the model walks and writes; the page checks the model against its own facts. The eight stations, the paces, distances and start time, the dark-start estimate and every flag (no meeting point, fast floor, drop risk, one distance, dark start with no lights, pub-only after, notes-only answers) come from linekit.js, which runs in node as well as in the browser. Its checkWalk and checkArc functions then hold each reply to the night: every station once and in order, quotes found in the post or the notes, no smooth station the page found nothing for, and no clock time, pace, distance or link in a message that the post and notes do not contain.

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

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":"first-mile"}), 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": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or task 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: both lanes are metered and need 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

Every run sends task, one of two lanes over the same night. Run walk first when you can: arc reads its fixes through walk_findings.

taskReturns
walktitle, verdict (ready / patch / rebuild), summary; stations [{id, status, says, newcomer, fix}] - all eight, in line order, status smooth / friction / broken; first_twenty (3-5 steps); promises [{says, reality}] (0-3); host_notes (0-3).
arctitle, status (send / hold), summary; messages [{send, when, subject, body, covers}] - send is confirm, before, dayof, after, in that order; holds [{station, why, fix}] (empty for send).
VerdictMeaning
readyEvery station smooth and the page flagged nothing at the warn or fix level.
patchNo blocker, but at least one station with friction or a warn flag: a missing welcome, a fast floor, one long distance, a dark start with no word on lights, a pub-only after, an answer only in the notes.
rebuildA blocker: no exact meeting point, no start time, a club that says it will not wait, or no regroup with a fast floor - a newcomer may not get through the night at all.
send / holdThe arc's status: hold when a page fix flag, or the walk marking find or regroup broken, means the messages would have to hide a blocker. Hold still writes all four drafts and names the fixes.

The station ids, in line order: find, arrive, welcome, groups, route, regroup, after, return.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (LineKit.toWire does this). Only task is required by the schema.

Input fields

FieldTypeNotes
taskstringwalk or arc.
todaystringYYYY-MM-DD (the dark-start estimate uses the month).
clubstringThe club's name.
poststringThe club's public post as pasted - what a newcomer reads. Past 5,000 characters the middle is cut on whole lines around a marker.
howstringThe organiser's notes on how the night really goes; past 3,000 characters the middle is cut.
factsstring (JSON){club, day, start, start_from, paces_km, slowest_km, walk_option, distances_km, dark_likely, lights_mentioned, stations_found, stations_thin, stations_missing, channel}.
station_scanstring (JSON)[{id, status, evidence}]; status is found, thin or missing; evidence lines start post: or how:.
page_readsstring (JSON)[{level, code, at, text}]; level is fix, warn or info; at lists station ids.
page_verdictstringThe page's own reading: ready / patch / rebuild for walk, send / hold for arc.
walk_findingsstring (JSON or "")arc only: the walk's [{id, status, fix}], or "" when no walk was run.
channelstringarc only: chat or email.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with LineKit.parseReply, then normalize(reply, task), then check it: checkWalk(reply, state) returns {rows, notes, counts} (the eight stations merged with the page's reading, the notes shown under "Checked by the page", and the smooth / friction / broken counts); checkArc(reply, state, walk) returns {rows, notes, placeholders, covered} (the four messages with any fact problems, the notes, the number of [placeholders] to fill, the stations covered).

Worked examples

Replies produced against the app's prompt for the sample clubs (made-up clubs, places, people and paces; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.

task: walk - Riverside social - ready

Input:

{
 "task": "walk",
 "today": "2026-10-20",
 "club": "Riverside Thursday Social",
 "post": "Riverside Thursday Social - every Thursday at 18:30. Meet at the bandstand in Holloway Park, by the north gate. Free, no sign-up, all paces  ... (903 characters)",
 "how": "Usually 25 to 40 people, a third of them new in any month.\nPriya runs the welcome circle; Sam sweeps the walk-run group.\nWe post the route i ... (217 characters)",
 "facts": "{\"club\":\"Riverside Thursday Social\",\"day\":\"Thursday\",\"start\":\"18:30\",\"start_from\":\"form\",\"paces_km\":[\"5:45\",\"6:45\",\"7:15\",\"8:00\"],\"slowest_km\":\"8:00\",\"walk_option\":true,\"distances_km\":[3,5,7],\"dark_likely\":true,\"lights_mentioned\":true,\"stations_found\":8,\"stations_thin\":0,\"stations_missing\":0,\"channel\":\"chat\"}",
 "station_scan": "[{\"id\":\"find\",\"status\":\"found\",\"evidence\":[\"post: Meet at the bandstand in Holloway Park, by the north gate.\"]},{\"id\":\"arrive\",\"status\":\"found\",\"evidence\":[\"post: Bring a light or something reflective - it is dark by half six now.\",\"post: Bags go in the bag corner at Fern Cafe; the toilets are in there too.\"]},\"... 6 more\"]",
 "page_reads": "[{\"level\":\"info\",\"code\":\"dark-ok\",\"at\":[\"arrive\"],\"text\":\"A 18:30 start is likely dark at this time of year (an estimate); lights or hi-vis are mentioned.\"}]",
 "page_verdict": "ready"
}

Reply:

{
 "task": "walk",
 "title": "Riverside Thursday Social is ready for newcomers",
 "verdict": "ready",
 "summary": "This is a ready night: all eight stations, from finding the bandstand to hearing about next week, are smooth, and the only page flag is an info note about dark evenings that the lights reminder already covers. Named hosts at a set time, a pace group slow enough for a beginner, a lit route shared beforehand, a sweeper and footbridge regroup, and a non-drinking after option all give a first-timer what they need before they arrive. The one thing worth tightening is making sure both named hosts are visible at 18:25 and that each week's rotating sweeper knows to regroup at the footbridge, since a third of the group is new most months.",
 "stations": [
  {
   "id": "find",
   "status": "smooth",
   "says": "Meet at the bandstand in Holloway Park, by the north gate.",
   "newcomer": "A first-timer walks straight to the bandstand by the north gate, already knowing the day and the 18:30 start time.",
   "fix": ""
  },
  {
   "id": "arrive",
   "status": "smooth",
   "says": "Bags go in the bag corner at Fern Cafe; the toilets are in there too.",
   "newcomer": "A first-timer arrives knowing to bring a light for the dark evening and finds the bag corner and toilets at Fern Cafe without asking.",
   "fix": ""
  },
  {
   "id": "welcome",
   "status": "smooth",
   "says": "Look for Priya or Sam in the yellow hi-vis vests - they host the newcomer circle at 18:25 and will introduce you to your group.",
   "newcomer": "A first-timer spots Priya or Sam in yellow hi-vis at 18:25 and is introduced straight into a group.",
   "fix": ""
  },
  "..."
 ],
 "first_twenty": [
  "Have Priya or Sam wait at the bandstand in yellow hi-vis vests from 18:25 for the newcomer circle.",
  "Ask the newcomer which pace sounds right - walk-run, chatty or steady - and point them to that group.",
  "..."
 ],
 "promises": [],
 "host_notes": [
  "With about a third of the group new in any month, keep both Priya and Sam visible in hi-vis at 18:25 rather than just one of them.",
  "..."
 ]
}

task: walk - Canal crew - patch

Input:

{
 "task": "walk",
 "today": "2026-10-20",
 "club": "Lockwick Canal Crew",
 "post": "Lockwick Canal Crew runs Tuesdays at 7pm from the canal basin.\nFast and fun - 5:00 to 6:00/km. 8 km along the towpath and back.\nAll levels welcome! P ... (202 characters)",
 "how": "We meet by the bridge at the basin. Normally 15 to 20 people, mostly regulars.\nThe 6:00 group waits at the turnaround.\nNobody really does a  ... (229 characters)",
 "facts": "{\"club\":\"Lockwick Canal Crew\",\"day\":\"Tuesday\",\"start\":\"19:00\",\"start_from\":\"text\",\"paces_km\":[\"5:00\",\"6:00\"],\"slowest_km\":\"6:00\",\"walk_option\":false,\"distances_km\":[8],\"dark_likely\":true,\"lights_mentioned\":false,\"stations_found\":4,\"stations_thin\":3,\"stations_missing\":1,\"channel\":\"chat\"}",
 "station_scan": "[{\"id\":\"find\",\"status\":\"thin\",\"evidence\":[\"how: We meet by the bridge at the basin.\"]},{\"id\":\"arrive\",\"status\":\"missing\",\"evidence\":[]},\"... 6 more\"]",
 "page_reads": "[{\"level\":\"warn\",\"code\":\"notes-only\",\"at\":[\"find\"],\"text\":\"Find us is answered only in your notes (\\\"We meet by the bridge at the basin.\\\") - a newcomer reads the post, so put it there.\"},{\"level\":\"warn\",\"code\":\"notes-only\",\"at\":[\"regroup\"],\"text\":\"Regroup is answered only in your notes (\\\"The 6:00 group waits at the turnaround.\\\") - a newcomer reads the post, so put it there.\"},\"... 8 more\"]",
 "page_verdict": "patch"
}

Reply:

{
 "task": "walk",
 "title": "Lockwick Canal Crew's fast run needs a welcome",
 "verdict": "patch",
 "summary": "Lockwick Canal Crew's Tuesday run is a patch: nothing in the input turns a newcomer away outright, but several stations only work if you already know the regulars. \"All levels welcome!\" sits against a 6:00/km pace floor with no walk-run option, and nobody actually welcomes anyone before the 7:05 start; the exact meeting point and the 6:00 group's wait at the turnaround live only in your notes, not the post. The one change that matters most is opening a 7:00/km or walk-run group so a newcomer who isn't already fast has somewhere to run; after that, put the meeting point and the regroup wait into the post itself.",
 "stations": [
  {
   "id": "find",
   "status": "friction",
   "says": "We meet by the bridge at the basin.",
   "newcomer": "A first-timer reads the post, finds the canal basin, but has to search for the exact bridge because that detail only lives in the organiser's notes.",
   "fix": "Put the meeting point in the post: say the group meets by the bridge at the basin, not just \"from the canal basin.\""
  },
  {
   "id": "arrive",
   "status": "friction",
   "says": "",
   "newcomer": "A first-timer doesn't know what to bring, where to put a bag, or whether there's a toilet nearby, and - since the 7pm start is likely dark - gets no word on lights either.",
   "fix": "Add a short \"what to bring\" line to the post covering bags, toilets, and lights, since the run is likely dark by 7pm."
  },
  {
   "id": "welcome",
   "status": "friction",
   "says": "Nobody really does a welcome - people just start running at 7:05.",
   "newcomer": "A first-timer who doesn't know anyone isn't greeted by anyone, and the group is already moving by 7:05.",
   "fix": "Ask one regular runner to greet newcomers at the bridge before the 7:05 start."
  },
  "..."
 ],
 "first_twenty": [
  "Have a regular greet newcomers by the bridge at the basin as they arrive before 7pm.",
  "Point out the 6:00 group as the pace to join.",
  "..."
 ],
 "promises": [
  {
   "says": "All levels welcome!",
   "reality": "The slowest group runs 6:00/km with no walk-run option, and nobody really does a welcome - people just start running at 7:05."
  }
 ],
 "host_notes": [
  "A few newcomers didn't come back last month - check in with first-timers after their first run to see what put them off.",
  "..."
 ]
}

task: walk - Hill crew - rebuild

Input:

{
 "task": "walk",
 "today": "2026-10-20",
 "club": "Hillrattle Crew",
 "post": "HILLRATTLE CREW. Wednesdays. 10k with hill repeats on Gallows Hill.\nSub-5 pace, keep up or catch the bus home. Everyone welcome.",
 "how": "Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\nWe don't wait at the top of the hill.\nNo after- ... (213 characters)",
 "facts": "{\"club\":\"Hillrattle Crew\",\"day\":\"Wednesday\",\"start\":\"19:00\",\"start_from\":\"text\",\"paces_km\":[\"5:00 (est.)\"],\"slowest_km\":\"5:00\",\"walk_option\":false,\"distances_km\":[10],\"dark_likely\":true,\"lights_mentioned\":false,\"stations_found\":1,\"stations_thin\":5,\"stations_missing\":2,\"channel\":\"chat\"}",
 "station_scan": "[{\"id\":\"find\",\"status\":\"thin\",\"evidence\":[\"how: Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\"]},{\"id\":\"arrive\",\"status\":\"thin\",\"evidence\":[\"how: Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\"]},\"... 6 more\"]",
 "page_reads": "[{\"level\":\"fix\",\"code\":\"no-meeting-point\",\"at\":[\"find\"],\"text\":\"Nowhere says exactly where to meet - a first-timer cannot find a group they have never seen.\"},{\"level\":\"fix\",\"code\":\"drop-said\",\"at\":[\"regroup\"],\"text\":\"The club says it will not wait (\\\"Sub-5 pace, keep up or catch the bus home.\\\") - the slowest newcomer is told up front they will be left.\"},\"... 8 more\"]",
 "page_verdict": "rebuild"
}

Reply:

{
 "task": "walk",
 "title": "Hillrattle Crew: Sub-5 Pace, No Wait",
 "verdict": "rebuild",
 "summary": "This is a rebuild: find and regroup are broken, since nothing gives a newcomer an exact meeting point or time, and the club says plainly it will not wait at the top of the hill. Welcome, groups, route, after and return are all at best friction - the only pace group runs 5:00/km with no slower option, there is one distance, nothing happens at the finish, and next week is only ever mentioned in the chat. The change that matters most is naming an exact meeting spot and a firm start time, since a newcomer who cannot find the group never gets to the rest of the night.",
 "stations": [
  {
   "id": "find",
   "status": "broken",
   "says": "Start whenever enough people show up, usually around 19:00, somewhere near the top car park.",
   "newcomer": "They have no exact place or time to aim for, so they may not find the group at all before it sets off.",
   "fix": "Post an exact meeting spot at the top car park (not just \"somewhere near\") and a firm 19:00 start time."
  },
  {
   "id": "arrive",
   "status": "friction",
   "says": "Start whenever enough people show up, usually around 19:00, somewhere near the top car park.",
   "newcomer": "Not knowing exactly when the group leaves, and with the 19:00 start likely dark, they are unsure how early to arrive or what to bring to be seen.",
   "fix": "Give a firm arrival time and tell newcomers to bring lights or hi-vis for the likely dark 19:00 start."
  },
  {
   "id": "welcome",
   "status": "friction",
   "says": "Everyone welcome.",
   "newcomer": "They are told they are welcome but no one is named or described to watch for, so they do not know who to approach.",
   "fix": "Name one runner each week to greet newcomers, or say what they will be wearing, in the post."
  },
  "..."
 ],
 "first_twenty": [
  "Have the host meet the newcomer at the top car park before 19:00.",
  "Tell the newcomer the group runs sub-5 pace and won't wait at the top of the hill.",
  "..."
 ],
 "promises": [
  {
   "says": "Everyone welcome.",
   "reality": "The only pace group runs sub-5 (5:00/km) and the club does not wait at the top of the hill for anyone who falls behind."
  }
 ],
 "host_notes": [
  "Confirm 19:00 in the club's own listing - the post itself gives no time, it was only read from the notes.",
  "..."
 ]
}

task: arc - Riverside social - send

Input:

{
 "task": "arc",
 "today": "2026-10-20",
 "club": "Riverside Thursday Social",
 "post": "Riverside Thursday Social - every Thursday at 18:30. Meet at the bandstand in Holloway Park, by the north gate. Free, no sign-up, all paces  ... (903 characters)",
 "how": "Usually 25 to 40 people, a third of them new in any month.\nPriya runs the welcome circle; Sam sweeps the walk-run group.\nWe post the route i ... (217 characters)",
 "facts": "{\"club\":\"Riverside Thursday Social\",\"day\":\"Thursday\",\"start\":\"18:30\",\"start_from\":\"form\",\"paces_km\":[\"5:45\",\"6:45\",\"7:15\",\"8:00\"],\"slowest_km\":\"8:00\",\"walk_option\":true,\"distances_km\":[3,5,7],\"dark_likely\":true,\"lights_mentioned\":true,\"stations_found\":8,\"stations_thin\":0,\"stations_missing\":0,\"channel\":\"chat\"}",
 "station_scan": "[{\"id\":\"find\",\"status\":\"found\",\"evidence\":[\"post: Meet at the bandstand in Holloway Park, by the north gate.\"]},{\"id\":\"arrive\",\"status\":\"found\",\"evidence\":[\"post: Bring a light or something reflective - it is dark by half six now.\",\"post: Bags go in the bag corner at Fern Cafe; the toilets are in there too.\"]},\"... 6 more\"]",
 "page_reads": "[{\"level\":\"info\",\"code\":\"dark-ok\",\"at\":[\"arrive\"],\"text\":\"A 18:30 start is likely dark at this time of year (an estimate); lights or hi-vis are mentioned.\"}]",
 "page_verdict": "send",
 "walk_findings": "[{\"id\":\"find\",\"status\":\"smooth\",\"fix\":\"\"},{\"id\":\"arrive\",\"status\":\"smooth\",\"fix\":\"\"},\"... 6 more\"]",
 "channel": "chat"
}

Reply:

{
 "task": "arc",
 "title": "Riverside Thursday Social first-run messages",
 "status": "send",
 "summary": "Send: the only page flag is an info note that the dark evening already has lights covered, and the walk found all eight stations smooth, so nothing here needs to be held back. The arc leans on the named hosts Priya and Sam at 18:25, the three pace groups with a walk-run option, the lit riverside loop with a sweeper and footbridge regroup, and the group chat for next week's route.",
 "messages": [
  {
   "send": "confirm",
   "when": "as soon as you join the group chat",
   "subject": "You're in the Riverside Thursday Social chat.",
   "body": "Good to have you.\nWe run every Thursday at 18:30.\nA day or two before the run we'll send the exact meeting point, the pace groups and what to bring.",
   "covers": []
  },
  {
   "send": "before",
   "when": "a day or two before Thursday's run",
   "subject": "Know before Thursday: where to go and what to bring.",
   "body": "Thursday, 18:30, at the bandstand in Holloway Park, by the north gate.\nLook for Priya or Sam in the yellow hi-vis vests - they host the newcomer circle at 18:25 and will introduce you to your group.\nThree groups to choose from: walk-run (about 8:00/km), chatty (6:45-7:15/km) or steady (5:45/km), over 3 km, 5 km or 7 km, all on the same lit riverside loop.\nIt is dark by half six now, so bring a light or something reflective.\nBags go in the bag corner at Fern Cafe; the toilets are in there too.\nNo-drop: a sweeper runs at the back of every group and we regroup at the footbridge on each lap.\nAfterwards: coffee, tea or a juice at Fern Cafe next to the bandstand - stay as long as you like.",
   "covers": [
    "find",
    "arrive",
    "welcome",
    "groups",
    "route",
    "regroup",
    "after"
   ]
  },
  "..."
 ],
 "holds": []
}

task: arc - Hill crew - hold

Input:

{
 "task": "arc",
 "today": "2026-10-20",
 "club": "Hillrattle Crew",
 "post": "HILLRATTLE CREW. Wednesdays. 10k with hill repeats on Gallows Hill.\nSub-5 pace, keep up or catch the bus home. Everyone welcome.",
 "how": "Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\nWe don't wait at the top of the hill.\nNo after- ... (213 characters)",
 "facts": "{\"club\":\"Hillrattle Crew\",\"day\":\"Wednesday\",\"start\":\"19:00\",\"start_from\":\"text\",\"paces_km\":[\"5:00 (est.)\"],\"slowest_km\":\"5:00\",\"walk_option\":false,\"distances_km\":[10],\"dark_likely\":true,\"lights_mentioned\":false,\"stations_found\":1,\"stations_thin\":5,\"stations_missing\":2,\"channel\":\"chat\"}",
 "station_scan": "[{\"id\":\"find\",\"status\":\"thin\",\"evidence\":[\"how: Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\"]},{\"id\":\"arrive\",\"status\":\"thin\",\"evidence\":[\"how: Start whenever enough people show up, usually around 19:00, somewhere near the top car park.\"]},\"... 6 more\"]",
 "page_reads": "[{\"level\":\"fix\",\"code\":\"no-meeting-point\",\"at\":[\"find\"],\"text\":\"Nowhere says exactly where to meet - a first-timer cannot find a group they have never seen.\"},{\"level\":\"fix\",\"code\":\"drop-said\",\"at\":[\"regroup\"],\"text\":\"The club says it will not wait (\\\"Sub-5 pace, keep up or catch the bus home.\\\") - the slowest newcomer is told up front they will be left.\"},\"... 8 more\"]",
 "page_verdict": "hold",
 "walk_findings": "[{\"id\":\"find\",\"status\":\"broken\",\"fix\":\"Post an exact meeting spot at the top car park (not just \\\"somewhere near\\\") and a firm 19:00 start time.\"},{\"id\":\"arrive\",\"status\":\"friction\",\"fix\":\"Give a firm arrival time and tell newcomers to bring lights or hi-vis for the likely dark 19:00 start.\"},\"... 6 more\"]",
 "channel": "chat"
}

Reply:

{
 "task": "arc",
 "title": "Hold the Hillrattle Crew first-run arc",
 "status": "hold",
 "summary": "Hold: the post gives no exact meeting point and the club says plainly it won't wait at the top of the hill, so a newcomer could be lost before the run even starts and left behind on the climb. The four drafts below lean on what the post and notes actually give - Wednesdays, a 19:00-ish start, the 10k Gallows Hill route, and the sub-5 pace floor - with square-bracket placeholders standing in for the exact meeting spot, the host, and a sweeper until the club sets them.",
 "messages": [
  {
   "send": "confirm",
   "when": "Right when they say they're coming",
   "subject": "You're in for Hillrattle Crew.",
   "body": "You're on the list for Hillrattle Crew, Wednesdays - 10k with hill repeats on Gallows Hill.\nWe'll send the meeting point and what to bring a day or two before, then a short note on the day itself.",
   "covers": [
    "route"
   ]
  },
  {
   "send": "before",
   "when": "A day or two before their first Wednesday",
   "subject": "Know before Wednesday.",
   "body": "Hillrattle Crew, Wednesday - usually off around 19:00 from [exact meeting spot near the top car park].\nLook for [who to ask] if you don't see anyone you know - no one's named yet to meet newcomers.\nIt's one route: 10k with hill repeats on Gallows Hill, sub-5 pace throughout - there's no slower group right now, so come ready to hold that pace.\nA 19:00 start is likely dark this time of year - bring a light or something reflective.\nThe group doesn't wait at the top of the hill - come ready to hold the pace, or let the club know beforehand.\nNo after-run plan - people head off once it's done.\nBags or toilets: [ask the club - not said anywhere yet].",
   "covers": [
    "find",
    "arrive",
    "welcome",
    "groups",
    "route",
    "regroup",
    "after"
   ]
  },
  "..."
 ],
 "holds": [
  {
   "station": "find",
   "why": "Nothing says exactly where to meet - a first-timer has no group they've ever seen to recognise, and the 19:00 start is only read from the notes, not fixed in the post.",
   "fix": "Post an exact meeting spot at the top car park (not just \"somewhere near\") and a firm 19:00 start time."
  },
  {
   "station": "regroup",
   "why": "The club says plainly it won't wait at the top of the hill, so a newcomer who falls behind on the climb is left to find their own way back.",
   "fix": "Ask one runner to sweep the back of the group and wait at the top of the hill until everyone has passed."
  }
 ]
}

Build the input yourself

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

const K = LineKit;
const st = K.state({
  today: "2026-10-20", club: "Lockwick Canal Crew", when: "Tuesdays", start: "19:00", channel: "chat",
  post: "Lockwick Canal Crew runs Tuesdays at 7pm from the canal basin. 5:00 to 6:00/km. 8 km along the towpath and back.",
  how: "We meet by the bridge at the basin. The 6:00 group waits at the turnaround."
});
console.log(st.stations, st.reads, st.verdict);
const walkWire = K.toWire(K.buildInput(st, "walk", { today: "2026-10-20" }).input);   // POST to /estimate and /run
// later: const walk = K.normalize(K.parseReply(job.output.output), "walk"); K.checkWalk(walk, st);
const arcWire = K.toWire(K.buildInput(st, "arc", { today: "2026-10-20", walk }).input); // carries walk_findings
// later: const arc = K.normalize(K.parseReply(job2.output.output), "arc"); K.checkArc(arc, st, walk);

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-mile.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-mile"}' | 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": "walk", "today": "2026-10-20", "club": "Lockwick Canal Crew", "post": "Lockwick Canal Crew runs Tuesdays at 7pm from the canal basin. ...", "how": "We meet by the bridge at the basin. ...", "facts": "{...}", "station_scan": "[...]", "page_reads": "[...]", "page_verdict": "patch"}
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-mile:<lane>:<inputHash>:a<attempt> (lane = walk or arc), 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-mile:walk:$(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-mile:walk:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json