← Patch or Pull
API

Drive Patch or Pull from your own code

The one metered task, sort-chats, is available over HTTP. It takes up to eight group chats - each with a name, what it is for, its volume band, how much you read it, when you last posted, how often it has things you act on, whether you would miss it and whether its alerts are on - plus an optional priority, and returns a plain-words reason for each chat's call and a short note you could post in the chats you mute, set to tags only, or step out of.

The division of labour first: the page decides; the model explains. The page scores each chat's signal and noise and picks its call before the run, and sends all of it in facts. The model copies each call verbatim and never changes one.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"patch-or-pull"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, chats, priority, facts) 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 reasons are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the calls

Every run sends task: sort-chats (the one task is sort-chats); a missing or different one is still answered as sort-chats. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways sort-chats.
chatsyesOne numbered line per chat (2 to 8): name | for: ... | volume: ... | reading: ... | you last posted: ... | has things you act on: ... | would miss it: ... | alerts now: .... Volume is one of Under 5 a day; 5 to 30 a day; 30 to 100 a day; Over 100 a day. Reading: I read it all; I skim it; It piles up unread. Last posted: This week; This month; Longer ago or never. Act on: Often; Sometimes; Rarely or never. Miss it: Yes; Not sure; No. Alerts: Alerts on; Already muted.
priorityyesWhat matters most right now, up to 120 characters, or not given.
factsyesThe page's lines: per chat its signal (0-8) with the four parts, its noise (0-3) and its verdict; then the count of each verdict.
retry_notenoOnly on the page's one reformat retry.

Signal is the sum of four parts, each 0-2: would miss it (Yes 2, Not sure 1, No 0), has things to act on (Often 2, Sometimes 1, Rarely 0), last posted (This week 2, This month 1, Longer 0) and reading (all 2, skim 1, piles up 0). Noise is the volume band, 0-3. The call, first match wins: signal 6+ with noise 0-1 is keep-ringing; signal 6+ otherwise is tag-only; signal 3+, or act-on Often, is mute-and-skim; otherwise miss-it No is step-out; the rest are archive. The fixed order means there are no ties.

The reply

{"task": "sort-chats", "headline": "...", "chats": [{"name": "...", "verdict": "mute-and-skim", "why": "...", "note": "..."}], "priority_fit": "...", "closing": "..."}
FieldWhat it holds
headlineAt most 22 words summing up the board; counts match facts.
chats[]One per input chat, same order and exact names: verdict copied from facts, why (at most 45 words, from that chat's own answers) and note (at most 20 words, first person, for tag-only, mute-and-skim and step-out only; otherwise empty).
priority_fitAt most 45 words on how the board serves the priority; empty when it is not given.
closingOne sentence pointing to the first change; never claims anything is done.

The page then checks the reply (ChatKit.check) - each call against its own, every chat present, notes present only where wanted, notes with time words, contact-like details or "already done" claims, chats it did not list, internal ids in prose, a priority line with no priority, and word limits - and lists any disagreement under "The page's check". The first step for each call is fixed text on the page, not model output.

Worked examples - all five calls

These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all five calls.

Four chats, too much buzzing - calls: keep it ringing, only when tagged, mute and skim, step out

Input:

{
 "task": "sort-chats",
 "chats": "1. Flat 4B | for: bins, parcels and repairs | volume: 5 to 30 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on\n2. Book club | for: picking the next book and where to meet | volume: 30 to 100 a day | reading: I skim it | you last posted: This month | has things you act on: Sometimes | would miss it: Yes | alerts now: Alerts on\n3. School run parents | for: lifts and pick-up swaps | volume: Over 100 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Not sure | alerts now: Alerts on\n4. Old job farewell | for: one leaving do that already happened | volume: Under 5 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: No | alerts now: Alerts on",
 "priority": "fewer buzzes while I study for my evening course",
 "facts": "1. Flat 4B: signal 8 of 8 (miss 2, act-on 2, posted 2, read 2); noise 1 of 3; verdict keep-ringing\n2. Book club: signal 5 of 8 (miss 2, act-on 1, posted 1, read 1); noise 2 of 3; verdict mute-and-skim\n3. School run parents: signal 7 of 8 (miss 1, act-on 2, posted 2, read 2); noise 3 of 3; verdict tag-only\n4. Old job farewell: signal 0 of 8 (miss 0, act-on 0, posted 0, read 0); noise 0 of 3; verdict step-out\ncounts: keep-ringing 1, tag-only 1, mute-and-skim 1, step-out 1, archive 0 (of 4 chats)"
}

Reply:

{
 "task": "sort-chats",
 "headline": "One chat you keep ringing, one is only when tagged, one you mute and skim, and one you step out of.",
 "chats": [
  {
   "name": "Flat 4B",
   "verdict": "keep-ringing",
   "why": "You read every message here, it often has things for you to act on like bins, parcels and repairs, and you said you'd miss it, so it makes sense to keep it ringing at 5 to 30 a day.",
   "note": ""
  },
  {
   "name": "Book club",
   "verdict": "mute-and-skim",
   "why": "This chat is for picking the next book and where to meet. You skim it and it sometimes has things to act on, so with 30 to 100 a day coming through, mute and skim suits it best.",
   "note": "Hi everyone, please mention me directly if you need my input here."
  },
  {
   "name": "School run parents",
   "verdict": "tag-only",
   "why": "This chat is for lifts and pick-up swaps. You read all of it and it often has things to act on, but you are not sure you would miss it, and with over 100 a day coming through, only when tagged fits better.",
   "note": "Hi all, please tag me directly if something needs my help here."
  },
  {
   "name": "Old job farewell",
   "verdict": "step-out",
   "why": "This chat was for one leaving do that already happened, it piles up unread, rarely has anything to act on, and you said you would not miss it, so stepping out fits here.",
   "note": "Thank you all - wishing you well, and goodbye."
  }
 ],
 "priority_fit": "Keeping just one chat ringing, with the rest set to only when tagged, mute and skim, or stepped out of, should give you fewer buzzes while you study for your evening course.",
 "closing": "A good first step is to turn down the alerts on Book club so it moves to mute and skim."
}

Family, hobbies and a deals group - calls: keep it ringing, only when tagged, step out, archive it

Input:

{
 "task": "sort-chats",
 "chats": "1. Cousins | for: family photos and news | volume: 30 to 100 a day | reading: I skim it | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: Not sure | alerts now: Alerts on\n2. Allotment swap | for: swapping seedlings and tools | volume: Under 5 a day | reading: I read it all | you last posted: This month | has things you act on: Sometimes | would miss it: Yes | alerts now: Already muted\n3. Climbing wall crew | for: who is going and when | volume: 30 to 100 a day | reading: I skim it | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on\n4. Deals and coupons | for: shared offers | volume: Over 100 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: No | alerts now: Alerts on",
 "priority": "not given",
 "facts": "1. Cousins: signal 2 of 8 (miss 1, act-on 0, posted 0, read 1); noise 2 of 3; verdict archive\n2. Allotment swap: signal 6 of 8 (miss 2, act-on 1, posted 1, read 2); noise 0 of 3; verdict keep-ringing\n3. Climbing wall crew: signal 7 of 8 (miss 2, act-on 2, posted 2, read 1); noise 2 of 3; verdict tag-only\n4. Deals and coupons: signal 0 of 8 (miss 0, act-on 0, posted 0, read 0); noise 3 of 3; verdict step-out\ncounts: keep-ringing 1, tag-only 1, mute-and-skim 0, step-out 1, archive 1 (of 4 chats)"
}

Reply:

{
 "task": "sort-chats",
 "headline": "Among your chats, one keeps ringing, one switches to only when tagged, one steps out, and one gets archived.",
 "chats": [
  {
   "name": "Cousins",
   "verdict": "archive",
   "why": "You only skim Cousins, posted there longer ago or never, and rarely have things to act on, so even though you're not sure you'd miss it, archiving fits best while you stay a member.",
   "note": ""
  },
  {
   "name": "Allotment swap",
   "verdict": "keep-ringing",
   "why": "You read every message in Allotment swap, sometimes find things to act on there, and say you would miss it, so alerts come back on even though it's quiet, at under 5 a day.",
   "note": ""
  },
  {
   "name": "Climbing wall crew",
   "verdict": "tag-only",
   "why": "Climbing wall crew often has things you act on and you'd miss it, but you only skim it and it's busy, at 30 to 100 a day, so you'll hear only when someone tags you.",
   "note": "Please tag me directly if you need me for something here."
  },
  {
   "name": "Deals and coupons",
   "verdict": "step-out",
   "why": "In Deals and coupons, messages pile up unread, you rarely or never act on it, and you said you wouldn't miss it, so with it being very busy, at over 100 a day, stepping out fits best.",
   "note": "Thanks for having me in this group - wishing you all well as I step out."
  }
 ],
 "priority_fit": "",
 "closing": "Start by archiving Cousins, the first change this board is pointing you toward."
}

One very noisy street group - calls: keep it ringing, mute and skim, archive it

Input:

{
 "task": "sort-chats",
 "chats": "1. Maple Road notices | for: bin days, lost cats and roadworks | volume: Over 100 a day | reading: I skim it | you last posted: Longer ago or never | has things you act on: Sometimes | would miss it: Not sure | alerts now: Alerts on\n2. Uni friends | for: catching up | volume: 30 to 100 a day | reading: It piles up unread | you last posted: Longer ago or never | has things you act on: Rarely or never | would miss it: Yes | alerts now: Already muted\n3. Board game night | for: who hosts and what to bring | volume: 5 to 30 a day | reading: I read it all | you last posted: This week | has things you act on: Often | would miss it: Yes | alerts now: Alerts on",
 "priority": "less scrolling late at night",
 "facts": "1. Maple Road notices: signal 3 of 8 (miss 1, act-on 1, posted 0, read 1); noise 3 of 3; verdict mute-and-skim\n2. Uni friends: signal 2 of 8 (miss 2, act-on 0, posted 0, read 0); noise 2 of 3; verdict archive\n3. Board game night: signal 8 of 8 (miss 2, act-on 2, posted 2, read 2); noise 1 of 3; verdict keep-ringing\ncounts: keep-ringing 1, tag-only 0, mute-and-skim 1, step-out 0, archive 1 (of 3 chats)"
}

Reply:

{
 "task": "sort-chats",
 "headline": "Of your three chats, one stays loud, one gets muted for skimming, and one moves out of your main list.",
 "chats": [
  {
   "name": "Maple Road notices",
   "verdict": "mute-and-skim",
   "why": "Maple Road notices is a busy one that you only skim, and although it sometimes has things worth acting on, you're not sure you would miss it.",
   "note": "Hey everyone, please tag or mention me directly if something needs my attention here."
  },
  {
   "name": "Uni friends",
   "verdict": "archive",
   "why": "Uni friends piles up unread and rarely has anything for you to act on, even though you would miss it being around. That combination points to tucking it out of your main list.",
   "note": ""
  },
  {
   "name": "Board game night",
   "verdict": "keep-ringing",
   "why": "Board game night often has things for you to act on, you read every message there, and you would miss it, all within 5 to 30 messages a day.",
   "note": ""
  }
 ],
 "priority_fit": "Muting your busiest chat and moving another out of your main list means fewer alerts competing for your attention, helping with less scrolling late at night.",
 "closing": "Start by turning off alerts for Maple Road notices so you can check it on your own terms."
}

Build the input yourself

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

const K = ChatKit;
const form = K.form({ priority: "", chats: [
  { name: "Flat 4B", purpose: "bins and parcels", volume: "5-30", read: "all", posted: "week", need: "often", miss: "yes", alerts: "on" },
  { name: "Deals", purpose: "", volume: "100+", read: "pile", posted: "longer", need: "rarely", miss: "no", alerts: "on" }] });
const built = K.buildInput(form);       // built.facts holds each chat's signal, noise and call
const wire = K.toWire(built.input);     // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes          // the page's re-check of the reply
// K.md(built, reply, K.title(form))    // the board as Markdown

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://patch-or-pull.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":"patch-or-pull"}' | 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": "sort-chats", "chats": "1. Flat 4B | for: bins, parcels and repairs | ...", "priority": "not given", ...}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses patch-or-pull:<task>:<inputHash>:a<attempt> (the one task is sort-chats), 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="patch-or-pull:sort-chats:$(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: patch-or-pull:sort-chats:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json