← No-Buy Pact
API
Tokens

Drive No-Buy Pact from your own code

The two metered steps - writing the pact and judging a temptation - are available over HTTP. A natural use is a budgeting tool or a group challenge: your code sorts the categories, sends the deck, and stores the pact; later it sends each purchase someone is considering and shows the verdict.

The split first: the page does the arithmetic; the model writes the judgement. The deck, the savings estimate, the flags, the cap check and the reply checks ship as a plain script, packkit.js (PactKit.summary, facts, tempt, buildInput, toWire, normalize, reconcilePact, reconcileTempted), which you can load in node. Typical monthly amounts inside it are rough estimates for pre-filling, not data about anyone.

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":"no-buy-pact"}), 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.
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 tasks 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 comes first

Every run sends task. There are two:

taskWhat comes back
pactA title, a first-person pledge, rules (one hard rule per cut category, one allowance per limit with your exact cap, 0–3 narrow exceptions), 2–5 triggers each with the bias behind it and a counter-move, 3–6 swaps, check-ins (cadence, three questions, a streak rule), a slip plan and warnings.
temptedA verdict - fits, wait or skip - with a headline, the pact rules that apply and their effect, the bias in your reason, three questions, a wait plan, a swap and how to buy within the pact if you go ahead.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (PactKit.toWire does this). task and facts are required; both lanes always send them.

Input fields - pact

FieldTypeNotes
categoriesstring (JSON)Array of {id, name, monthly, decision, cap, essential}; decision is cut, limit or keep; ids: clothes, takeout, coffee, dining, beauty, home, tech, online, books, subs, hobby, fun, gifts, groceries, bills, transport.
duration_daysnumber7 to 366.
start_onstringYYYY-MM-DD.
goal, why, triggersstringAt most 1,200 characters each (the page keeps the start and end of longer text with a [...] marker).
factsstring (JSON){monthly_now, monthly_after, monthly_save, projected_save, cut, limit, flags[]} - the page's arithmetic and strictness flags; every warning flag must be answered.

Input fields - tempted

FieldTypeNotes
item, price, item_categorystring, number, stringThe category is an id from the list above or none.
reasonstringWhy they want it - the bias reading depends on it.
wage, usesnumberTake-home per hour and expected uses; 0 when unknown.
pactstring (JSON){pledge, rules[], duration_days, days_in} - the pact's rules (or the page's starter rules).
factsstring (JSON){status, essential, cap, spent_this_month, remaining, over_by, hours_of_work, cost_per_use, wait_until, rule_hint} from PactKit.tempt.

Either task may carry retry_note on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with PactKit.parseReply, then normalize, then check it: reconcilePact(reply, deck) flags cut cards without a hard rule, limits whose cap changed, keep cards that got banned, essentials banned outright, biases outside the list and warning flags left unanswered; reconcileTempted(reply, PactKit.tempt(...), rules) flags a fits on a cut category with no exception, a fits over the remaining allowance (not on an essential need), a wait or skip on an essential category (a need is never waited out), and short waits.

Worked examples

Real replies, produced against the app's prompt (lists shortened here; the full replies ship in example-result.js). The page replays each of them free from its example buttons; the three temptations land on all three verdicts.

task: pact - thirty days for a trip

Input (decoded for reading; on the wire categories and facts are JSON strings):

{
  "task": "pact",
  "categories": [
    {
      "id": "clothes",
      "name": "Clothes & shoes",
      "monthly": 90,
      "decision": "cut",
      "cap": null,
      "essential": false
    },
    {
      "id": "takeout",
      "name": "Takeout & delivery",
      "monthly": 210,
      "decision": "limit",
      "cap": 60,
      "essential": false
    },
    {
      "id": "coffee",
      "name": "Coffee & snacks out",
      "monthly": 75,
      "decision": "limit",
      "cap": 20,
      "essential": false
    },
    {
      "...": "13 more categories"
    }
  ],
  "duration_days": 30,
  "start_on": "2026-11-01",
  "goal": "Two weeks in Lisbon next April - flights and the first week of rent there.",
  "why": "I keep saying I can't afford to travel, then my card statement is mostly delivery. I want the trip more than the convenience.",
  "triggers": "Ordering delivery when I get home late and tired. Scrolling sale emails at lunch. Coffee runs when work is boring.",
  "facts": {
    "monthly_now": 2858,
    "monthly_after": 2235,
    "monthly_save": 623,
    "projected_save": 614,
    "cut": 5,
    "limit": 4,
    "flags": []
  }
}

Reply:

{
  "task": "pact",
  "title": "30 Days to Lisbon",
  "pledge": "For 30 days starting November 1, I'm cutting clothes, dining out, home decor, tech and online shopping, and capping takeout, coffee, subscriptions and going out, so I can afford two weeks in Lisbon next April.",
  "rules": [
    {
      "kind": "hard",
      "category": "clothes",
      "rule": "No new clothes, shoes or bags for the full 30 days.",
      "cap": null,
      "why": "Sale emails are exactly the scroll you named - skipping new clothes keeps that browse from becoming a cart."
    },
    {
      "kind": "hard",
      "category": "dining",
      "rule": "No restaurants or bars for the full 30 days.",
      "cap": null,
      "why": "Eating out is easy to justify after a long day, and this is the cash the Lisbon flights need."
    },
    {
      "kind": "hard",
      "category": "home",
      "rule": "No home decor or gadget purchases for the full 30 days.",
      "cap": null,
      "why": "These are easy 'just one thing' buys that don't move you closer to Lisbon."
    },
    {
      "...": "8 more rules"
    }
  ],
  "triggers": [
    {
      "trigger": "Getting home late and tired and reaching for a delivery app",
      "bias": "decision fatigue",
      "counter": "Keep one 10-minute meal (eggs, frozen rice, canned beans) ready before you leave for work, so tired-you has an easy option that isn't the app."
    },
    {
      "trigger": "Scrolling sale emails at lunch",
      "bias": "scarcity",
      "counter": "Unsubscribe from sale lists now, or route them to a folder you check once a week instead of at your desk."
    },
    {
      "trigger": "Coffee runs when work is boring",
      "bias": "reward substitution",
      "counter": "When the boredom hits, take a 5-minute walk instead of a walk to the coffee shop - same break, no spend."
    },
    {
      "trigger": "Telling yourself you can't afford to travel while the card keeps paying for delivery",
      "bias": "mental accounting",
      "counter": "Before you pay for delivery, move that amount in your head into the Lisbon envelope - it's the same money, one pot."
    }
  ],
  "swaps": [
    {
      "instead_of": "Ordering takeout on an exhausted night",
      "do": "A 10-minute pantry meal prepped ahead and kept ready in the freezer."
    },
    {
      "instead_of": "A coffee-shop run to break up a boring afternoon",
      "do": "Make coffee where you are and take the walk instead - same break, no spend."
    }
  ],
  "checkins": {
    "cadence": "daily",
    "questions": [
      "Did I buy anything from a cut category today?",
      "Am I inside today's share of my takeout, coffee, subs or fun allowance?",
      "Did a trigger moment show up today, and what did I do instead?"
    ],
    "streak_rule": "A day counts as clean when you made no cut-category purchase and stayed inside your allowances; a slip ends that day's streak but not the challenge - log it and start the next day clean."
  },
  "slip_plan": [
    {
      "if": "You order delivery after a late, tired night",
      "then": "Log it as a slip, eat without guilt, and prep tomorrow's easy meal before you leave for work."
    },
    {
      "if": "You buy something from a sale email",
      "then": "Log the slip, check if it's returnable, and unsubscribe from that sender today."
    },
    {
      "if": "You go over a limit category's cap mid-month",
      "then": "Stop new spending in that category for the rest of the month - no borrowing from another category, no restarting the whole pact."
    }
  ],
  "warnings": []
}

task: tempted - inside the allowance (fits)

Input (decoded; pact and facts are JSON strings on the wire):

{
  "task": "tempted",
  "item": "Thai takeout on a late Friday",
  "price": 18,
  "item_category": "takeout",
  "reason": "Worked until 9, nothing in the fridge, and I am exhausted.",
  "wage": 24,
  "uses": 1,
  "pact": {
    "pledge": "For 30 days starting November 1, I'm cutting clothes, dining out, home decor, tech and online shopping, and capping takeout, coffee, subscriptions and going out, so I can afford two weeks in Lisbon next April.",
    "rules": [
      {
        "kind": "hard",
        "category": "clothes",
        "rule": "No new clothes, shoes or bags for the full 30 days.",
        "cap": null
      },
      {
        "kind": "hard",
        "category": "dining",
        "rule": "No restaurants or bars for the full 30 days.",
        "cap": null
      },
      {
        "...": "the rest of the pact's rules"
      }
    ],
    "duration_days": 30,
    "days_in": 2
  },
  "facts": {
    "status": "limit",
    "essential": false,
    "cap": 60,
    "spent_this_month": 25,
    "remaining": 35,
    "over_by": 0,
    "hours_of_work": 0.8,
    "cost_per_use": 18,
    "wait_until": "2026-11-06",
    "rule_hint": "fits"
  }
}

Reply:

{
  "task": "tempted",
  "verdict": "fits",
  "headline": "Your Thai takeout on this tired Friday fits inside the $60 takeout allowance you set.",
  "rule_hits": [
    {
      "rule": "Takeout up to $60 a month, used only on nights you're genuinely too wiped to cook.",
      "effect": "limits"
    }
  ],
  "bias": {
    "name": "decision fatigue",
    "explain": "After working until 9 with nothing in the fridge, you're running on empty decision-making energy, so the easiest option feels like the only option - which is exactly the kind of night this allowance was built for."
  },
  "questions": [
    "Is tonight truly a 'too wiped to cook' night, or just a long one?",
    "That's about 0.8 hours of your paycheck for one meal - still feel right?",
    "Would a quick pantry meal cover the same need tonight?"
  ],
  "wait_plan": {
    "hours": 0,
    "meanwhile": "Glance in the fridge and pantry for a quick backup option, then order if nothing works - no need to wait on this one."
  },
  "swap": "Whatever's already in the fridge or pantry - eggs, pasta, canned soup, frozen veggies - thrown together into a quick meal.",
  "if_you_buy": "Order it against your $60 takeout allowance and log it so you can see what's left for the rest of the month - this is exactly what the allowance is for, no justification needed."
}

task: tempted - a cut category on sale (skip)

Verdict, rules and bias only:

{
  "verdict": "skip",
  "headline": "Skip the air fryer - your pact already put home gadgets on the no-buy list, and a weekend sale isn't the broken-appliance exception.",
  "rule_hits": [
    {
      "rule": "No new home decor or gadgets.",
      "effect": "blocks"
    }
  ],
  "bias": {
    "name": "scarcity",
    "explain": "The sale ending Sunday is a deadline built to make you decide fast - scarcity bias - and the 'everyone says it changes how you cook' line adds social pressure on top, but neither means you actually need it right now."
  }
}

task: tempted - over the allowance (wait)

{
  "verdict": "wait",
  "headline": "The noise-cancelling headphones are $240 (about 5.7 hours of work, roughly $0.80 per use), but that blows well past your $50-a-month tech allowance, so this one waits.",
  "bias": {
    "name": "moral licensing",
    "explain": "Telling yourself you deserve it after a hard quarter is moral licensing - using recent effort as a pass to buy an upgrade your current, working headphones don't actually require."
  },
  "wait_plan": {
    "hours": 72,
    "meanwhile": "Keep using your current headphones, save the listing, and revisit it once the tech allowance resets on October 31 - if the pull is still there, it's a real want, not just quarter-end relief."
  }
}

Build the input yourself

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

const P = PactKit;
let deck = P.defaultDeck(true);                       // typical amounts - replace with your own
deck = P.decide(deck, "clothes", "cut");
deck = P.decide(deck, "takeout", "limit", 60);
const { input } = P.buildInput({ task: "pact", deck, days: 30, start: "2026-11-01", goal: "A spring trip" });
const wire = P.toWire(input);                         // POST this to /estimate and /run
// later: const pact = P.normalize(P.parseReply(job.output.output));
// P.reconcilePact(pact, deck)  -> notes
// then:  P.buildInput({ task: "tempted", deck, days: 30, pact, item: "Jacket", price: 90, category: "clothes", reason: "..." })

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://no-buy-pact.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":"no-buy-pact"}' | 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": "pact", "categories": "[...]", "duration_days": 30, "goal": "...", "facts": "{...}"}
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 no-buy-pact:<task>:<inputHash>:a<attempt>, 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="no-buy-pact:pact:$(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: no-buy-pact:pact:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json