← Linkable
Linkable API
Tokens

Drive Linkable from your own code

The metered read is available over HTTP. A natural use is a privacy checklist or an onboarding flow for people who post publicly: your code sends a person's own profile texts with the page's clue list, and gets back what a stranger could piece together, a fix for every clue that matters, and each profile rewritten with those clues trimmed.

The division of labour first: the page finds the clues and the overlaps; the model explains and rewrites; the page checks the model against its own clue list. Finding contact details, addresses, live-location phrases, birthdays, places, workplaces, schools, routines, pet and family names, links and handles, merging matching handles and names across profiles, the links between profiles, the flags and the page's verdict all come from linkkit.js, which runs in node as well as in the browser. Its check function then holds each reply to the clue list: every shared or high-risk clue needs a remove or blur fix, a trimmed clue must be gone from that profile's rewrite, and the verdict is compared with the page's own. Linkable reads only the text it is sent and looks nothing up; send only profiles you own or have permission to audit.

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":"linkable"}), 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 or profiles 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 read 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 comes first

Every run sends task. Linkable has one lane, read; the field is still required so the contract can grow without breaking callers.

taskReturns
readtitle, verdict (low / moderate / high), summary; links [{profiles, via, strength}] (0-3, strength strong or weak); picture [{claim, basis, confidence}] (1-5, confidence likely or possible); fixes [{clue, profile, action, how}] - one per page clue, action remove, blur or keep; rewrites [{profile, text}] - one per profile with text; habits (0-3 strings).
VerdictMeaning
lowNothing shared between the profiles, no high-risk clue, at most one medium clue.
moderateA clue is shared (the accounts can be joined), or there are two or more medium clues, but nothing says how to reach the person or where and when to find them.
highA contact detail, a street address, a live location or a birthday is visible, or a routine sits next to a place.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (LinkKit.toWire does this). task and profiles are required.

Input fields

FieldTypeNotes
taskstringread.
todaystringYYYY-MM-DD.
worrystringWho the person would rather not be found by, in their words (up to 300 characters), or "".
profilesstring (JSON)1 to 3: [{label, handle, name, text}]. label is the person's own name for the profile; text is the bio plus a few posts. Over 3,000 characters the page keeps the start and the end and marks the cut.
page_cluesstring (JSON)At most 40, shared and high-risk first: [{id, kind, value, profiles, risk, shared}]. kind: contact, address, live, birthday, place, work, school, routine, pet, family, name, handle, link, age. risk: high, medium or low.
page_linksstring (JSON)[{profiles: [a, b], via: [clue ids], strength}].
page_readsstring (JSON)[{level, text}]; level is high, medium or info.
page_verdictstringThe page's own reading: low, moderate or high.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with LinkKit.parseReply, then normalize(reply), then check(reply, state), which returns {notes, missed, leaks}: the notes shown under "Checked by the page", the number of shared or high-risk clues left without a fix, and the number of trimmed clues still present in a rewrite.

Worked examples

Replies produced against the app's prompt for the sample profiles (made-up people, handles and places; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.

task: read - Two quiet profiles - low

Input:

{
 "task": "read",
 "today": "2026-10-20",
 "worry": "",
 "profiles": "[{\"label\": \"Photo app\", \"handle\": \"tidepool_frames\", \"name\": \"\", \"text\": \"Macro and close-up photography. Moss, lichen, rain on glass.\\nShooting ...\"}, {\"label\": \"Lens forum\", \"handle\": \"slow_shutter_notes\", \"name\": \"\", \"text\": \"Mostly here for vintage lens repair threads.\\n\\nPost: cleaned haze out o...\"}]",
 "page_clues": "[{\"id\": \"c1\", \"kind\": \"handle\", \"value\": \"@tidepool_frames\", \"profiles\": [\"Photo app\"], \"risk\": \"low\", \"shared\": false}, {\"id\": \"c2\", \"kind\": \"handle\", \"value\": \"@slow_shutter_notes\", \"profiles\": [\"Lens forum\"], \"risk\": \"low\", \"shared\": false}]",
 "page_links": "[]",
 "page_reads": "[{\"level\": \"info\", \"text\": \"The page found no clue shared between the profiles.\"}]",
 "page_verdict": "low"
}

Reply:

{
 "task": "read",
 "title": "Two quiet hobby profiles, minimal exposure",
 "verdict": "low",
 "summary": "This reading agrees with the low verdict: both profiles describe a macro-photography and vintage-lens-repair hobby, but neither contains a name, location, or contact detail a stranger could use. Nothing in the text ties the two accounts together, so there is no single combination that stands out as the most revealing. The handle on each profile is already the only identifying detail, and leaving it as the sole identifier is the simplest way to keep it that way.",
 "links": [],
 "picture": [
  {
   "claim": "A stranger reading both profiles would mainly learn that this person does macro photography and repairs vintage camera lenses as a hobby.",
   "basis": "the profiles describe \"Macro and close-up photography\" and being \"here for vintage lens repair threads\"",
   "confidence": "likely"
  },
  {
   "claim": "A stranger would not easily connect the two accounts to each other, since nothing in the text links them.",
   "basis": "the two handles are unrelated words and neither profile's text mentions a name, place, or other detail that appears on the other",
   "confidence": "possible"
  }
 ],
 "fixes": [
  {
   "clue": "c1",
   "profile": "Photo app",
   "action": "keep",
   "how": "Leave this handle as is - on its own it is just how people find this account, and the text doesn't connect it to anything else."
  },
  {
   "clue": "c2",
   "profile": "Lens forum",
   "action": "keep",
   "how": "Leave this handle as is too - it doesn't appear anywhere else and doesn't reveal anything about who runs the account."
  }
 ],
 "rewrites": [
  {
   "profile": "Photo app",
   "text": "Macro and close-up photography. Moss, lichen, rain on glass.\nShooting on an old manual lens most of the time.\n\nPost: tried focus stacking on a snail shell - 14 frames, still learning.\nPost: overcast days are the best light for this. Change my mind."
  },
  "..."
 ],
 "habits": [
  "Post about a project after it's finished rather than while it's still in progress, so timing doesn't line up with where you are.",
  "Check equipment or workspace photos for background details before posting them."
 ]
}

task: read - Same handle, same city - moderate

Input:

{
 "task": "read",
 "today": "2026-10-20",
 "worry": "Coworkers finding my gaming account.",
 "profiles": "[{\"label\": \"Gaming profile\", \"handle\": \"kestrel_92\", \"name\": \"\", \"text\": \"Speedrunner from Portland. Platformers, mostly glitchless categories.\\n...\"}, {\"label\": \"Book club forum\", \"handle\": \"kestrel92\", \"name\": \"Mira\", \"text\": \"Based in Portland. Sci-fi and long fantasy series, the longer the bett...\"}]",
 "page_clues": "[{\"id\": \"c1\", \"kind\": \"pet\", \"value\": \"Biscuit\", \"profiles\": [\"Gaming profile\", \"Book club forum\"], \"risk\": \"medium\", \"shared\": true}, {\"id\": \"c2\", \"kind\": \"place\", \"value\": \"Portland\", \"profiles\": [\"Gaming profile\", \"Book club forum\"], \"risk\": \"medium\", \"shared\": true}, \"... 2 more\"]",
 "page_links": "[{\"profiles\":[\"Gaming profile\",\"Book club forum\"],\"via\":[\"c1\",\"c2\",\"c3\"],\"strength\":\"strong\"}]",
 "page_reads": "[{\"level\": \"medium\", \"text\": \"Gaming profile and Book club forum share 3 clues (pet name, place, handle) - enough to join the two accounts.\"}, \"... 1 more\"]",
 "page_verdict": "moderate"
}

Reply:

{
 "task": "read",
 "title": "Shared cat name and city link profiles",
 "verdict": "moderate",
 "summary": "This reads as moderate risk, matching the page's read: the gaming profile and the book club profile share the same cat's name, the same city, and nearly identical handles, which together make them easy to join as one account, and the book club profile's display name then attaches a real first name to that same combination. Blurring the shared cat name and city, and giving the two profiles handles that no longer share a stem, is the single change that helps most. That also keeps the gaming profile from reading as the same account as the book club one, since nothing left in the text would tie the two together.",
 "links": [
  {
   "profiles": [
    "Gaming profile",
    "Book club forum"
   ],
   "via": "Both profiles mention a cat with the same name (\"my cat Biscuit\" and \"Biscuit approves of reading in bed\"), both say \"Portland\", and the handles \"kestrel_92\" and \"kestrel92\" differ by only an underscore.",
   "strength": "strong"
  }
 ],
 "picture": [
  {
   "claim": "A stranger could read the gaming profile and the book club profile as the same person.",
   "basis": "the shared cat name (\"my cat Biscuit\" / \"Biscuit approves of reading in bed\"), the shared city (\"Speedrunner from Portland\" / \"Based in Portland\"), and the near-identical handles \"kestrel_92\" and \"kestrel92\"",
   "confidence": "likely"
  },
  {
   "claim": "Once joined, that link attaches a real first name to the speedrunning handle.",
   "basis": "the book club profile's display name, \"Mira\"",
   "confidence": "likely"
  },
  "..."
 ],
 "fixes": [
  {
   "clue": "c1",
   "profile": "",
   "action": "blur",
   "how": "Replace the cat's name \"Biscuit\" with a generic \"my cat\" on both profiles so it can no longer tie the two profiles together or serve as a recovery-question answer."
  },
  {
   "clue": "c2",
   "profile": "",
   "action": "blur",
   "how": "Replace \"Portland\" with a generic \"a city\" on both profiles so the shared home city no longer connects them."
  },
  {
   "clue": "c3",
   "profile": "",
   "action": "blur",
   "how": "In account settings, change one of the two handles so \"kestrel_92\" and \"kestrel92\" no longer share the same stem."
  },
  "..."
 ],
 "rewrites": [
  {
   "profile": "Gaming profile",
   "text": "Speedrunner from a city. Platformers, mostly glitchless categories.\nStreams when the schedule allows.\n\nPost: new personal best on the second world, finally under 40 minutes.\nPost: my cat walked across the keyboard mid-run. Reset."
  },
  "..."
 ],
 "habits": [
  "Keep a pet's name out of posts, since it is a common account-recovery answer.",
  "Mention only a general region instead of naming the same home city on every profile.",
  "..."
 ]
}

task: read - Runner, photos, work - high

Input:

{
 "task": "read",
 "today": "2026-10-20",
 "worry": "A former client who got too friendly.",
 "profiles": "[{\"label\": \"Running app\", \"handle\": \"dana.runs\", \"name\": \"Dana K.\", \"text\": \"Training for a spring half marathon.\\nTempo every Tuesday 6am at the Ri...\"}, {\"label\": \"Photo app\", \"handle\": \"danaruns\", \"name\": \"Dana\", \"text\": \"Sunrises, trails and one very spoiled dog.\\n\\nPost: golden hour from the...\"}, {\"label\": \"Work profile\", \"handle\": \"dana-kowalczyk\", \"name\": \"Dana Kowalczyk\", \"text\": \"Physical therapist at Northgate Clinic, sports injuries and running ga...\"}]",
 "page_clues": "[{\"id\": \"c1\", \"kind\": \"name\", \"value\": \"Dana Kowalczyk\", \"profiles\": [\"Work profile\", \"Running app\", \"Photo app\"], \"risk\": \"medium\", \"shared\": true}, {\"id\": \"c2\", \"kind\": \"pet\", \"value\": \"Pepper\", \"profiles\": [\"Running app\", \"Photo app\"], \"risk\": \"medium\", \"shared\": true}, \"... 9 more\"]",
 "page_links": "[{\"profiles\":[\"Running app\",\"Photo app\"],\"via\":[\"c1\",\"c2\",\"c3\",\"c4\"],\"strength\":\"strong\"},{\"profiles\":[\"Running app\",\"Work profile\"],\"via\":[\"c1\"],\"strength\":\"strong\"},{\"profiles\":[\"Photo app\",\"Work profile\"],\"via\":[\"c1\"],\"strength\":\"strong\"}]",
 "page_reads": "[{\"level\": \"medium\", \"text\": \"Running app and Photo app share 4 clues (name, pet name, place, handle) - enough to join the two accounts.\"}, \"... 7 more\"]",
 "page_verdict": "high"
}

Reply:

{
 "task": "read",
 "title": "Shared details tie your profiles together",
 "verdict": "high",
 "summary": "This reads as high risk: a shared name, your dog's name, a shared park and near-identical handles connect your running and photo profiles to your named work profile, and a real-time post plus a listed email make you easy to reach right now. The most revealing combination is the exact weekly time and place of your tempo run, which tells a stranger exactly where to be and when to find you in person. Deleting the real-time post and blurring the run's day, time, place, and your dog's name help the most, and together with changing a handle these changes make it harder for someone from your past to connect your accounts or predict where you'll be, without needing to know anything else about that person.",
 "links": [
  {
   "profiles": [
    "Running app",
    "Photo app"
   ],
   "via": "both mention the same dog by name, the same park, and use near-identical handles",
   "strength": "strong"
  },
  {
   "profiles": [
    "Running app",
    "Work profile"
   ],
   "via": "the full name on the work profile matches the name used on the running profile",
   "strength": "strong"
  },
  "..."
 ],
 "picture": [
  {
   "claim": "The running, photo and work profiles all belong to the same person, identified by her full name.",
   "basis": "the work profile's name 'Dana Kowalczyk' matches 'Dana K.' on the running profile and 'Dana' on the photo profile, and the same dog and the same park appear on the running and photo profiles with near-identical handles",
   "confidence": "likely"
  },
  {
   "claim": "A stranger could show up at a specific time and place to meet her in person.",
   "basis": "the running profile says 'Tempo every Tuesday 6am at the Riverside Park track'",
   "confidence": "likely"
  },
  "..."
 ],
 "fixes": [
  {
   "clue": "c1",
   "profile": "Work profile",
   "action": "keep",
   "how": "Keep your full name here since this is the profile clients use to book appointments."
  },
  {
   "clue": "c1",
   "profile": "Running app",
   "action": "blur",
   "how": "In this profile's settings, shorten the displayed name to a first name only, dropping the last initial."
  },
  {
   "clue": "c1",
   "profile": "Photo app",
   "action": "remove",
   "how": "In this profile's settings, swap the first name shown for a nickname that isn't close to your real name."
  },
  "..."
 ],
 "rewrites": [
  {
   "profile": "Running app",
   "text": "Training for a spring half marathon.\nTempo on weekday mornings at a park by the river, then the long loop.\n\nPost: my dog joined the weekend long run and refused to turn back.\nPost: 10 x 400 done. Legs are toast."
  },
  "..."
 ],
 "habits": [
  "Post about a place after you've already left it, not while you're still there.",
  "Vary the days and times you mention for recurring routines so a pattern doesn't repeat.",
  "..."
 ]
}

Build the input yourself

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

const K = LinkKit;
const st = K.state({
  worry: "Old classmates.",
  p1_label: "Music profile", p1_handle: "night_owl_beats", p1_name: "",
  p1_text: "Bedroom producer from Leeds. New track every Friday 9pm.",
  p2_label: "Gaming profile", p2_handle: "nightowlbeats", p2_name: "Sam",
  p2_text: "Co-op games most nights. My dog Rufus is my co-pilot."
});
console.log(st.clues, st.reads, st.verdict);
const wire = K.toWire(K.buildInput(st, { today: "2026-10-20" }).input);   // POST to /estimate and /run
// later: const read = K.normalize(K.parseReply(job.output.output)); K.check(read, st);

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://linkable.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":"linkable"}' | 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": "read", "today": "2026-10-20", "worry": "", "profiles": "[{\"label\":\"Running app\",\"handle\":\"...\",\"name\":\"...\",\"text\":\"...\"}]", "page_clues": "[...]", "page_links": "[...]", "page_reads": "[...]", "page_verdict": "high"}
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 linkable:<lane>:<inputHash>:a<attempt> (the one lane is read), 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="linkable:read:$(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: linkable:read:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json