← Same Table
Same Table API
Tokens

Drive Same Table from your own code

Both metered runs are available over HTTP. unstick takes one task someone keeps avoiding, what makes it awful, their energy and the minutes they have, and returns the bricks in their wall of awful (each with a small counter-move), a first move of two to five minutes, a short ladder of steps and - when the task is waiting on someone - a draft message, with a start_now / break_down / ask_first verdict. double takes a task list, a session length and a sprint rhythm, and returns a scripted body-doubling session: what the double says as you sit down, every sprint and break with the double's line and the check-in question, what to park, and the closing - with a fits / trim verdict. A natural use is a to-do or focus app: your code sends the task that is stuck, then the steps, and gets a ready-to-run session back.

The division of labour first: the page reads and packs; the model writes; the page re-checks the reply. The vague and big wording, what the task is waiting on, the bricks found in the person's words, the page's own lean, the parsed task list (minutes typed or guessed at 15), the sprints and breaks for the rhythm and the page's own packing come from tablekit.js; the reply checks come from tablereply.js. Both run in node as well as in the browser. checkUnstick holds an unstick reply to the task (a first move of five minutes or less, a ladder sized to the verdict, a message exactly when the verdict is ask_first, no internal names or diagnostic labels in prose); checkDouble holds a double reply to the list (every task in a sprint or parked - never both, never neither - no unknown ids, frames that follow the rhythm and fit the work budget, a verdict that matches what was parked). Replies are built only from what was sent - the prompt forbids inventing people, dates or details.

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":"same-table"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or task 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: both runs are metered and need 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

Every run sends task first: unstick or double. The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (TableKit.toWire does this). The schema requires task.

taskSendsReturnsVerdicts
unstickone avoided task, what makes it awful, energy, minutes free, and the page's readfirst move, steps, bricks, a message when waiting, summarystart_now (one concrete action that fits the time), break_down (vague, big or unclear where to begin), ask_first (the next real step waits on someone else)
doublea task list, the session length, the rhythm, the double's style, derailers, and the page's packingopening, frames, parked, derail plan, closing, summaryfits (nothing parked), trim (some tasks left for another session)

Input fields

FieldTaskTypeNotes
task, todaybothstringunstick or double; YYYY-MM-DD.
avoided_taskunstickstringThe task in the person's words, up to 300 characters.
avoided_for, energy, minutes_freeunstickstringa few days / a few weeks / months / longer than I can remember or not given; low / medium / high; whole minutes or not given.
awfulunstickstringWhat makes it awful, up to 1,500 characters (cut on a sentence with a marker), or not given.
prescanunstickstring (JSON){page_lean, vague_words, size_words, waiting_on, bricks:[{kind, words}], word_count}.
tasksdoublestring (JSON)[{id, text, minutes, minutes_source}] with ids t1, t2 … - one per line, at most 12; minutes_source is you or page guess (15).
session_minutes, double_style, derailersdoublestring15-240; gentle / brisk / quiet; free text or not given.
rhythmdoublestring (JSON){label, sprint, break} in minutes: 25/5, 15/5, 45/10 or 50/10.
page_plandoublestring (JSON){work_budget, sprints:[{minutes, task_ids}], parked_ids, page_lean} - the work budget is the session minus 5 (2 to open, 3 to close).
retry_notebothstringOnly on a reformat retry.

The replies

The job's output.output is a string holding one JSON object.

Worked examples

Replies produced against the app's prompt for the sample tables (made up; lists shortened; the full replies ship in example-result.js). Together they cover every verdict of both tasks. The page replays each of them free from its sample buttons.

unstick - the tax shoebox - break_down

Input:

{
 "task": "unstick",
 "today": "2026-10-20",
 "avoided_task": "Deal with my taxes",
 "avoided_for": "months",
 "awful": "I don't even know which forms I need, the papers are in a shoebox somewhere, and I feel stupid for leaving it this long.",
 "energy": "low",
 "minutes_free": "30",
 "prescan": "{\"page_lean\":\"break_down\",\"vague_words\":[\"deal with\"],\"size_words\":[],\"waiting_on\":[],\"bricks\":[{\"kind\":\"unclear\",\"words\":\"which forms\"},{\"kind\":\"dread\",\"words\":\"stupid\"}],\"word_count\":4}"
}

Reply:

{
 "task": "unstick",
 "verdict": "break_down",
 "verdict_reason": "You said you don't even know which forms you need and the papers are sitting in a shoebox somewhere, so this is a handful of unclear pieces to sort through rather than one action you can finish right now.",
 "first_move": {
  "text": "Find the shoebox of papers and put it on the table in front of you.",
  "minutes": 3
 },
 "steps": [
  {
   "text": "Open the shoebox and sort the papers into two piles: ones that look official (letters, statements, forms) and everything else.",
   "minutes": 15,
   "done_when": "every paper from the shoebox is in one of the two piles"
  },
  {
   "text": "Write down the name or title at the top of each paper in the official pile on a single sheet.",
   "minutes": 10,
   "done_when": "you have one list naming everything in the official pile"
  },
  "... 3 more"
 ],
 "bricks": [
  {
   "label": "which forms I need",
   "kind": "unclear",
   "counter": "Skip the form name for now - just sort the papers into two piles first."
  },
  {
   "label": "papers are in a shoebox somewhere",
   "kind": "too_big",
   "counter": "Bring just the shoebox to the table - you don't need to know what's inside yet, only where it is."
  },
  "... 1 more"
 ],
 "message": null,
 "summary": "What's in the way is not knowing which forms you need while the papers themselves are still unsorted in a shoebox. The first move is just getting that shoebox onto the table so you can see what you're working with."
}

unstick - the moving-out deposit - ask_first

Input:

{
 "task": "unstick",
 "today": "2026-10-20",
 "avoided_task": "Get my security deposit back from my old landlord",
 "avoided_for": "a few weeks",
 "awful": "I need the move-out inspection report from the letting agency first and I hate chasing people.",
 "energy": "medium",
 "minutes_free": "20",
 "prescan": "{\"page_lean\":\"ask_first\",\"vague_words\":[],\"size_words\":[],\"waiting_on\":[\"need the move-out inspection report from the letting agency first\"],\"bricks\":[{\"kind\":\"waiting\",\"words\":\"need the move-out inspection report from\"},{\"kind\":\"dread\",\"words\":\"hate\"}],\"word_count\":9}"
}

Reply:

{
 "task": "unstick",
 "verdict": "ask_first",
 "verdict_reason": "You said you need the move-out inspection report from the letting agency before you can go any further, so the first real step is asking them for it.",
 "first_move": {
  "text": "Open a new email to the letting agency and write a subject line about the move-out inspection report.",
  "minutes": 3
 },
 "steps": [
  {
   "text": "Finish the email asking for the move-out inspection report and send it.",
   "minutes": 5,
   "done_when": "the email has been sent"
  },
  {
   "text": "once they reply, read through the move-out inspection report they send you",
   "minutes": 10,
   "done_when": "you know what it says about the deposit"
  },
  "... 1 more"
 ],
 "bricks": [
  {
   "label": "need the move-out inspection report first",
   "kind": "waiting",
   "counter": "send the email asking for it today, then wait instead of chasing"
  },
  {
   "label": "hate chasing people",
   "kind": "dread",
   "counter": "write the email once and let the reply come to you"
  }
 ],
 "message": {
  "to": "the letting agency",
  "text": "Hi, I'm following up to ask for a copy of the move-out inspection report from my old tenancy. I need it to move forward with getting my security deposit back from my landlord. Could you send it over when you have a chance? Let me know if you need anything from me, such as [your forwarding address], to send it through."
 },
 "summary": "The real hold-up is that you can't move forward on the deposit until you have the move-out inspection report from the letting agency, and chasing people for it feels draining. The first move is opening an email to ask them for it, which takes about three minutes."
}

unstick - the dentist call - start_now

Input:

{
 "task": "unstick",
 "today": "2026-10-20",
 "avoided_task": "Call the dentist to book a cleaning",
 "avoided_for": "a few weeks",
 "awful": "Phone calls make me anxious and I always forget what to say.",
 "energy": "medium",
 "minutes_free": "15",
 "prescan": "{\"page_lean\":\"start_now\",\"vague_words\":[],\"size_words\":[],\"waiting_on\":[],\"bricks\":[{\"kind\":\"fear\",\"words\":\"anxious\"}],\"word_count\":7}"
}

Reply:

{
 "task": "unstick",
 "verdict": "start_now",
 "verdict_reason": "Booking a cleaning is one phone call that fits inside the fifteen minutes you have, and writing down what to say first takes care of forgetting what to say once you're on the call.",
 "first_move": {
  "text": "Grab a pen and write down the few words you'll say first: that you'd like to book a cleaning.",
  "minutes": 3
 },
 "steps": [
  {
   "text": "Call the dentist's office and read your written lines to book the cleaning appointment.",
   "minutes": 6,
   "done_when": "You have an appointment time, or they've said they'll call you back."
  },
  {
   "text": "Write the appointment time somewhere you'll actually see it, like your phone calendar.",
   "minutes": 2,
   "done_when": "The time is saved and visible."
  }
 ],
 "bricks": [
  {
   "label": "phone calls make me anxious",
   "kind": "fear",
   "counter": "Keep your written lines in front of you while you dial, so the anxious part has less to grab onto."
  },
  {
   "label": "always forget what to say",
   "kind": "unclear",
   "counter": "Stick to the one line you wrote: your name, the cleaning, and the days that work."
  }
 ],
 "message": null,
 "summary": "What's in the way is the phone call itself - you said it makes you anxious and that you forget what to say once you're on it. The first move is writing down a few short lines, so by the time you dial you already know what you're going to say."
}

double - four tax-sorting tasks in 75 minutes - fits

Input:

{
 "task": "double",
 "today": "2026-10-20",
 "tasks": "[{\"id\":\"t1\",\"text\":\"Find the shoebox of tax papers\",\"minutes\":10,\"minutes_source\":\"you\"},{\"id\":\"t2\",\"text\":\"Sort the papers into three piles\",\"minutes\":20,\"minutes_source\":\"you\"},\"... 2 more\"]",
 "session_minutes": "75",
 "rhythm": "{\"label\":\"25 on, 5 off\",\"sprint\":25,\"break\":5}",
 "double_style": "gentle",
 "derailers": "My phone, and getting up for snacks.",
 "page_plan": "{\"work_budget\":70,\"sprints\":[{\"minutes\":25,\"task_ids\":[\"t1\",\"t2\"]},{\"minutes\":25,\"task_ids\":[\"t2\",\"t3\",\"t4\"]},{\"minutes\":5,\"task_ids\":[\"t4\"]}],\"parked_ids\":[],\"page_lean\":\"fits\"}"
}

Reply:

{
 "task": "double",
 "verdict": "fits",
 "verdict_reason": "All four tasks - fifty-five minutes of focused work plus two five-minute breaks, sixty-five minutes in all - fit inside the seventy-minute work budget, so nothing needs to be parked for another time.",
 "opening": "Hello - glad you're here, and glad you're not doing this alone. Before we start, say out loud the very first thing you're going to touch: the shoebox of tax papers.",
 "frames": [
  {
   "kind": "sprint",
   "minutes": 25,
   "task_ids": [
    "t1",
    "t2"
   ],
   "focus": "Finding the shoebox of tax papers and starting to sort the papers into the three piles.",
   "double_line": "I'm getting settled next to you now, working on my own thing. Take it slow - find the shoebox first, and once it's open, ease into sorting the papers into their three piles.",
   "check_in": "Where did you get to with the shoebox and the sorting?"
  },
  {
   "kind": "break",
   "minutes": 5,
   "task_ids": [],
   "focus": "Stand up, stretch, and look out of a window for a few minutes.",
   "double_line": "Let's both pause here for a few minutes - stand up, roll your shoulders, and let your eyes rest somewhere far away.",
   "check_in": ""
  },
  {
   "kind": "sprint",
   "minutes": 25,
   "task_ids": [
    "t2",
    "t3",
    "t4"
   ],
   "focus": "Finishing the sort into the three piles, listing which forms are missing, and starting the questions for the tax helpline.",
   "double_line": "Whenever you're ready, let's settle back in together. Keep easing through the piles, then move into noting which forms seem to be missing, and start jotting down your questions for the helpline.",
   "check_in": "Where are things now with the piles, the missing-forms list, and the helpline questions?"
  },
  "... 2 more"
 ],
 "parked": [],
 "derail_plan": [
  "If your phone pulls at your attention, then set it somewhere out of reach for this session and come back to the table.",
  "If you feel the pull to get up for a snack, then keep something to drink close by instead, and save the snack for the next break."
 ],
 "closing": "Look at what came together today - the shoebox is open, the papers are sorted, the missing forms are listed, and the helpline questions are written down. Next time, the next small step is actually calling the helpline with those questions.",
 "summary": "The session moves through three steady stretches of work with two short breaks worked in between, covering the shoebox, the sort, the missing-forms list, and the helpline questions in order. The fifty-five minutes of tasks fit inside the seventy-minute work budget, so nothing is left over for another time."
}

double - six moving-out jobs in 60 minutes - trim

Input:

{
 "task": "double",
 "today": "2026-10-20",
 "tasks": "[{\"id\":\"t1\",\"text\":\"Reply to the letting agency email\",\"minutes\":10,\"minutes_source\":\"you\"},{\"id\":\"t2\",\"text\":\"Photograph the meter readings\",\"minutes\":10,\"minutes_source\":\"you\"},\"... 4 more\"]",
 "session_minutes": "60",
 "rhythm": "{\"label\":\"25 on, 5 off\",\"sprint\":25,\"break\":5}",
 "double_style": "brisk",
 "derailers": "not given",
 "page_plan": "{\"work_budget\":55,\"sprints\":[{\"minutes\":25,\"task_ids\":[\"t1\",\"t2\",\"t3\"]},{\"minutes\":15,\"task_ids\":[\"t3\"]}],\"parked_ids\":[\"t4\",\"t5\",\"t6\"],\"page_lean\":\"trim\"}"
}

Reply:

{
 "task": "double",
 "verdict": "trim",
 "verdict_reason": "About forty minutes of focused work fits into today's fifty-five, so the three longest items on the list are held for another session.",
 "opening": "Hey, glad you're here. Say out loud the first thing you're about to pick up, and let's start the clock.",
 "frames": [
  {
   "kind": "sprint",
   "minutes": 25,
   "task_ids": [
    "t1",
    "t2",
    "t3"
   ],
   "focus": "Replying to the letting agency email, photographing the meter readings, and making a start on cancelling the old internet contract.",
   "double_line": "Clock's running - I'm getting into my own stuff right here beside you. Go.",
   "check_in": "Where did you land - did the email and the photos get done, and how far did you get on the internet contract?"
  },
  {
   "kind": "break",
   "minutes": 5,
   "task_ids": [],
   "focus": "Stand up, get a glass of water, and look out of a window.",
   "double_line": "Five minutes, up and away from the screen - back here after.",
   "check_in": ""
  },
  {
   "kind": "sprint",
   "minutes": 15,
   "task_ids": [
    "t3"
   ],
   "focus": "Finishing the cancellation of the old internet contract.",
   "double_line": "Last stretch - let's close out that internet contract together.",
   "check_in": "Did the internet contract get cancelled?"
  }
 ],
 "parked": [
  {
   "id": "t4",
   "why": "Updating the address with the bank didn't fit in today's time, so it waits for next time."
  },
  {
   "id": "t5",
   "why": "Filing the moving receipts is being left for a session with more room."
  },
  "... 1 more"
 ],
 "derail_plan": [
  "If your attention drifts off the table, notice it and come straight back."
 ],
 "closing": "Nice work - the email, the photos, and the internet contract are handled. Next time, let's open with updating the address with the bank.",
 "summary": "Two sprints with one short break fit in the letting agency email, the meter photos, and cancelling the old internet contract. Updating the bank address, filing the moving receipts, and returning the spare keys are parked for another session."
}

double - three quick jobs in 30 minutes - fits

Input:

{
 "task": "double",
 "today": "2026-10-20",
 "tasks": "[{\"id\":\"t1\",\"text\":\"Call the dentist\",\"minutes\":10,\"minutes_source\":\"you\"},{\"id\":\"t2\",\"text\":\"Pay the water bill\",\"minutes\":5,\"minutes_source\":\"you\"},\"... 1 more\"]",
 "session_minutes": "30",
 "rhythm": "{\"label\":\"15 on, 5 off\",\"sprint\":15,\"break\":5}",
 "double_style": "quiet",
 "derailers": "Checking messages after every little thing.",
 "page_plan": "{\"work_budget\":25,\"sprints\":[{\"minutes\":15,\"task_ids\":[\"t1\",\"t2\"]},{\"minutes\":5,\"task_ids\":[\"t3\"]}],\"parked_ids\":[],\"page_lean\":\"fits\"}"
}

Reply:

{
 "task": "double",
 "verdict": "fits",
 "verdict_reason": "All three tasks fit inside the 25 minutes you have, so nothing needs to wait for another time.",
 "opening": "Hi. Say out loud the first thing you'll touch.",
 "frames": [
  {
   "kind": "sprint",
   "minutes": 15,
   "task_ids": [
    "t1",
    "t2"
   ],
   "focus": "The call to the dentist, then the water bill.",
   "double_line": "Go.",
   "check_in": "Where are you with the call and the bill?"
  },
  {
   "kind": "break",
   "minutes": 5,
   "task_ids": [],
   "focus": "Stand up and get some water.",
   "double_line": "Five off.",
   "check_in": ""
  },
  {
   "kind": "sprint",
   "minutes": 5,
   "task_ids": [
    "t3"
   ],
   "focus": "The reply to Sam about Saturday.",
   "double_line": "Last stretch.",
   "check_in": "Did the reply to Sam go out?"
  }
 ],
 "parked": [],
 "derail_plan": [
  "If you pick up your phone to check messages, then put it face down and come back to the table."
 ],
 "closing": "You got through the call to the dentist, the water bill, and the reply to Sam. Next time, bring whatever's next to the table.",
 "summary": "The first 15 minutes covers the call and the bill, then a short break, then 5 minutes for the reply to Sam. Nothing is left over for another session."
}

Build the input yourself

Load both scripts in node (global.window = {}; require("./tablekit.js"); require("./tablereply.js")), then:

const K = TableKit;
const st = K.state({
  u: { task: "Call the dentist to book a cleaning", awful: "Phone calls make me anxious", waited: "weeks", energy: "medium", minutes: "15" },
  d: { tasks: "Call the dentist (10m)\nPay the water bill (5m)\nReply to Sam", session: "30", rhythm: "15-5", style: "quiet", derailers: "" }
});
console.log(st.u.lean, st.u.bricks, st.d.plan.frames, st.d.plan.parked);
const unstickWire = K.toWire(K.buildUnstick(st, { today: "2026-10-20" }).input);   // POST to /estimate and /run
const doubleWire = K.toWire(K.buildDouble(st, { today: "2026-10-20" }).input);
// later: const u = K.normalizeUnstick(K.parseReply(job.output.output)); K.checkUnstick(u, st).notes
//        const d = K.normalizeDouble(K.parseReply(job2.output.output)); K.checkDouble(d, st).notes
// hand the ladder to the session: K.stepsToTasks(u) gives one "text (Nm)" line per step

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://same-table.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":"same-table"}' | 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": "unstick", "today": "2026-10-20", "avoided_task": "Call the dentist to book a cleaning", "avoided_for": "a few weeks", "awful": "Phone calls make me anxious", "energy": "medium", "minutes_free": "15", "prescan": "{\"page_lean\":\"start_now\", ...}"}
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 task and the input: a retry with the same key never bills twice. The page uses same-table:<task>:<inputHash>:a<attempt> (task is unstick or double), 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="same-table:unstick:$(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: same-table:unstick:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json