← Bent Feather
API

Drive Bent Feather from your own code

The one metered task, rally, is available over HTTP. It takes 4 to 16 badminton points a recreational player lost in one session - what happened, and whether each ended with their own mistake or the other side's winner - plus the format and the page's own keyword read. It returns a rally stage and a short note for each point, the one binding stage (the stage leaking the most points), why it binds, one thing to do next game with the shots the player already has, what to leave alone, one drill, what to tally next session, and a verdict (own-goal, under-pressure or spread-thin) - with a safety note when a point mentions pain.

The division of labour first: the page reads keywords for stages and pain words and applies a fixed rule; the model reads every point properly and does the coaching; the page then re-checks the verdict and the binding stage against the model's own stage labels and the player's own "ended by" choices, the point count and order, its own pain words, the closing count and the word limits. Build points and page_read 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":"bent-feather"}), 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, count, format, points, page_read) 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 session 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 stages and the verdicts

Every run sends task: rally (the one task is rally); a missing or different one is still answered as rally. Each point gets one stage - the stage of the first shot of the player's that went wrong: serve, return, net, lift, attack, defence, move or other. The binding stage is the stage (never other) with the most points; a tie goes to the stage listed earlier. The verdict is checked in this order, stopping at the first match: spread-thin (no stage holds two points), own-goal (at least half of the binding stage's points ended with the player's own mistake), under-pressure (every other case).

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways rally.
countyesHow many lost points, 4 to 16, as digits.
formatyessingles, doubles or not sure.
pointsyesNumbered lines, one per point: 1. what happened: my clear landed mid-court and they smashed it | ended by: their winner. "ended by" is my mistake, their winner or not sure.
page_readyesThe page's keyword read, one numbered line per point: 1. stage guess: lift; pain word: none. A hint only - the model reads each point itself.
retry_notenoOnly on the page's one reformat retry, after an unusable reply.

Reply (one JSON object)

{"task": "rally", "verdict": "own-goal", "binding": "net",
 "points": [{"n": 1, "stage": "net", "note": "..."}, ...],
 "identify": "...", "exploit": "...", "subordinate": "...", "elevate": "...", "recheck": "...", "safety": "", "closing": "..."}
FieldWhat it holds
verdictown-goal, under-pressure or spread-thin, by the rule above.
bindingThe binding stage id; empty for spread-thin.
pointsExactly count entries in input order. Each: n, stage (one id from the list) and note (what went wrong, at most 18 words).
identifyWhy the stage binds, with "N of M" lost points and "K of N" own mistakes, at most 30 words.
exploit, subordinate, elevateOne thing to do next game with the shots the player has (at most 30 words), what to leave alone (at most 30), one drill with a count or target (at most 35); all empty for spread-thin.
recheckWhat to tally next session, at most 25 words.
safetyOnly when a point mentions pain: stop playing on it and have a doctor or physiotherapist check it, at most 30 words.
closingOne sentence of at most 20 words restating the verdict; digits only as "N of M".

The page then checks the reply (FeatherKit.check) and lists any disagreement under "The page's check".

Worked examples

These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every verdict - net-tape night (own goal at the net), short clears (under pressure on clears and lifts), all over the place (spread thin) and serve and a twinge (own goal on the serve, with a safety note).

Net-tape night - own goal

Input:

{
 "task": "rally",
 "count": "7",
 "format": "doubles",
 "points": "1. what happened: my net shot clipped the tape and dropped on my side | ended by: my mistake\n2. what happened: tried a tight net shot, it went into the net | ended by: my mistake\n3. what happened: net kill went long past the back line | ended by: my mistake\n4. what happened: their smash was too fast to get back | ended by: their winner\n5. what happened: my serve went too high and they smashed it | ended by: their winner\n6. what happened: tumbled the net shot but it floated up and they killed it | ended by: their winner\n7. what happened: smashed straight into the net | ended by: my mistake",
 "page_read": "1. stage guess: net; pain word: none\n2. stage guess: net; pain word: none\n3. stage guess: net; pain word: none\n4. stage guess: defence; pain word: none\n5. stage guess: serve; pain word: none\n6. stage guess: net; pain word: none\n7. stage guess: attack; pain word: none"
}

Reply:

{
 "task": "rally",
 "verdict": "own-goal",
 "binding": "net",
 "points": [
  {
   "n": 1,
   "stage": "net",
   "note": "Net shot clipped the tape and dropped on your own side."
  },
  {
   "n": 2,
   "stage": "net",
   "note": "Tight net shot went into the net."
  },
  {
   "n": 3,
   "stage": "net",
   "note": "Net kill went long, past the back line."
  },
  {
   "n": 4,
   "stage": "defence",
   "note": "Your opponents' smash was too fast to get back."
  },
  {
   "n": 5,
   "stage": "serve",
   "note": "Serve went too high and your opponents smashed it."
  },
  {
   "n": 6,
   "stage": "net",
   "note": "Tumbled net shot floated up and your opponents killed it."
  },
  {
   "n": 7,
   "stage": "attack",
   "note": "Smash went straight into the net."
  }
 ],
 "identify": "4 of 7 lost points were at the net, 3 of 4 ended by your own mistake, making it the stage to fix first.",
 "exploit": "From the next game, add more height and clearance over the tape on your net shots to give yourself safer margin and cut mistakes at the net.",
 "subordinate": "Leave your serve, attack and defence alone for now until your net play improves, or use them to set up safer net chances.",
 "elevate": "With a partner, hit 20 net shots in a row, each clearing the tape with a little room before it drops, keeping that margin each time.",
 "recheck": "Next session, tally how many lost points are net shots again, and how many of those end in your own mistake.",
 "safety": "",
 "closing": "Your net play is leaking the most points, 4 of 7, mostly from your own mistakes."
}

Short clears - under pressure

Input:

{
 "task": "rally",
 "count": "8",
 "format": "singles",
 "points": "1. what happened: my clear landed mid-court and they smashed it down the line | ended by: their winner\n2. what happened: lifted from the net but it was too short, they killed it | ended by: their winner\n3. what happened: clear from the back fell short, got smashed at my body | ended by: their winner\n4. what happened: cleared out over the back line | ended by: my mistake\n5. what happened: returning their low serve, I hit it into the net | ended by: my mistake\n6. what happened: net shot went wide | ended by: my mistake\n7. what happened: wrong-footed by their drop to the front | ended by: their winner\n8. what happened: they smashed and I couldn't get a racket on it | ended by: their winner",
 "page_read": "1. stage guess: lift; pain word: none\n2. stage guess: lift; pain word: none\n3. stage guess: lift; pain word: none\n4. stage guess: lift; pain word: none\n5. stage guess: return; pain word: none\n6. stage guess: net; pain word: none\n7. stage guess: move; pain word: none\n8. stage guess: defence; pain word: none"
}

Reply:

{
 "task": "rally",
 "verdict": "under-pressure",
 "binding": "lift",
 "points": [
  {
   "n": 1,
   "stage": "lift",
   "note": "Clear landed mid-court and was smashed down the line."
  },
  {
   "n": 2,
   "stage": "lift",
   "note": "Lift from the net was too short and got killed."
  },
  {
   "n": 3,
   "stage": "lift",
   "note": "Clear from the back fell short and was smashed at your body."
  },
  {
   "n": 4,
   "stage": "lift",
   "note": "Clear went out over the back line."
  },
  {
   "n": 5,
   "stage": "return",
   "note": "Return of their low serve went into the net."
  },
  {
   "n": 6,
   "stage": "net",
   "note": "Net shot went wide."
  },
  {
   "n": 7,
   "stage": "move",
   "note": "Wrong-footed by their drop to the front."
  },
  {
   "n": 8,
   "stage": "defence",
   "note": "Smash came through and you couldn't get a racket on it."
  }
 ],
 "identify": "Lift and clear cost 4 of 8 lost points, and only 1 of 4 ended on your own mistake - the rest were smashed after a short lift or clear.",
 "exploit": "Add more height and depth to every clear and lift so it lands deeper in the court, taking away the time your opponent needs to smash it.",
 "subordinate": "Leave your serve return, net play, footwork and defence alone for now - better lifts will buy your footwork more time to recover forward.",
 "elevate": "With a partner, hit 20 clears in a row from the back line aiming for height over them and landing past the back service line each time.",
 "recheck": "Next session, tally how many lost points start with a short clear or lift that let your opponent attack early.",
 "safety": "",
 "closing": "Short clears and lifts under pressure are costing you the most points, 4 of 8 this session."
}

All over the place - spread thin

Input:

{
 "task": "rally",
 "count": "6",
 "format": "not sure",
 "points": "1. what happened: served long and out | ended by: my mistake\n2. what happened: net shot went into the tape | ended by: my mistake\n3. what happened: my clear was short and they smashed it | ended by: their winner\n4. what happened: smash went wide | ended by: my mistake\n5. what happened: couldn't block their drive | ended by: their winner\n6. what happened: rushed my return of serve into the net | ended by: my mistake",
 "page_read": "1. stage guess: serve; pain word: none\n2. stage guess: net; pain word: none\n3. stage guess: lift; pain word: none\n4. stage guess: attack; pain word: none\n5. stage guess: defence; pain word: none\n6. stage guess: return; pain word: none"
}

Reply:

{
 "task": "rally",
 "verdict": "spread-thin",
 "binding": "",
 "points": [
  {
   "n": 1,
   "stage": "serve",
   "note": "Served long and out."
  },
  {
   "n": 2,
   "stage": "net",
   "note": "Net shot went into the tape."
  },
  {
   "n": 3,
   "stage": "lift",
   "note": "Clear was short and the other side smashed it."
  },
  {
   "n": 4,
   "stage": "attack",
   "note": "Smash went wide."
  },
  {
   "n": 5,
   "stage": "defence",
   "note": "Could not block the other side's drive."
  },
  {
   "n": 6,
   "stage": "return",
   "note": "Rushed return of serve into the net."
  }
 ],
 "identify": "No stage holds multiple points yet; your lost points are spread across serve, return, net, lift, attack and defence this session.",
 "exploit": "",
 "subordinate": "",
 "elevate": "",
 "recheck": "Log more lost points next session, noting the shot that went wrong in every point.",
 "safety": "",
 "closing": "No stage cost you more than 1 of 6 lost points, so your points were spread across the whole rally."
}

Serve and a twinge - own goal with a safety note

Input:

{
 "task": "rally",
 "count": "5",
 "format": "doubles",
 "points": "1. what happened: my low serve clipped the tape and fell short | ended by: my mistake\n2. what happened: my serve floated high and they pushed it at me | ended by: their winner\n3. what happened: flick serve went out at the back | ended by: my mistake\n4. what happened: smash went into the net and my shoulder felt a twinge | ended by: my mistake\n5. what happened: their drive to my body, I couldn't defend it | ended by: their winner",
 "page_read": "1. stage guess: serve; pain word: none\n2. stage guess: serve; pain word: none\n3. stage guess: serve; pain word: none\n4. stage guess: attack; pain word: twinge\n5. stage guess: defence; pain word: none"
}

Reply:

{
 "task": "rally",
 "verdict": "own-goal",
 "binding": "serve",
 "points": [
  {
   "n": 1,
   "stage": "serve",
   "note": "Low serve clipped the tape and fell short."
  },
  {
   "n": 2,
   "stage": "serve",
   "note": "Serve floated high and your opponents pushed it at you."
  },
  {
   "n": 3,
   "stage": "serve",
   "note": "Flick serve went out at the back."
  },
  {
   "n": 4,
   "stage": "attack",
   "note": "Smash went into the net and your shoulder felt a twinge."
  },
  {
   "n": 5,
   "stage": "defence",
   "note": "Your opponents' drive to your body, and you could not defend it."
  }
 ],
 "identify": "Serve cost the most points, 3 of 5, and 2 of 3 of those ended by your own mistake.",
 "exploit": "Give your low serve slightly more height over the tape, and aim flick serves a racket's length inside the back line.",
 "subordinate": "Leave your attack and defence as they are for now until your serve stops costing points.",
 "elevate": "Serve 20 low serves aiming to clear the tape by a hand's width, then 10 flick serves landing a racket's length inside the back line.",
 "recheck": "Next session, tally how many lost points start with your serve, and how many of those are your own mistake.",
 "safety": "Your shoulder twinge on the smash means you should stop playing on it and have a doctor or physiotherapist check it.",
 "closing": "Your serve is costing you the most, 3 of 5 lost points this session."
}

Build the input yourself

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

const K = FeatherKit, D = FeatherDraw;
const built = K.buildInput({ format: "singles", pts: [
  { what: "my clear landed short and they smashed it", end: "theirs" },
  { what: "clear from the back fell short", end: "theirs" },
  { what: "served into the net", end: "mine" },
  { what: "net shot went wide", end: "mine" }] });
built.prescan.stages;                    // ["lift", "lift", "serve", "net"] - the page's keyword read
built.prescan.verdict;                   // "under-pressure" - the page's own rule
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.md(built, reply)                    // the read 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://bent-feather.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":"bent-feather"}' | 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": "rally", "count": "5", "format": "doubles", "points": "1. what happened: my low serve clipped the tape | ended by: my mistake ...
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 bent-feather:<task>:<inputHash>:a<attempt> (the one task is rally), 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="bent-feather:rally:$(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: bent-feather:rally:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json