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": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both runs are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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.
| task | Sends | Returns | Verdicts |
|---|---|---|---|
unstick | one avoided task, what makes it awful, energy, minutes free, and the page's read | first move, steps, bricks, a message when waiting, summary | start_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) |
double | a task list, the session length, the rhythm, the double's style, derailers, and the page's packing | opening, frames, parked, derail plan, closing, summary | fits (nothing parked), trim (some tasks left for another session) |
Input fields
| Field | Task | Type | Notes |
|---|---|---|---|
task, today | both | string | unstick or double; YYYY-MM-DD. |
avoided_task | unstick | string | The task in the person's words, up to 300 characters. |
avoided_for, energy, minutes_free | unstick | string | a few days / a few weeks / months / longer than I can remember or not given; low / medium / high; whole minutes or not given. |
awful | unstick | string | What makes it awful, up to 1,500 characters (cut on a sentence with a marker), or not given. |
prescan | unstick | string (JSON) | {page_lean, vague_words, size_words, waiting_on, bricks:[{kind, words}], word_count}. |
tasks | double | string (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, derailers | double | string | 15-240; gentle / brisk / quiet; free text or not given. |
rhythm | double | string (JSON) | {label, sprint, break} in minutes: 25/5, 15/5, 45/10 or 50/10. |
page_plan | double | string (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_note | both | string | Only on a reformat retry. |
The replies
The job's output.output is a string holding one JSON object.
- unstick:
verdict,verdict_reason,first_move{text, minutes}(2-5),steps[{text, minutes, done_when}](1-3 for start_now, 3-8 for break_down, 2-6 for ask_first),bricks[{label, kind, counter}](kind is unclear / dread / fear / boredom / too_big / decision / waiting),message{to, text}or null,summary. Parse withTableKit.parseReply, thennormalizeUnstick, thencheckUnstick(reply, state). - double:
verdict,verdict_reason,opening,frames[{kind, minutes, task_ids, focus, double_line, check_in}](sprint / break, alternating, starting and ending with a sprint),parked[{id, why}],derail_plan(strings),closing,summary. Parse,normalizeDouble, thencheckDouble(reply, state).
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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://same-table.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// same-table.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://same-table.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://same-table.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SameTable.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SameTable {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://same-table.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://same-table.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://same-table.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://same-table.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "same-table"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "same-table" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "same-table"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "same-table"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "same-table" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "same-table"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "same-table" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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\", ...}"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "unstick" or "double", today, avoided_task, avoided_for, awful, energy, minutes_free, prescan - or for double: tasks, session_minutes, rhythm, double_style, derailers, page_plan (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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}'
import hashlib, time
key = "same-table:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `same-table:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "same-table:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "same-table:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "same-table:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "same-table:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"same-table:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
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
payload = json.load(open("input.json")) # the same run input as input.json: {"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\", ...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "same-table:unstick:" + hashlib.sha256(json.dumps(payload).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const payload = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(payload), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `same-table:unstick:${createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "same-table:unstick:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String payload = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(payload.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "same-table:unstick:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(payload)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
payload = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "same-table:unstick:#{Digest::SHA256.hexdigest(payload)[0, 16]}:a1")
req.body = payload
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$payload = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: same-table:unstick:" . substr(hash("sha256", $payload), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var payload = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"same-table:unstick:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);