← Dog-Eared
API
Tokens

Drive Dog-Eared from your own code

The one metered lane, menu, is available over HTTP. It takes the book's title, how far everyone has read, how many lines were held back, and the members' lines that are left, and returns a verdict - full-table, light-bite or needs-more - with an easy starter, two or three main courses (each built on one to three members' lines, with a Socratic move, an opening question and two follow-ups), a steel-man side and a closing dessert. A natural use is a reading-group bot that collects lines in a chat during the month and posts the menu the day before the meeting.

The division of labour first: the page owns the spoiler fence, the tally and the minutes; the model writes the questions; the page re-checks the reply. The line reader, the fence and the clipping come from dogkit.js, which runs in node as well as in the browser. Run DogKit.buildInput on anything you send: it drops every line that points past read_to, so the model never sees a spoiler. Minutes are never asked of the model - DogKit.minutes(length, mains) sets them (starter 10%, side 15%, dessert 15%, the rest shared by the mains). DogKit.check holds each reply to the lines: the course count matches the verdict, every course is built on lines that were sent, no line feeds two courses, the moves are distinct, and no capitalised name, number or pronoun turns up that the lines do 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":"dog-eared"}), 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 such as task, book or lines 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 menu lane 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 verdicts

Every run sends task: menu, the only lane; a missing or unknown task is treated as menu. A line is discussable when it gives an opinion, a reading, a reaction with a reason, a question about the book, or quoted words; a bare chapter number, logistics and a one- or two-word reaction are not. The verdict is decided by three ordered, non-overlapping rules - the first that applies wins:

OrderVerdictWhen
1needs-moreFewer than three discussable lines. No courses; asks holds two or three questions to send members.
2light-biteThree, four or five discussable lines: exactly two main courses.
3full-tableSix or more: exactly three main courses.

The declared input schema carries scalars only, so the array-valued lines field travels JSON-encoded as a string (DogKit.toWire does this). Every value is a string.

Input fields

FieldTypeNotes
taskstringmenu. Required.
bookstringThe title as the host typed it, author optional. Up to 120 characters. Required.
read_tostringHow far everyone has read ("chapter 16", "page 210", "the whole book"), or empty.
held_backstringHow many lines the page withheld for pointing past read_to; "0" when none.
linesstring (JSON)[{id, who, kind, ref, text}] - ids L1.., up to 24 lines of up to 360 characters; who from a "Name:" prefix; kind is quote, take or question from punctuation; ref like ch. 5 or p. 88. Required.
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, starter ({question, lines}), mains ([{id, lines, dish, claim, move, question, follow_ups}]; ids M1..; move is one of clarify, assumptions, evidence, implications, perspectives, never twice; follow_ups holds two questions), side ({question, lines} - the steel man, with the line ids it pushes against), dessert ({question, lines}) and asks. Parse with DogKit.parseReply, then normalize(reply), then check(reply, form), which returns the page's notes.

Worked examples

Replies produced against the app's prompt for the three sample nights (made-up clubs reading public-domain classics; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict.

menu - Frankenstein, read to chapter 16, one line held back: full-table

Input:

{
 "task": "menu",
 "book": "Frankenstein by Mary Shelley",
 "read_to": "chapter 16",
 "held_back": "1",
 "lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"},{\"id\":\"L3\",\"who\":\"Ines\",\"kind\":\"quote\",\"ref\":\"ch. 10\",\"text\":\"\\\"I was benevolent and good; misery made me a fien ... (1145 characters)"
}

Reply:

{
 "task": "menu",
 "verdict": "full-table",
 "title": "Frankenstein: who is the real monster",
 "summary": "Tonight's table is full: the group sent in plenty to chew on, from who really deserves blame to what the creature decides about Satan and Adam. That's enough for three full courses, with room for a side question too. One line is waiting past chapter 16, so tonight stays spoiler-free.",
 "starter": {
  "question": "Why do you think Victor never gives him a name?",
  "lines": [
   "L7"
  ]
 },
 "mains": [
  {
   "id": "M1",
   "lines": [
    "L1",
    "L2"
   ],
   "dish": "The real monster",
   "claim": "Priya says Victor is the real monster for running from the creature the moment it opens its eyes; Tom answers that the creature still chose to kill William, and nobody made him do it.",
   "move": "clarify",
   "question": "What does calling Victor 'the real monster' really mean here?",
   "follow_ups": [
    "How does the creature's choice to kill William change who deserves to be called a monster?",
    "What would it mean for both Victor and the creature to share that name?"
   ]
  },
  {
   "id": "M2",
   "lines": [
    "L3",
    "L6"
   ],
   "dish": "Misery, guilt, or excuse",
   "claim": "Ines quotes the creature saying misery made him a fiend and asks whether that is true or an excuse; Tom says Victor knew the truth at Justine's trial and said nothing.",
   "move": "assumptions",
   "question": "What does the idea that misery can turn someone into a fiend take for granted?",
   "follow_ups": [
    "What does Victor's silence at the trial take for granted about what he owed Justine?",
    "How far could the excuse in Ines's quote stretch to cover Victor's silence too?"
   ]
  },
  "... (3 in all)"
 ],
 "side": {
  "question": "What is the strongest case that the creature, not Victor, is the one who chose to kill William?",
  "lines": [
   "L1",
   "L6"
  ]
 },
 "dessert": {
  "question": "After tonight's arguments about monsters, excuses, and Satan or Adam, what would you most want to ask the author?",
  "lines": []
 },
 "asks": []
}

menu - The Great Gatsby, read to chapter 5: light-bite

Input:

{
 "task": "menu",
 "book": "The Great Gatsby",
 "read_to": "chapter 5",
 "held_back": "0",
 "lines": "[{\"id\":\"L1\",\"who\":\"Rosa\",\"kind\":\"question\",\"ref\":\"ch. 1\",\"text\":\"Gatsby reaching out toward the green light at the end of Daisy's dock (ch. 1) - romantic or creepy?\"},{\"id\":\"L2\",\"who\":\"Dev\",\"kind\":\"take\",\"ref\":\"ch. 3\",\"text\":\"Nick calls himself one of the few honest people he has ever known. I don't trust him at all (ch. 3)\"},{\"id\":\"L3\",\"who\":\"Rosa\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"The shirts scene made me cry and ... (684 characters)"
}

Reply:

{
 "task": "menu",
 "verdict": "light-bite",
 "title": "Honesty, trust, and the green light",
 "summary": "Tonight's table turns on two threads: how to feel about reaching for the green light and the shirts scene, and whether Nick or the host can really be trusted. A handful of lines gave enough for two courses, so we're keeping it to a light bite rather than a full spread.",
 "starter": {
  "question": "What's one moment from the book so far that has stuck with you?",
  "lines": []
 },
 "mains": [
  {
   "id": "M1",
   "lines": [
    "L1",
    "L3"
   ],
   "dish": "Reactions to Gatsby",
   "claim": "Rosa wonders whether Gatsby reaching for the green light is romantic or creepy, and says the shirts scene brought tears they can't explain.",
   "move": "clarify",
   "question": "When you call reaching for the green light 'romantic' or 'creepy,' what exactly are you reacting to?",
   "follow_ups": [
    "What one detail would tip that reach for the light from romantic into creepy, or back again?",
    "What might those unexplained tears over the shirts actually be about?"
   ]
  },
  {
   "id": "M2",
   "lines": [
    "L2",
    "L4"
   ],
   "dish": "Knowing the host",
   "claim": "Dev says Nick's claim to be one of the few honest people does not earn trust, and Sam says the parties are fun even though nobody there actually knows the host.",
   "move": "assumptions",
   "question": "What do we expect from someone before we're willing to call their claim of honesty true?",
   "follow_ups": [
    "What would it take to actually know a host whose parties you enjoy?",
    "Why might enjoying a party not require knowing the person who threw it?"
   ]
  }
 ],
 "side": {
  "question": "What's the strongest case that you can really know Nick or the host just by watching what they do?",
  "lines": [
   "L2",
   "L4"
  ]
 },
 "dessert": {
  "question": "Between the gesture toward the green light and the question of who to really trust, what will stick with you after tonight?",
  "lines": []
 },
 "asks": []
}

menu - "loved it", "meh", snacks and a page number: needs-more

Input:

{
 "task": "menu",
 "book": "Little Women",
 "read_to": "page 120",
 "held_back": "0",
 "lines": "[{\"id\":\"L1\",\"who\":\"Ana\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"loved it\"},{\"id\":\"L2\",\"who\":\"Ben\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"meh\"},{\"id\":\"L3\",\"who\":\"Ana\",\"kind\":\"take\",\"ref\":\"\",\"text\":\"I'll bring the tea and scones\"},{\"id\":\"L4\",\"who\":\"Cleo\",\"kind\":\"take\",\"ref\":\"p. 88\",\"text\":\"page 88\"}]"
}

Reply:

{
 "task": "menu",
 "verdict": "needs-more",
 "title": "Little Women discussion night",
 "summary": "The lines sent in for Little Women are quick reactions, a snack offer, and a page number, not enough to build tonight's board. A few more thoughts about what members are reading would give the group something to dig into.",
 "starter": {
  "question": "",
  "lines": []
 },
 "mains": [],
 "side": {
  "question": "",
  "lines": []
 },
 "dessert": {
  "question": "",
  "lines": []
 },
 "asks": [
  "What part of the story has stayed with you so far?",
  "Is there a moment you disagreed with or want to talk through?",
  "What's a line that stuck with you, and why?"
 ]
}

Build the input yourself

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

const K = DogKit;
const form = K.form({ book: "Frankenstein", read_to: "chapter 16", length: "60", members: "Priya, Tom, Jo",
  lines: "Priya: Victor runs the moment it opens its eyes (ch. 5)\nTom: the creature still chose to kill (ch. 16)\nTom: the ending (ch. 20)" });
console.log(K.flags(form), K.lean(form), K.items(form).held);   // the flags, the page's lean, the lines held back
const wire = K.toWire(K.buildInput("menu", form).input);         // POST this to /estimate and /run
// then: const menu = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(menu, form);                          // the page's re-check of the reply
// K.minutes(form.length, menu.mains.length)                      // the minutes for each course

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://dog-eared.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":"dog-eared"}' | 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": "menu", "book": "Frankenstein by Mary Shelley", "read_to": "chapter 16", "held_back": "1", "lines": "[{\"id\":\"L1\",\"who\":\"Priya\",\"kind\":\"take\",\"ref\":\"ch. 5\",\"text\":\"Victor runs from the creature the moment it opens its eyes (ch. 5) - he's the real monster here\"},{\"id\":\"L2\",\"who\":\"Tom\",\"kind\":\"take\",\"ref\":\"ch. 16\",\"text\":\"I don't buy that. The creature still kills William and nobody made him do it (ch. 16)\"}, ...]"}
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 dog-eared:<task>:<inputHash>:a<attempt> (the one lane is menu), 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="dog-eared:menu:$(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: dog-eared:menu:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json