← Pucker Check
API

Drive Pucker Check from your own code

The one metered task, check, is available over HTTP. It takes fruit_count and fruits - one numbered line per persimmon with its label, shape, feel, colour, skin, days on the counter and note, exactly as the page writes it. It returns one entry per fruit (verdict, claim, evidence for and against, a test and a next step), the one to eat first and a ripening tip, or a polite decline for health, baby and pet questions.

The division of labour first: the page works out its own verdict per fruit by a fixed rule; the model checks the claim and writes the words; the page then re-checks every verdict, the count, the one to eat first (softest ready fruit, then a split skin, then more days, then the lower number) and the word limits. Build fruits the same way (see "Build the input yourself") so the lines match.

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":"pucker-check"}), 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, fruit_count, fruits) 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: checking a tray 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, the status and the verdicts

Every run sends task: check (the one task is check); a missing or different one is still answered as check. status is declined when any note asks a health or medical question or about feeding the fruit to a baby, a child or a pet. Otherwise each fruit gets a verdict, checked in this order and stopping at the first match: past_it (mould, or a note about mildew, fuzz, sliminess, rot, fizz or a sour, boozy, alcohol, vinegar, bad or off smell); ready (pointed astringent kind that is jelly-soft and deep orange or dark red-orange; squat non-astringent kind that is not hard and not green; kind not known but jelly-soft and deep in colour); unsure (kind not known, firm to soft, not green); not_yet (everything else).

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways check.
fruit_countyesHow many persimmons, 1 to 6, as text.
fruitsyesOne numbered line per fruit, fields separated by | : the label (the person's own words, up to 40 characters, or Fruit N), shape:, feel:, colour:, skin:, days on the counter: (a number or not given) and note: (up to 100 characters, or none), worded exactly as the page writes them.
retry_notenoOnly on the page's one reformat retry, after an unusable reply.

Reply (one JSON object)

{"task": "check", "status": "ok", "reason": "",
 "fruits": [{"n": 1, "verdict": "ready", "claim": "...", "for": "...", "against": "", "test": "...", "next": "..."}, ...],
 "eat_first": 1, "eat_first_why": "...", "tip": "", "closing": "..."}
FieldWhat it holds
status, reasonok or declined; for declined, one sentence of at most 25 words, fruits empty, eat_first 0 and every other field empty.
fruitsExactly fruit_count entries in input order. Each: n, verdict (ready, not_yet, unsure or past_it), claim (the claim checked, at most 16 words), for and against (facts from that fruit's own line, at most 20 words each, empty when none), test (one hands-on check before biting, at most 18 words) and next (what to do now, at most 18 words).
eat_first, eat_first_whyThe n of the softest ready fruit (ties: a split skin, then more days on the counter, then the lower number), or 0 when none is ready; and the deciding facts in at most 20 words.
tipA ripening tip of at most 24 words, only when some fruit is not_yet; otherwise empty.
closingOne line of at most 18 words that agrees with the verdicts.

The page then checks the reply (PuckerKit.check) against its own verdict for each fruit, the count, the one to eat first and the word limits, and lists any disagreement under "The page's check".

Worked examples

These are the three free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every outcome - a counter bowl of four (ready, not yet, ready, unsure), a gift box of three (not yet, ready, past it) and a note with a health question (declined).

A counter bowl of four - ready, not yet, unsure

Input:

{
 "task": "check",
 "fruit_count": "4",
 "fruits": "1. big one by the window | shape: pointed, acorn-shaped - the astringent kind | feel: jelly-soft, like a water balloon | colour: dark red-orange and glossy | skin: wrinkled or bruised | days on the counter: 9 | note: skin looks almost see-through\n2. small pointed one | shape: pointed, acorn-shaped - the astringent kind | feel: firm, like an apple | colour: yellow-orange | skin: smooth | days on the counter: 2 | note: none\n3. flat one from the market | shape: squat, tomato-shaped - the non-astringent kind | feel: firm, like an apple | colour: deep orange | skin: a few dark specks | days on the counter: not given | note: none\n4. the one my neighbour gave me | shape: kind not known | feel: gives a little when pressed | colour: yellow-orange | skin: smooth | days on the counter: 4 | note: none"
}

Reply:

{
 "task": "check",
 "status": "ok",
 "reason": "",
 "fruits": [
  {
   "n": 1,
   "verdict": "ready",
   "claim": "The big one by the window is ready to eat now.",
   "for": "Jelly-soft flesh, dark red-orange colour, wrinkled or bruised and see-through skin, with 9 days on the counter show ripeness.",
   "against": "",
   "test": "Press the flesh gently to confirm it still feels jelly-soft before eating.",
   "next": "Eat it with a spoon since the flesh is jelly-soft."
  },
  {
   "n": 2,
   "verdict": "not_yet",
   "claim": "The small pointed one is ready to eat now.",
   "for": "",
   "against": "Its flesh is firm, like an apple, and its colour is yellow-orange.",
   "test": "Press the flesh gently to check if it still feels firm.",
   "next": "Leave it at room temperature and check again later."
  },
  {
   "n": 3,
   "verdict": "ready",
   "claim": "The flat one from the market is ready to eat now.",
   "for": "It is a non-astringent, tomato-shaped persimmon that feels firm, like an apple, and is deep orange.",
   "against": "",
   "test": "Press the flesh gently to confirm it still feels firm, like an apple.",
   "next": "Slice it and eat since the flesh is firm."
  },
  {
   "n": 4,
   "verdict": "unsure",
   "claim": "The one labelled \"the one my neighbour gave me\" is ready to eat now.",
   "for": "It gives a little when pressed, with 4 days on the counter.",
   "against": "Its colour is yellow-orange.",
   "test": "Touch a slice to your tongue; if it feels dry and furry, it still needs to wait.",
   "next": "Do the tongue-tip test, then eat it or wait based on the result."
  }
 ],
 "eat_first": 1,
 "eat_first_why": "The big one by the window is jelly-soft, softer than the firm flat one from the market.",
 "tip": "Place the small pointed one in a paper bag alongside a ripe banana or apple at room temperature.",
 "closing": "Overall your tray has 2 ready, 1 not yet, and 1 unsure persimmon."
}

A gift box of three - not yet, ready, past it

Input:

{
 "task": "check",
 "fruit_count": "3",
 "fruits": "1. Fruit 1 | shape: squat, tomato-shaped - the non-astringent kind | feel: hard as a stone | colour: yellow with green patches | skin: smooth | days on the counter: 1 | note: none\n2. the softest one | shape: kind not known | feel: jelly-soft, like a water balloon | colour: deep orange | skin: split or leaking juice | days on the counter: 6 | note: none\n3. bottom of the box | shape: pointed, acorn-shaped - the astringent kind | feel: jelly-soft, like a water balloon | colour: dark red-orange and glossy | skin: mould, or a sour or boozy smell | days on the counter: not given | note: white fuzz near the stem"
}

Reply:

{
 "task": "check",
 "status": "ok",
 "reason": "",
 "fruits": [
  {
   "n": 1,
   "verdict": "not_yet",
   "claim": "Fruit 1 is ready to eat now.",
   "for": "",
   "against": "It is hard as a stone and yellow with green patches.",
   "test": "Press it gently to feel if it gives at all.",
   "next": "Leave it at room temperature and check again later."
  },
  {
   "n": 2,
   "verdict": "ready",
   "claim": "The softest one is ready to eat now.",
   "for": "It is jelly-soft, deep orange, has split or leaking juice, and 6 days on the counter.",
   "against": "",
   "test": "Press it once more to confirm the flesh feels fully jelly-soft.",
   "next": "Eat it with a spoon since the flesh is jelly-soft."
  },
  {
   "n": 3,
   "verdict": "past_it",
   "claim": "Bottom of the box is ready to eat now.",
   "for": "It is jelly-soft and dark red-orange and glossy.",
   "against": "Its skin is mould, or a sour or boozy smell, and there is white fuzz near the stem.",
   "test": "Check for a sour or boozy smell and visible mould before discarding.",
   "next": "Put it in the compost or bin and never eat it."
  }
 ],
 "eat_first": 2,
 "eat_first_why": "It is the only fruit that is ready to eat now.",
 "tip": "Store Fruit 1 in a paper bag alongside a ripe banana to speed ripening.",
 "closing": "One fruit is ready now, one needs more time, and one should be discarded."
}

A health question - declined

Input:

{
 "task": "check",
 "fruit_count": "2",
 "fruits": "1. Fruit 1 | shape: pointed, acorn-shaped - the astringent kind | feel: firm, like an apple | colour: yellow-orange | skin: smooth | days on the counter: not given | note: none\n2. Fruit 2 | shape: squat, tomato-shaped - the non-astringent kind | feel: gives a little when pressed | colour: deep orange | skin: smooth | days on the counter: 3 | note: is this okay for my diabetes?"
}

Reply:

{
 "task": "check",
 "status": "declined",
 "reason": "This page only checks ripeness for you - questions about health conditions belong with a doctor or vet.",
 "fruits": [],
 "eat_first": 0,
 "eat_first_why": "",
 "tip": "",
 "closing": ""
}

Build the input yourself

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

const K = PuckerKit, T = PuckerTray;
const built = K.buildInput({ fruits: [
  { label: "big one", kind: "pointed", feel: "jelly", colour: "red", skin: "wrinkled", days: "9", note: "" },
  { label: "", kind: "squat", feel: "hard", colour: "green", skin: "smooth", days: "", note: "" } ] });
built.parsed.rows.map(r => r.verdict);   // the page's own verdicts: ["ready", "not_yet"]
built.prescan.eat_first;                 // 1 - the softest ready fruit
const wire = K.toWire(built.input);      // POST this to /estimate and /run
// kind: pointed | squat | unknown   feel: hard | firm | gives | soft | jelly
// colour: green | yellow | orange | red   skin: smooth | specks | wrinkled | split | mould
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes           // the page's re-check of the reply
// T.svg({ rows: built.parsed.rows, eatFirst: reply.eat_first })
// K.md(built, reply)                    // the checked tray 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://pucker-check.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":"pucker-check"}' | 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": "check", "fruit_count": "2", "fruits": "1. ...
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 pucker-check:<task>:<inputHash>:a<attempt> (the one task is check), 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="pucker-check:check:$(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: pucker-check:check:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json