← Sand Left
API
Tokens

Drive Sand Left from your own code

The one metered task, rescue, is available over HTTP. It takes a shelf of keepsakes - what each one is, where it lives, how many years that place has existed, how many copies exist elsewhere, and the signals - together with the page's own figures (the rule-of-thumb odds, a colour and an action per keepsake, the lifeboat order) and returns a verdict (rescue-now, copy-soon or steady) and a rescue plan: for every keepsake why it sits where it does, the first step and what to keep it as, plus 2 to 5 weekend steps and up to three watch-outs. A natural use is a family or club archive checklist that re-reads its shelf each season, or a community that is closing and wants to hand members a rescue order.

The division of labour first: the page owns the arithmetic; the model owns the judgement and the words; the page re-checks the reply. Reading the shelf, the Lindy odds, the signals, the colours, the actions and the lifeboat order come from sandkit.js, which runs in node as well as in the browser. The model never works out odds: SandKit.buildInput hands it every figure. SandKit.check holds each reply to them: the verdict by the rule, one plan per keepsake in lifeboat order with the page's action, and no number, capitalised name, he/she, field name or brand that the input does not hold.

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":"sand-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, items, figures) 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 rescue plan 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 verdict rule

Every run sends task: rescue (the only task; a missing or unknown one is treated as rescue). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:

  1. rescue-now - a keepsake sits under a closing notice with no copy elsewhere (closing_no_copy is yes), or at least half the shelf is red (red_half is yes).
  2. steady - nothing is red (red_count is 0).
  3. copy-soon - everything else.

A keepsake's odds come from the Lindy rule of thumb: a place that has lasted A years has about A/(A+5) odds of lasting 5 more (an unknown age is taken as 3). A closing notice drops them to 0%, a lapsed plan halves them, an ageing device caps them at 50%. Risk is (1 - odds) times a copy factor (no copy 1, one 0.4, two or more 0.15), plus 0.1 with no export; red is 0.6 or more, amber 0.25 or more, green below. The action: red - rescue; not red with an app-only format - convert; amber - copy; green - leave. All of it is a rough rule of thumb, an estimate, not a forecast for any real place. The declared input schema carries scalars only, so the array- and object-valued fields (items, figures) travel JSON-encoded as strings (SandKit.toWire does this). Every value is a string.

Input fields

FieldTypeNotes
taskstringrescue. Required.
shelfstringWhat the person called the shelf, up to 80 characters. May be empty.
itemsstring (JSON)[{id, rank, what, where, host_years, age_assumed, copies, copies_assumed, signals, age_odds_5y, odds_5y, tier, action}] in lifeboat order (rank 1 first) - ids K1.. in the order typed, up to 20 keepsakes; host_years is unknown when not given; signals are labels; age_odds_5y is the rule of thumb from the age alone and odds_5y the odds after the signals; tier is red, amber or green. Required.
figuresstring (JSON){items, red_count, amber_count, green_count, closing_no_copy, red_half, unknown_age, horizon_years, first_in_line}, every value a string. Required.
notesstringFree text from the person, up to 400 characters.
clip_note, retry_notestringWhat the page cut, when it cut something; and only on a reformat retry, what was wrong.

The reply

The job's output.output is a string holding one JSON object: task, verdict, title, summary, items (one {ref, action, why, first_step, keep_as} per keepsake, in lifeboat order), weekend (2-5 strings) and watch_outs (0-3 strings). Parse with SandKit.parseReply, then normalize(reply), then check(reply, built) where built = SandKit.buildInput(form); md(reply, built, form) writes the Markdown and csv(built, reply) the rescue list.

Worked examples

Replies produced against the app's prompt for the three sample shelves (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict and every action.

a family folder where almost everything has a copy - steady

Input:

{
 "task": "rescue",
 "shelf": "The family folder",
 "items": "[{\"id\":\"K5\",\"rank\":\"1\",\"what\":\"Voice memos from the kids\",\"where\":\"old phone\",\"host_years\":\"10\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"ageing device\"],\"age_odds_5y\":\"67%\",\"odds_5y\":\"50%\",\"tier\":\"amber\",\"action\":\"copy\"},{\"id\":\"K3\",\"rank\":\"2\",\"what\":\"Wedding video\",\"where\":\"external hard drive\",\"host_years\":\"15\",\"age_assumed\":\"no\",\"copies\":\"1\",\"copies_assumed\":\"no\",\"signals\":[\"ageing device\"] ... (1247 characters)",
 "figures": "{\"items\":\"5\",\"red_count\":\"0\",\"amber_count\":\"1\",\"green_count\":\"4\",\"closing_no_copy\":\"no\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K5\"}",
 "notes": "We already pay for the photo backup. The old phone still charges but the screen is cracked."
}

Reply:

{
 "task": "rescue",
 "verdict": "steady",
 "title": "Steady - one old phone to copy",
 "summary": "The family folder holds 5 keepsakes: 0 red, 1 amber and 4 green. Voice memos from the kids lead the list - an ageing device has cut its odds to 50%.",
 "items": [
  {
   "ref": "K5",
   "action": "copy",
   "why": "The old phone has run 10 years - by the rule of thumb, about 67%. An ageing device brings that down to 50%, with no copies elsewhere.",
   "first_step": "Look for an export or download option for the voice memos first.",
   "keep_as": "audio files on a second drive"
  },
  {
   "ref": "K3",
   "action": "leave",
   "why": "The external hard drive has run 15 years - by the rule of thumb, about 75%, though an ageing device brings that down to 50%, with 1 copy elsewhere.",
   "first_step": "Confirm the copy of the wedding video still opens.",
   "keep_as": "original video files on a second drive"
  },
  {
   "ref": "K2",
   "action": "convert",
   "why": "The notes app has run 16 years - by the rule of thumb, about 76%. The odds don't change, but the app-only format adds risk, and 1 copy exists elsewhere.",
   "first_step": "Copy the recipe notes out of the notes app into a plain text file.",
   "keep_as": "plain text file"
  },
  {
   "ref": "K4",
   "action": "leave",
   "why": "The email inbox has run 22 years - by the rule of thumb, about 81%, and 1 copy already exists elsewhere.",
   "first_step": "Confirm the scanned tax letters still open from the email inbox.",
   "keep_as": "PDF copies on a second drive"
  },
  {
   "ref": "K1",
   "action": "leave",
   "why": "The photo backup service has run 19 years - by the rule of thumb, about 79%, with 2 copies elsewhere and an export tool available.",
   "first_step": "Confirm the copies of the family photos still open.",
   "keep_as": "original image files on a second drive"
  }
 ],
 "weekend": [
  "Copy the voice memos off the old phone onto a second drive.",
  "Confirm the wedding video copy on the external hard drive still opens."
 ],
 "watch_outs": [
  "The old phone still charges, but its screen is cracked.",
  "The old phone holding the voice memos is an ageing device.",
  "The external hard drive holding the wedding video is also an ageing device."
 ]
}

a writer's shelf where one young site holds the only copy - copy-soon

Input:

{
 "task": "rescue",
 "shelf": "Everything I have written",
 "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"},{\"id\":\"K4\",\"rank\":\"2\",\"what\":\"Newsletter archive\",\"where\":\"newsletter platform\",\"host_years\":\"6\",\"age_assumed\":\"no\",\"copies\":\"1\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\" ... (1013 characters)",
 "figures": "{\"items\":\"4\",\"red_count\":\"1\",\"amber_count\":\"2\",\"green_count\":\"1\",\"closing_no_copy\":\"no\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K1\"}",
 "notes": "The blog moved to this site when the old one closed. I mostly write on my phone."
}

Reply:

{
 "task": "rescue",
 "verdict": "copy-soon",
 "title": "One keepsake is red - start with the blog posts",
 "summary": "Of your four keepsakes, one is red, two are amber and one is green. Ten years of blog posts lead the lifeboat order, with about 38% odds over 5 years.",
 "items": [
  {
   "ref": "K1",
   "action": "rescue",
   "why": "The blogging site has run 3 years - by the rule of thumb, about 38% - and you have no copies elsewhere.",
   "first_step": "Use the export tool to save the blog posts to a file you control.",
   "keep_as": "plain text or PDF files on a second drive"
  },
  {
   "ref": "K4",
   "action": "copy",
   "why": "The newsletter platform has run 6 years - by the rule of thumb, about 55% - but a lapsed plan lowers that to about 27%.",
   "first_step": "Use the export tool to save the newsletter archive to a file you control.",
   "keep_as": "plain text or PDF files on a second drive"
  },
  {
   "ref": "K2",
   "action": "copy",
   "why": "The notes app has run 14 years - by the rule of thumb, about 74% - and you have no copies elsewhere.",
   "first_step": "Look for an export or download option first; if none, copy the text out.",
   "keep_as": "plain text files on a second drive"
  },
  {
   "ref": "K3",
   "action": "convert",
   "why": "The USB stick has run 20 years - by the rule of thumb, about 80% - but an ageing device caps that at about 50%, and the app-only format adds risk.",
   "first_step": "Look for an export or download option first, then save the files in a plain format.",
   "keep_as": "plain text or document files on a second drive"
  }
 ],
 "weekend": [
  "Export the blog posts into a file you control.",
  "Use the export tool to save the newsletter archive.",
  "Look for an export option for the poems, then copy the text out."
 ],
 "watch_outs": [
  "The short stories live on an ageing device, which could fail before you copy them out.",
  "Those short stories are also in an app-only format, so convert them into a plain one.",
  "The newsletter archive's plan has lapsed, lowering its odds compared to the platform's age alone."
 ]
}

a fan's shelf on a community that posted a closing notice - rescue-now

Input:

{
 "task": "rescue",
 "shelf": "My fandom years",
 "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Fan art and comments, 2015-2026\",\"where\":\"small art-sharing community\",\"host_years\":\"12\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"closing notice\",\"export tool\"],\"age_odds_5y\":\"71%\",\"odds_5y\":\"0%\",\"tier\":\"red\",\"action\":\"rescue\"},{\"id\":\"K2\",\"rank\":\"2\",\"what\":\"Group roleplay threads\",\"where\":\"the same community's forums\",\"host_years\":\"12\",\"age_assumed\":\"no\",\"copies ... (1256 characters)",
 "figures": "{\"items\":\"5\",\"red_count\":\"2\",\"amber_count\":\"2\",\"green_count\":\"1\",\"closing_no_copy\":\"yes\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K1\"}",
 "notes": "The community posted its closing notice this month. The roleplay threads were written with three friends."
}

Reply:

{
 "task": "rescue",
 "verdict": "rescue-now",
 "title": "A closing community holds the only copies",
 "summary": "Of 5 keepsakes, 2 are red, 2 are amber and 1 is green. Fan art and comments leads the list because of a closing notice, which drops its odds to 0%.",
 "items": [
  {
   "ref": "K1",
   "action": "rescue",
   "why": "The community has run 12 years - by the rule of thumb, about 71%. A closing notice drops it to 0%, and no copies exist elsewhere.",
   "first_step": "Use the export tool to save the art and comments before the community closes.",
   "keep_as": "original image files and plain text, on a second drive."
  },
  {
   "ref": "K2",
   "action": "rescue",
   "why": "The forums have run 12 years - by the rule of thumb, about 71%. A closing notice drops it to 0%, with no export, and no copies exist elsewhere.",
   "first_step": "Save the pages or copy the text out yourself, since no export option exists.",
   "keep_as": "plain text files on a second drive."
  },
  {
   "ref": "K4",
   "action": "copy",
   "why": "The laptop has existed 4 years - by the rule of thumb, about 44%. It is an ageing device, which raises the risk, and no copies exist elsewhere.",
   "first_step": "Look for an export or download option first, then copy the sketchbook scans off the ageing laptop.",
   "keep_as": "original image files on a second drive."
  },
  {
   "ref": "K5",
   "action": "convert",
   "why": "The chat app has run 9 years - by the rule of thumb, about 64%. The app-only format raises the risk, and no copies exist elsewhere.",
   "first_step": "Look for an export or download option first, then save the voice notes as a plain audio file.",
   "keep_as": "audio files on a second drive."
  },
  {
   "ref": "K3",
   "action": "leave",
   "why": "The email has run 20 years - by the rule of thumb, about 80%. No signals apply, and a copy exists elsewhere.",
   "first_step": "Confirm the existing copy still opens and reads correctly.",
   "keep_as": "the existing copy, as plain text or PDF files."
  }
 ],
 "weekend": [
  "Use the export tool to copy the art and comments out of the closing community.",
  "Save the pages or copy the roleplay threads out of the forums by hand.",
  "Look for an export option, then copy the sketchbook scans off the ageing laptop.",
  "Play the voice notes and record them outside the app-only format."
 ],
 "watch_outs": [
  "The forums are closing and have no export option, so save the pages or copy text out directly.",
  "The laptop is an ageing device, which puts the sketchbook scans at risk.",
  "The voice notes are stuck in an app-only format until they are converted to a plain one."
 ]
}

Build the input yourself

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

const K = SandKit;
const form = K.form({ shelf: "Club archive",
  list: "Meeting minutes | shared drive | 9 | 1\nEvent photos | photo site | 2 | 0 | export\nMember chat | chat app | 6 | 0 | app-only" });
const r = K.read(form);                            // r.items: odds, colour, action; r.order: the lifeboat; r.fig.verdict
const built = K.buildInput(form);
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.csv(built, reply)                            // the rescue list, one row per keepsake

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://sand-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":"sand-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": "rescue", "shelf": "Everything I have written", "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"}, ...]", "figures": "{...}", "notes": "The blog moved to this site when the old one closed. I mostly write on my phone."}
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 sand-left:<task>:<inputHash>:a<attempt> (the one task is rescue), 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="sand-left:rescue:$(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: sand-left:rescue:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json