← Bone Dry
API

Drive Bone Dry from your own code

The one metered task, retell-dry, is available over HTTP. It takes one short true story, how it will be told (aloud or written), an optional line to keep word for word, and the page's own giveaway count and list. It returns a plain reading of the count, the turn (an exact quote from the story and why it is the turn), a fix for each giveaway, a deadpan retelling, its last line, two or three delivery notes and a closing line.

The division of labour first: the page finds the giveaways and picks the verdict from their count; the model only writes the words. After a run the page checks that the reply copied the verdict, gave one fix per giveaway in order, quoted the turn from the story, kept your line word for word, ended on its own last line, left no giveaways in the retelling and added no names, numbers or pronouns the story never had. A playful tool, not professional advice.

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": "..." } }

The token is minted for this app (the guest call takes {"slug":"bone-dry"}), 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, story, giveaways, verdict and the rest of the field table) 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 retelling is metered and needs a personal sign-in.
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 input and the verdicts

Every run sends task: retell-dry (the one task is retell-dry); a missing or different one is still answered as retell-dry. The page finds seven kinds of giveaway: exclaim (exclamation marks), laugh (laugh tags), emoji, boost (intensifiers), setup (set-up phrases), explain (joke explainers) and shout (words in capitals). The verdict, by count alone: 0 - bone-dry; 1 to 3 - damp; 4 or more - soaked.

FieldRequiredWhat it holds
taskyesAlways retell-dry.
storyyesThe story, at least 8 words and at most 900 characters.
mediumyesaloud or written.
keep_lineyesWords from the story to keep word for word (90 characters), or (not given).
words, giveaway_countyesThe story's word count and the page's giveaway count, as strings.
giveawaysyesUp to 12 numbered lines such as 1. boost: "honestly" in "which was honestly hilarious.", or (none).
verdictyesThe page's verdict id.
retry_notenoOnly on the page's single reformat retry.

The reply is one JSON object:

{"verdict": "damp", "reading": "...", "turn": {"quote": "...", "why": "..."}, "giveaways": [{"text": "honestly", "fix": "..."}, ...], "retelling": "...", "last_line": "...", "beats": ["...", "..."], "closing": "..."}
FieldWhat it holds
verdictCopied from the input.
readingA plain reading of the story as it stands, with the count in digits (at most 30 words).
turnquote: an exact quote from the story (at most 12 words); why: why it is the turn (at most 20).
giveawaysOne {text, fix} per input line, same order; [] when there are none.
retelling, last_lineThe deadpan retelling and its final sentence, which delivers the turn.
beats, closing2 or 3 delivery notes for the medium (at most 18 words each) and a closing line (16).

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

Worked examples - every verdict

These are the four free samples on the page: the exact input the page sends and the reply that replays for free.

The out-of-service bus - five giveaways, said aloud: soaked

Input:

{
 "task": "retell-dry",
 "story": "OMG you won't believe this!! I literally ran for the bus for two whole streets, waving my arms like a lunatic. The driver waited for me, opened the doors, let me catch my breath and then told me the bus was out of service lol",
 "medium": "aloud",
 "keep_line": "(not given)",
 "words": "45",
 "giveaway_count": "5",
 "giveaways": "1. laugh: \"OMG\" in \"OMG you won't believe\"\n2. setup: \"you won't believe\" in \"OMG you won't believe this!! I literally ran\"\n3. exclaim: \"!!\" in \"OMG you won't believe this!! I literally ran for\"\n4. boost: \"literally\" in \"you won't believe this!! I literally ran for the bus for\"\n5. laugh: \"lol\" in \"the bus was out of service lol\"",
 "verdict": "soaked"
}

Reply:

{
 "verdict": "soaked",
 "reading": "Right now it reads like a hyped-up text: 5 giveaways, including laugh tags, a set-up phrase, exclamation marks and an intensifier.",
 "turn": {
  "quote": "then told me the bus was out of service",
  "why": "You ran, waving your arms, and the driver waited and opened the doors - then the bus was out of service."
 },
 "giveaways": [
  {
   "text": "OMG",
   "fix": "Cut it; no replacement."
  },
  {
   "text": "you won't believe",
   "fix": "Cut it; no replacement."
  },
  {
   "text": "!!",
   "fix": "Cut it; no replacement."
  },
  {
   "text": "literally",
   "fix": "Cut it; no replacement."
  },
  {
   "text": "lol",
   "fix": "Cut it; no replacement."
  }
 ],
 "retelling": "I ran for the bus for two whole streets. I waved my arms like a lunatic. The driver waited for me. The driver opened the doors and let me catch my breath. Then the driver told me the bus was out of service.",
 "last_line": "Then the driver told me the bus was out of service.",
 "beats": [
  "Keep your voice flat and your face still while you describe running and waving your arms.",
  "Say the driver waiting, opening the doors and letting you catch your breath in one even tone.",
  "Pause briefly, then say the bus was out of service as plainly as the rest."
 ],
 "closing": "Say the whole thing evenly and let the last fact speak for itself."
}

The repotted basil - two giveaways, written down: damp

Input:

{
 "task": "retell-dry",
 "story": "I finally repotted the basil I had been keeping alive on the kitchen windowsill since spring. New pot, fresh compost, a proper drink of water. The next morning the cat had eaten every leaf and was asleep in the empty pot, which was honestly hilarious.",
 "medium": "written",
 "keep_line": "(not given)",
 "words": "45",
 "giveaway_count": "2",
 "giveaways": "1. boost: \"honestly\" in \"the empty pot, which was honestly hilarious.\"\n2. laugh: \"hilarious\" in \"pot, which was honestly hilarious.\"",
 "verdict": "damp"
}

Reply:

{
 "verdict": "damp",
 "reading": "As written, 2 cues signal the joke early: an intensifier and a word that calls the ending funny before it lands.",
 "turn": {
  "quote": "cat had eaten every leaf and was asleep in the empty pot",
  "why": "You gave the basil a new pot and fresh compost, but the cat had eaten every leaf and was asleep."
 },
 "giveaways": [
  {
   "text": "honestly",
   "fix": "Cut, with nothing put in its place."
  },
  {
   "text": "hilarious",
   "fix": "Cut, with nothing put in its place."
  }
 ],
 "retelling": "I finally repotted the basil I had been keeping alive on the kitchen windowsill since spring. New pot, fresh compost, a proper drink of water. The next morning the cat had eaten every leaf and was asleep in the empty pot.",
 "last_line": "The next morning the cat had eaten every leaf and was asleep in the empty pot.",
 "beats": [
  "Keep each sentence short and plain, the same even length as the rest.",
  "Let the sentence about the cat and the empty pot be the final line, with nothing after it."
 ],
 "closing": "Post it plainly and let the last line do the work on its own."
}

The office printer - no giveaways and a line to keep, said aloud: bone dry

Input:

{
 "task": "retell-dry",
 "story": "The office printer has jammed every morning this month. Someone from IT finally came to fix it. He spent an hour inside it and left a note on the tray saying it was working perfectly. The note had been printed on that printer. It jammed halfway.",
 "medium": "aloud",
 "keep_line": "It jammed halfway.",
 "words": "46",
 "giveaway_count": "0",
 "giveaways": "(none)",
 "verdict": "bone-dry"
}

Reply:

{
 "verdict": "bone-dry",
 "reading": "This already reads plainly: 0 exclamation marks, intensifiers, emoji, laugh words, set-up or explaining phrases, or capitals for emphasis.",
 "turn": {
  "quote": "It jammed halfway",
  "why": "You expect the note saying it was working perfectly to be true, but it jammed halfway printing that note."
 },
 "giveaways": [],
 "retelling": "The office printer has jammed every morning this month. Someone from IT finally came to fix it. He spent an hour inside it and left a note on the tray saying it was working perfectly. The note had been printed on that printer. It jammed halfway.",
 "last_line": "It jammed halfway.",
 "beats": [
  "Keep your voice flat and steady, no rise in pitch, through the whole telling.",
  "Pause briefly right before the final line, then say it flat, no smile.",
  "Keep a straight face the whole time, especially when you mention the note."
 ],
 "closing": "Say it just as it stands here, flat, and let the last line close it quietly."
}

The loyal gym member - four giveaways, written down: soaked

Input:

{
 "task": "retell-dry",
 "story": "Signed up for the gym in January. Went ONCE. Seriously. Today they emailed to thank me for being one of their most loyal members, because I never cancelled!!! 💀",
 "medium": "written",
 "keep_line": "(not given)",
 "words": "28",
 "giveaway_count": "4",
 "giveaways": "1. shout: \"ONCE\" in \"the gym in January. Went ONCE. Seriously. Today they\"\n2. boost: \"Seriously\" in \"gym in January. Went ONCE. Seriously. Today they emailed to\"\n3. exclaim: \"!!!\" in \"because I never cancelled!!! 💀\"\n4. emoji: \"💀\" in \"I never cancelled!!! 💀\"",
 "verdict": "soaked"
}

Reply:

{
 "verdict": "soaked",
 "reading": "Right now you signal the joke early: 4 giveaways — capital letters, an intensifier, exclamation marks and an emoji — give it away before the ending lands.",
 "turn": {
  "quote": "one of their most loyal members",
  "why": "You went once, yet the gym calls that being one of their most loyal members, because you never cancelled."
 },
 "giveaways": [
  {
   "text": "ONCE",
   "fix": "Written as a plain lowercase word, not capitalized for emphasis."
  },
  {
   "text": "Seriously",
   "fix": "Cut; adds no information once the emphasis is removed."
  },
  {
   "text": "!!!",
   "fix": "Replaced with a plain period to end the sentence."
  },
  {
   "text": "💀",
   "fix": "Cut, with nothing put in its place."
  }
 ],
 "retelling": "I signed up for the gym in January. I went once. Today they emailed because I never cancelled, and called me one of their most loyal members.",
 "last_line": "Today they emailed because I never cancelled, and called me one of their most loyal members.",
 "beats": [
  "Keep each sentence short and matter-of-fact, the way you'd type a quick update.",
  "Let the final line stand alone, with nothing added after it.",
  "Leave the ending plain — no extra line, no comment, no punctuation to flag it."
 ],
 "closing": "Send this version exactly as it reads, and let the ending do the work."
}

Build the input yourself

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

const K = DryKit;
const built = K.buildInput(K.form({ story: "I waited an hour for the plumber, who rang to say he was outside the wrong house. It was mine. He had the wrong door.", medium: "aloud", keep: "" }));
built.facts.count, built.facts.verdict  // the page's giveaway count and verdict
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 plumber")    // the retelling 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://bone-dry.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":"bone-dry"}' | 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": "retell-dry", "story": "I finally repotted the basil ...", "medium": "written", "keep_line": "(not given)", "words": "45", "giveaway_count": "2", "giveaways": "1. boost: \"honestly\" in ...\n2. laugh: \"hilarious\" in ...", "verdict": "damp"}
# - 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 bone-dry:<task>:<inputHash>:a<attempt> (the one task is retell-dry), 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="bone-dry:retell-dry:$(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: bone-dry:retell-dry:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json