← Half-Hour Wander
API

Drive Half-Hour Wander from your own code

The one metered task, wander, is available over HTTP. It takes the minutes you have (budget), the minutes of your plain loop from the door and back (loop_minutes), a tone (curious, calm or playful) and your stops, one to eight lines as n | spot | minutes | note. It returns every stop with a pull of 1 to 3 and one thing to look for there, the best stop, the verdict, the stops a trim leaves out and the total after.

The division of labour first: the page numbers the stops and adds up the minutes, and the verdict comes from those sums; the model rates each note, writes the words, names the best stop and picks the trim; the page works out the best stop and the trim again from the model's pulls, draws the dial and re-checks the reply - one stop back per stop listed and copied word for word, every pull against its note, the best stop, the verdict, the trim and the total after.

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":"half-hour-wander"}), 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, tone, budget, loop_minutes, stops, 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: reading a walk 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 status

Every run sends task: wander (the one task is wander); a missing or different one is still answered as wander. The status field holds one of two ids, checked in this order: declined when a spot or a note names a specific real person, is somewhere a walker is not allowed or that is unsafe to walk (on or along railway tracks, a motorway or its verge, a rooftop, a building site, a closed or fenced-off site, ice or frozen water, a cliff edge, a quarry or a derelict building), or is about violence, weapons, crime, drugs or anything sexual or hateful; otherwise ok.

For ok, every stop's pull is checked in a fixed order: 1 when the note is -; otherwise 3 when the note names a particular thing to see, hear, touch or do there; otherwise 2. The verdict is copied from the page's sums and holds one of three ids, checked in this order: trim when the total is more than the budget plus 2 minutes; otherwise room-to-spare when the total is at most the budget minus 8; otherwise set-off. The best stop (keeper) has the highest pull, then the fewest minutes, then the lowest number. A trim takes stops off one at a time, never the keeper - the lowest pull, then the most minutes, then the highest number - until the total is within the budget plus 2 or only the keeper is left.

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways wander.
toneyescurious, calm or playful.
budgetyesThe minutes for the whole walk, 10 to 90 (the page offers 15, 20, 30, 45 and 60).
loop_minutesyesThe minutes of the plain loop from the door and back with no stops, 0 to 180.
stopsyes1 to 8 lines as n | spot | minutes | note, numbered from 1. minutes is what the stop adds to the plain loop (3 when the person gave none, at most 30); note is the person's note, or -. Spots are cut at 60 characters and notes at 120, repeated spots are dropped and pipes in the text become slashes.
page_factsyesOne key | value per line: budget, loop_minutes, stop_minutes, total_minutes, over_by, spare, verdict, grace, then the hints harm_words, unsafe_words and capitalised_words.
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": "wander", "status": "ok", "reason": "", "walk_title": "...",
 "stops": [{"n": 1, "spot": "the flower stall", "pull": 3, "look_for": "...", "keep": "keep"},
           {"n": 2, ...}, ...],
 "keeper": 1, "dropped": [4, 5, 6], "total_minutes": 44, "total_after": 31,
 "verdict": "trim", "verdict_reason": "...", "plan_note": "...", "closing": "..."}
FieldWhat it holds
status, reasonThe status by the order above; for declined, at most 30 words saying why (empty for ok).
walk_title2 to 6 lowercase words naming the walk from the spots' own nouns (empty when declined).
stopsOne per input stop, in order ([] when declined): n and spot copied from the input, pull as above, look_for (at most 18 words: the thing the note names, or an invitation to choose something when the note is general or empty) and keep (keep or drop).
keeperThe number of the best stop (0 when declined).
dropped, total_afterThe stop numbers a trim leaves out, lowest first ([] unless the verdict is trim), and the plain loop plus every kept stop's minutes.
total_minutes, verdict, verdict_reasonThe total and the verdict id copied from page_facts, and at most 30 words on the total against the time available.
plan_noteAt most 30 words: the stops that come off and the new total (trim), the spare minutes offered at the best stop (room-to-spare), or that the walk fits and where to linger (set-off).
closingA light line of at most 16 words.

The page then checks the reply (WanderKit.check): one stop back for each stop sent, copied word for word, a pull of 1 exactly when the note is empty, the total, the verdict, the best stop, the dropped list and the total after recomputed from the reply's own pulls, the word limits, no number the input never gives, no he or she, and no internal field id in the prose. Anything that disagrees is listed under the reply; nothing is silently corrected.

Worked examples

Each example is the exact input the page sends for one of its four samples, and the saved reply it replays for free.

Bakery, footbridge, park and library steps - set off

Input:

{
 "task": "wander",
 "tone": "curious",
 "budget": "30",
 "loop_minutes": "15",
 "stops": "1 | the corner bakery | 3 | the clock over the door has stopped at ten past four\n2 | the canal footbridge | 4 | ducks wait under it in the mornings\n3 | the little park | 5 | nice\n4 | the library steps | 2 | -",
 "page_facts": "budget | 30\nloop_minutes | 15\nstop_minutes | 14\ntotal_minutes | 29\nover_by | 0\nspare | 1\nverdict | set-off\ngrace | 2\nharm_words | none\nunsafe_words | none\ncapitalised_words | none"
}

Reply:

{
 "task": "wander",
 "status": "ok",
 "reason": "",
 "walk_title": "bakery footbridge park steps",
 "stops": [
  {
   "n": 1,
   "spot": "the corner bakery",
   "pull": 3,
   "look_for": "See if the stopped clock over the door still shows ten past four.",
   "keep": "keep"
  },
  {
   "n": 2,
   "spot": "the canal footbridge",
   "pull": 3,
   "look_for": "Watch for the ducks that wait under the canal footbridge in the mornings.",
   "keep": "keep"
  },
  {
   "n": 3,
   "spot": "the little park",
   "pull": 2,
   "look_for": "Pick one thing in the little park that catches your eye and look closer.",
   "keep": "keep"
  },
  {
   "n": 4,
   "spot": "the library steps",
   "pull": 1,
   "look_for": "Choose something on the library steps worth a second glance.",
   "keep": "keep"
  }
 ],
 "keeper": 1,
 "dropped": [],
 "total_minutes": 29,
 "total_after": 29,
 "verdict": "set-off",
 "verdict_reason": "Your walk comes to 29 minutes against the 30 you have, so you can set off as planned.",
 "plan_note": "The walk fits as you listed it, with the corner bakery the best stop to linger at.",
 "closing": "Time to head out and enjoy your walk."
}

Six stops on a market walk - trim

Input:

{
 "task": "wander",
 "tone": "playful",
 "budget": "30",
 "loop_minutes": "18",
 "stops": "1 | the flower stall | 3 | buckets of tulips line the pavement\n2 | the old cinema front | 4 | the sign is missing a letter\n3 | the allotment gate | 6 | someone painted it bright blue\n4 | the cafe on Mill Street | 4 | good\n5 | the post box | 2 | -\n6 | the hill with the bench | 7 | you can see the gasworks from the top",
 "page_facts": "budget | 30\nloop_minutes | 18\nstop_minutes | 26\ntotal_minutes | 44\nover_by | 14\nspare | 0\nverdict | trim\ngrace | 2\nharm_words | none\nunsafe_words | none\ncapitalised_words | Mill, Street"
}

Reply:

{
 "task": "wander",
 "status": "ok",
 "reason": "",
 "walk_title": "flower stall, cinema front, allotment gate",
 "stops": [
  {
   "n": 1,
   "spot": "the flower stall",
   "pull": 3,
   "look_for": "Look out for the buckets of tulips that line the pavement.",
   "keep": "keep"
  },
  {
   "n": 2,
   "spot": "the old cinema front",
   "pull": 3,
   "look_for": "See if you can spot which letter is missing from the sign.",
   "keep": "keep"
  },
  {
   "n": 3,
   "spot": "the allotment gate",
   "pull": 3,
   "look_for": "Take a moment to spot the bright blue paint on the gate.",
   "keep": "keep"
  },
  {
   "n": 4,
   "spot": "the cafe on Mill Street",
   "pull": 2,
   "look_for": "Choose one thing at the cafe on Mill Street you have not noticed before.",
   "keep": "drop"
  },
  {
   "n": 5,
   "spot": "the post box",
   "pull": 1,
   "look_for": "Pick one thing at the post box you have not noticed before.",
   "keep": "drop"
  },
  {
   "n": 6,
   "spot": "the hill with the bench",
   "pull": 3,
   "look_for": "See the gasworks from the top for yourself.",
   "keep": "drop"
  }
 ],
 "keeper": 1,
 "dropped": [
  4,
  5,
  6
 ],
 "total_minutes": 44,
 "total_after": 31,
 "verdict": "trim",
 "verdict_reason": "Your planned stops add up to 44 minutes against the 30 you have, so this walk needs a trim before you can comfortably set off.",
 "plan_note": "Stepping off: the cafe on Mill Street, the post box and the hill with the bench, bringing your walk down to 31 minutes.",
 "closing": "Trim it down and you are ready to wander."
}

A pond walk with time left over - room to spare

Input:

{
 "task": "wander",
 "tone": "calm",
 "budget": "45",
 "loop_minutes": "20",
 "stops": "1 | the pond | 4 | herons stand on the far bank\n2 | the bus shelter mural | 2 | a painted fox on the back wall\n3 | the second-hand bookshop | 6 | -",
 "page_facts": "budget | 45\nloop_minutes | 20\nstop_minutes | 12\ntotal_minutes | 32\nover_by | 0\nspare | 13\nverdict | room-to-spare\ngrace | 2\nharm_words | none\nunsafe_words | none\ncapitalised_words | none"
}

Reply:

{
 "task": "wander",
 "status": "ok",
 "reason": "",
 "walk_title": "pond mural bookshop",
 "stops": [
  {
   "n": 1,
   "spot": "the pond",
   "pull": 3,
   "look_for": "See the herons that stand on the far bank.",
   "keep": "keep"
  },
  {
   "n": 2,
   "spot": "the bus shelter mural",
   "pull": 3,
   "look_for": "Look for the painted fox on the back wall.",
   "keep": "keep"
  },
  {
   "n": 3,
   "spot": "the second-hand bookshop",
   "pull": 1,
   "look_for": "Pick whatever at the second-hand bookshop catches your attention.",
   "keep": "keep"
  }
 ],
 "keeper": 2,
 "dropped": [],
 "total_minutes": 32,
 "total_after": 32,
 "verdict": "room-to-spare",
 "verdict_reason": "Your walk comes to 32 minutes against the 45 you have, leaving room to spend longer at one stop.",
 "plan_note": "You have 13 spare minutes to linger at the bus shelter mural, the best stop on this walk.",
 "closing": "Take it slow and enjoy the extra time."
}

Along the railway tracks - declined

Input:

{
 "task": "wander",
 "tone": "playful",
 "budget": "30",
 "loop_minutes": "12",
 "stops": "1 | the old railway tracks behind the station | 5 | you can walk along them to the next town\n2 | the newsagent | 2 | -",
 "page_facts": "budget | 30\nloop_minutes | 12\nstop_minutes | 7\ntotal_minutes | 19\nover_by | 0\nspare | 11\nverdict | room-to-spare\ngrace | 2\nharm_words | none\nunsafe_words | railway tracks\ncapitalised_words | none"
}

Reply:

{
 "task": "wander",
 "status": "declined",
 "reason": "This app only builds walks from places that are safe and fine to walk and look at - try an everyday list of streets, shops or parks instead.",
 "walk_title": "",
 "stops": [],
 "keeper": 0,
 "dropped": [],
 "total_minutes": 0,
 "total_after": 0,
 "verdict": "",
 "verdict_reason": "",
 "plan_note": "",
 "closing": "Thanks for sharing - wishing you a lovely day."
}

Build the input yourself

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

const K = WanderKit, D = WalkDraw;
const built = K.buildInput({ tone: "curious", budget: "30", loop: "15",
  stops: "the corner bakery | 3 | the clock over the door has stopped
the little park | 5 | nice" });
built.parsed.stops;              // [{n: 1, spot: "the corner bakery", minutes: 3, note: "the clock over the door has stopped"}, ...]
built.sums;                      // {loop: 15, add: 8, total: 23, budget: 30, verdict: "set-off", ...}
const wire = K.toWire(built.input);            // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes                          // the page's re-check of the reply
// K.check(reply, built).plan                           // the best stop, the trim and the total after, worked by the page
// D.svg({ budget: 30, loop: 15, stops: [{ n: 1, spot: "the corner bakery", minutes: 3, keep: true, keeper: true }] })   // the dial and stick as SVG
// K.md(built, reply)                                   // the walk card 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://half-hour-wander.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":"half-hour-wander"}' | 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": "wander", "tone": "curious", "budget": "30", "loop_minutes": "15", "stops": "1 | spot | 3 | note\n2 | spot | 4 | -", "page_facts": "budget | 30\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 half-hour-wander:<task>:<inputHash>:a<attempt> (the one task is wander), 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="half-hour-wander:wander:$(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: half-hour-wander:wander:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json