← Fair or Dark / API
Tokens

Drive Fair or Dark from your own code

The page does its free work in the browser: it reads one piece of marketing copy (text or HTML, one block per line) and finds the psychological principles it pulls and the dark patterns it risks, every price's style and per-day equivalent, the Rule of 100 framing, the calls to action, the form fields and the skill's models for the chosen challenge. Its two paid lanes are available over HTTP. You send one JSON object with task set to "map" or "apply", and the model replies with one JSON object. The easiest way to build the facts string is the page itself: PsychKit.buildInput(lane, PsychKit.analyze(copy, {asset, challenge, product, question}), {handoff}) in psychkit.js, which also runs under Node.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform holds the estimate's hold_credits while the run is in flight and then charges only what it used (charged_credits). /estimate is free. A plain-text summary is at /llms.txt. The output reads the copy as pasted and recommends honest levers only.

The response envelope

Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.

Error codes

HTTPcodemeaning
400VALIDATION_ERRORThe body is not a JSON object, or a field is the wrong type. Every field of the run input is a string.
401UNAUTHORIZEDNo token, or an expired one: get a new one (step 2).
402INSUFFICIENT_CREDITSThe balance is below the run's minimum. Top up at skillsafe.ai/account/billing/.
403FORBIDDENA guest token tried a metered run: sign in for a personal token.
404NOT_FOUNDUnknown job id.
429RATE_LIMITEDBack off and retry with the same Idempotency-Key.

The task field: two lanes

taskwhat the model does
"map"Which principles the copy pulls, honestly or not: each principle with its lines, a word-for-word quote, use (working, weak, misused) and honesty (genuine, needs proof, manipulative); every page cue confirmed or disputed; the dark patterns with an honest fix each; one to five missing principles for the challenge; and the verdict the lists imply (honest and working, honest but underpowered, needs proof, crosses the line).
"apply"Three to eight paste-ready rewrites (R1..), each on one line with its exact old text, the principle it applies or the dark pattern it fixes, why and a guardrail; one to five A/B hypotheses (T1..) written If, then, because, with a metric and a guardrail metric; and the levers this copy should not pull. Proof the copy lacks is a [placeholder].

Input fields (all strings)

fieldwhencontent
taskrequiredmap or apply
asset_typerequiredlanding page, pricing page, email, checkout or signup, onboarding screen, ad, popup or modal, other.
challengerequiredlow conversions, price objections, building trust, increasing urgency, retention and churn, decision paralysis, onboarding, growth stalling.
productoptionalWhat is sold, to whom - one line.
copyrequiredThe copy as the page read it, one line each: L3: Pro - $49/month - Most popular. Up to 220 lines or 16,000 characters; past that the middle is dropped and a marker line says which lines.
factsrequiredA JSON string: asset, product, challenge, lines, cues[{id, principle, kind: principle|dark, severity, lines[], match}], prices[{line, text, style: charm|round|other|total, period, anchor}], per_day[{line, text, per_day}], rule_of_100[{line, text, base, better, suggest, why}], tiers, ctas[{line, text}], form_fields, recommended[], present[], absent[], notes[].
questionoptionalWhat you want to know; answered in summary.
map_handoffapply, optionalA JSON string of the map the rewrites follow: verdict, principles[{principle, lines[], use, honesty}], dark_patterns[{pattern, lines[], risk, fix}], missing[{principle, where, priority}].
retry_noteoptionalOnly on a reformat retry: what was wrong with the previous reply.

The reply contracts

Both lanes share an envelope: task, task_note, headline, questions[], summary.

task: "map"

verdict, principles[{principle, lines[], evidence, use, honesty, note}], cues[{cue, response: confirmed|disputed, note}] (one per page cue, in order), dark_patterns[{pattern, lines[], evidence, risk: high|medium, fix}], missing[{principle, why, where: Lx|new, priority: high|medium|low}] (1-5).

task: "apply"

source (map handoff|from the input), rewrites[{id, principle, fixes, line: Lx|new, before, after, why, guardrail}] (3-8, R1..), tests[{id, rewrite, hypothesis, metric, guardrail}] (1-5, T1..), avoid[{principle, why}] (0-4).

Worked examples

These are the page's own bundled examples (invented companies, people and numbers), with the input as app.js builds it and the saved model reply the page replays for free: a map of a SaaS pricing page and its rewrites after the handoff, a map of a meal-kit welcome email, and rewrites for a course checkout.

Map: a SaaS pricing page with a countdown, a shaming decline and a pre-ticked add-on

Input (the copy shown as its lines; facts shortened here):

{
 "task": "map",
 "asset_type": "pricing page",
 "product": "Tallyquill - bookkeeping software for freelancers and small agencies",
 "challenge": "price objections",
 "copy": [
  "L1: Tallyquill",
  "L2: Bookkeeping that does itself, so you can bill more hours.",
  "L3: Trusted by 12,000 freelancers and agencies",
  "L4: Offer ends in 09:59 left - lock in launch pricing",
  "L5: Starter - $19/month",
  "L6: 1 user, bank sync, invoices, receipts by email",
  "... (19 lines in all)"
 ],
 "facts": "(a JSON string; decoded: 19 lines, 14 cues - 10 principle, dark: NP-11 fake urgency, NP-12 preselection, NP-13 forced continuity, NP-14 confirmshaming; 7 prices, 3 tiers, 3 calls to action, 0 form fields; recommended for price objections: anchoring, framing effect, mental accounting, loss aversion)",
 "question": "Which of these tactics are hurting trust more than they help?"
}

Reply:

{
 "task": "map",
 "task_note": "",
 "verdict": "crosses the line",
 "principles": [
  {
   "principle": "social proof",
   "lines": [
    "L3",
    "L7",
    "L16"
   ],
   "evidence": "Trusted by 12,000 freelancers and agencies",
   "use": "working",
   "honesty": "needs proof",
   "note": "A specific user count, a most-popular badge and a named quote tell a hesitant buyer that people like them already pay. The count and the quote carry no source, so they need to be verifiable before they can be trusted."
  },
  {
   "principle": "urgency",
   "lines": [
    "L4"
   ],
   "evidence": "Offer ends in 09:59 left - lock in launch pricing",
   "use": "misused",
   "honesty": "needs proof",
   "note": "A short countdown pushes a quick decision, but on a monthly subscription a ten-minute timer reads as a sales trick unless it is tied to a real end date. It spends trust the price objection needs."
  },
  {
   "principle": "charm pricing",
   "lines": [
    "L5",
    "L7",
    "L9",
    "L18"
   ],
   "evidence": "Pro - $49/month - Most popular",
   "use": "working",
   "honesty": "genuine",
   "note": "Prices ending in 9 read as a step below the round number, which suits a value tool for freelancers. The top tier could use a round price if it is positioned as premium."
  },
  {
   "principle": "price relativity",
   "lines": [
    "L5",
    "L7",
    "L9"
   ],
   "evidence": "Pro - $49/month - Most popular",
   "use": "working",
   "honesty": "genuine",
   "note": "Three tiers with the target in the middle make Pro look reasonable next to Agency and fuller than Starter."
  },
  {
   "principle": "anchoring",
   "lines": [
    "L11"
   ],
   "evidence": "Was $588 now $468 a year - 20% off when you pay annually",
   "use": "working",
   "honesty": "genuine",
   "note": "The higher figure shown first sets the reference point, so the annual price feels like a saving. It is honest as long as the higher figure is what the reader would really pay month by month."
  },
  {
   "principle": "rule of 100",
   "lines": [
    "L11"
   ],
   "evidence": "Was $588 now $468 a year - 20% off when you pay annually",
   "use": "weak",
   "honesty": "genuine",
   "note": "On a base of 100 or more the amount off reads bigger than the percentage, so 20% off undersells the discount; facts suggest $120 off."
  },
  {
   "principle": "zero-price effect",
   "lines": [
    "L12"
   ],
   
... (shortened here; the page shows it whole)

Rewrites: the same pricing page, with the map handed over

Input (with the map handed over; copy shown as lines):

{
 "task": "apply",
 "asset_type": "pricing page",
 "product": "Tallyquill - bookkeeping software for freelancers and small agencies",
 "challenge": "price objections",
 "copy": [
  "L1: Tallyquill",
  "L2: Bookkeeping that does itself, so you can bill more hours.",
  "L3: Trusted by 12,000 freelancers and agencies",
  "L4: Offer ends in 09:59 left - lock in launch pricing",
  "L5: Starter - $19/month",
  "L6: 1 user, bank sync, invoices, receipts by email",
  "... (19 lines in all)"
 ],
 "facts": "(a JSON string; decoded: 19 lines, 14 cues - 10 principle, dark: NP-11 fake urgency, NP-12 preselection, NP-13 forced continuity, NP-14 confirmshaming; 7 prices, 3 tiers, 3 calls to action, 0 form fields; recommended for price objections: anchoring, framing effect, mental accounting, loss aversion)",
 "question": "Which of these tactics are hurting trust more than they help?",
 "map_handoff": "{\"verdict\":\"crosses the line\",\"principles\":[{\"principle\":\"social proof\",\"lines\":[\"L3\",\"L7\",\"L16\"],\"use\":\"working\",\"honesty\":\"needs proof\"},{\"principle\":\"urgency\",\"lines\":[\"L4\"],\"use\":\"misused\",\"honesty\":\"needs proof\"},{\"principle\":\"charm pricing\",\"lines\":[\"L5\",\"L7\",\"L9\",\"L18\"],\"use\":\"working\",\"honesty\":\"genuine\"},{\"pri..."
}

Reply:

{
 "task": "apply",
 "task_note": "",
 "source": "map handoff",
 "rewrites": [
  {
   "id": "R1",
   "principle": "default effect",
   "fixes": "preselection",
   "line": "L15",
   "before": "[x] Add Tallyquill Shield receipt backup +$9/month",
   "after": "[ ] Add Tallyquill Shield receipt backup +$9/month",
   "why": "Defaults carry a lot of weight, so a pre-ticked paid add-on gets bought by people who never chose it. Leaving the box unticked keeps the offer visible and makes every Shield sale a real decision.",
   "guardrail": "The box stays unticked by default and the $9/month price shows next to it and again in the checkout total."
  },
  {
   "id": "R2",
   "principle": "nudge theory",
   "fixes": "confirmshaming",
   "line": "L19",
   "before": "No thanks, I like doing my books at midnight",
   "after": "No thanks",
   "why": "A nudge should make the good option easy, not make the other option feel shameful. Mocking the reader who declines buys a few clicks at the cost of trust on a page that is asking for card details.",
   "guardrail": "The decline link stays as visible and as easy to click as the accept option."
  },
  {
   "id": "R3",
   "principle": "mental accounting",
   "fixes": "",
   "line": "L7",
   "before": "Pro - $49/month - Most popular",
   "after": "Pro - $49/month ($1.61 a day) - Most popular",
   "why": "People file a small daily amount under everyday spending rather than a big monthly bill, so the per-day figure softens the price objection without changing the price.",
   "guardrail": "The monthly price stays the main figure, and Most popular is only used if Pro really is the most chosen plan."
  },
  {
   "id": "R4",
   "principle": "rule of 100",
   "fixes": "",
   "line": "L11",
   "before": "Was $588 now $468 a year - 20% off when you pay annually",
   "after": "Was $588 now $468 a year - $120 off when you pay annually",
   "why": "On a price of 100 or more, the amount off reads bigger than the percentage, so the dollar figure makes the same annual discount feel larger.",
   "guardrail": "$588 must be the real price of twelve months on the monthly plan the offer refers to, and the plan must be named on the page."
  },
  {
   "id": "R5",
   "principle": "urgency",
   "fixes": "fake urgency",
   "line": "L4",
   "before": "Offer ends in 09:59 left - lock in launch pricing",
   "after": "Launch pricing ends [real end date] - lock 
... (shortened here; the page shows it whole)

Map: an honest meal-kit welcome email

Input (copy shown as lines):

{
 "task": "map",
 "asset_type": "email",
 "product": "Fennelby Box - weekly meal kits for two to four people, UK",
 "challenge": "onboarding",
 "copy": [
  "L1: Subject: Your first Fennelby Box ships Thursday",
  "L2: Hi Sam,",
  "L3: Welcome to Fennelby Box. Your first delivery of 3 recipes for 2 people ships on Thursday.",
  "L4: Here is what to do before then:",
  "L5: Pick your recipes - you can swap any of them until Tuesday at 11:59 pm.",
  "L6: Tell us about allergies so we never send you something you can't eat.",
  "... (14 lines in all)"
 ],
 "facts": "(a JSON string; decoded: 14 lines, 1 cues - 1 principle, dark: none; 0 prices, 0 tiers, 1 calls to action, 0 form fields; recommended for onboarding: goal-gradient effect, IKEA effect, commitment and consistency)"
}

Reply:

{
 "task": "map",
 "task_note": "",
 "verdict": "needs proof",
 "principles": [
  {
   "principle": "endowment effect",
   "lines": [
    "L1",
    "L3"
   ],
   "evidence": "Subject: Your first Fennelby Box ships Thursday",
   "use": "working",
   "honesty": "genuine",
   "note": "Calling it your first box with a ship day makes the order feel already owned, so finishing setup protects something the reader has rather than starting something new."
  },
  {
   "principle": "activation energy",
   "lines": [
    "L4",
    "L5",
    "L6",
    "L7",
    "L8"
   ],
   "evidence": "Here is what to do before then:",
   "use": "working",
   "honesty": "genuine",
   "note": "Setup is cut into three short, concrete tasks with a time estimate, which lowers the effort it feels like to start."
  },
  {
   "principle": "social proof",
   "lines": [
    "L8"
   ],
   "evidence": "Most new members finish setup in about 4 minutes.",
   "use": "weak",
   "honesty": "needs proof",
   "note": "What most new members do sets a norm to follow, but the claim is unsourced and the reader has no way to check it."
  },
  {
   "principle": "regret aversion",
   "lines": [
    "L10",
    "L14"
   ],
   "evidence": "If anything arrives damaged, reply to this email and we will refund that recipe.",
   "use": "working",
   "honesty": "genuine",
   "note": "A plain refund promise and the option to skip or pause take the fear of a bad first box away; the skip and pause line sits last in the email, where fewer readers will see it."
  },
  {
   "principle": "liking",
   "lines": [
    "L12"
   ],
   "evidence": "Questions? Just reply - a real person reads every email.",
   "use": "working",
   "honesty": "genuine",
   "note": "A human, reply-to-us tone makes the brand feel approachable, which makes new members more willing to act on its requests."
  }
 ],
 "cues": [
  {
   "cue": "NP-01",
   "response": "confirmed",
   "note": "L10 offers a real refund for damaged recipes, which removes the fear of a bad first order."
  }
 ],
 "dark_patterns": [],
 "missing": [
  {
   "principle": "goal-gradient effect",
   "why": "The three setup tasks are not numbered or shown as progress, so the reader gets no sense of moving toward done; people speed up as they see the finish line.",
   "where": "L4",
   "priority": "high"
  },
  {
   "principle": "IKEA effect",
   "why": "Picking recipes is presented as a c
... (shortened here; the page shows it whole)

Rewrites: a course checkout with pre-ticked add-ons and a surprise fee

Input (copy shown as lines):

{
 "task": "apply",
 "asset_type": "checkout or signup",
 "product": "Lensmoor - online photography courses",
 "challenge": "low conversions",
 "copy": [
  "L1: Checkout - Lensmoor Photography Course",
  "L2: Landscape Photography Masterclass - lifetime access",
  "L3: $129",
  "L4: [x] Add lifetime updates and new lessons +$29",
  "L5: [x] Add 1:1 portfolio review +$49",
  "L6: [ ] Add printable field guide +$9",
  "... (19 lines in all)"
 ],
 "facts": "(a JSON string; decoded: 19 lines, 5 cues - 3 principle, dark: NP-04 preselection, NP-05 hidden costs; 6 prices, 0 tiers, 4 calls to action, 5 form fields; recommended for low conversions: Hick's law, activation energy, BJ Fogg behavior model, nudge theory)",
 "question": "What should we change first?"
}

Reply:

{
 "task": "apply",
 "task_note": "",
 "source": "from the input",
 "rewrites": [
  {
   "id": "R1",
   "principle": "default effect",
   "fixes": "preselection",
   "line": "L4",
   "before": "[x] Add lifetime updates and new lessons +$29",
   "after": "[ ] Add lifetime updates and new lessons +$29",
   "why": "Defaults are powerful because most people accept them, so a pre-ticked paid add-on charges buyers for something they did not choose. An unticked box keeps the offer visible while the buyer opts in.",
   "guardrail": "The box ships unticked and the subtotal only includes the add-on after the buyer ticks it."
  },
  {
   "id": "R2",
   "principle": "default effect",
   "fixes": "preselection",
   "line": "L5",
   "before": "[x] Add 1:1 portfolio review +$49",
   "after": "[ ] Add 1:1 portfolio review +$49",
   "why": "The same default effect: a pre-ticked $49 extra inflates the total the buyer sees at the moment of payment and invites refunds and chargebacks.",
   "guardrail": "The box ships unticked and nothing is added to the subtotal without a click."
  },
  {
   "id": "R3",
   "principle": "nudge theory",
   "fixes": "hidden costs",
   "line": "L8",
   "before": "+ $4.50 processing fee",
   "after": "Processing fee: $4.50, included in [total including fee] below",
   "why": "A fee that appears under the subtotal feels like a surprise and triggers distrust at the last step. Showing it plainly as part of the total is an honest nudge that removes the reason to abandon.",
   "guardrail": "The fee is shown next to the price from the course page onward and the final total includes it before the buyer clicks to pay."
  },
  {
   "id": "R4",
   "principle": "regret aversion",
   "fixes": "",
   "line": "L9",
   "before": "Only 7 seats left at this price",
   "after": "Not for you? [real refund window] money-back guarantee, no questions asked.",
   "why": "An online course with lifetime access has no obvious seat limit, so the claim reads as pressure. Buyers who fear regretting a purchase convert more readily when the downside is removed.",
   "guardrail": "The refund policy is real, written down and honoured; the seat claim is only restored if a true limit exists."
  },
  {
   "id": "R5",
   "principle": "activation energy",
   "fixes": "",
   "line": "L13",
   "before": "Company name",
   "after": "",
   "why": "Every extra field adds effort at the point 
... (shortened here; the page shows it whole)

The page re-checks the reply; the API does not

The page holds every reply to account (recon.js). For a map: every principle one of the skill's models, listed once, with real lines and a word-for-word quote; one answer per page cue, in order, and every confirmed cue present in the principles or the dark patterns; dark patterns named from the list, quoted, each with a fix; one to five missing principles, none already working, at least one of the skill's models for the challenge; the verdict the lists imply. For rewrites: the source as sent; 3 to 8 rewrites numbered R1.. on real lines, each carrying that line's exact text; every high dark pattern fixed on its own line; no rewrite the page's own detector reads as shaming, pre-ticked, hidden or obstructive; tests numbered T1.. on real rewrites, written If, then, because. In both, every line and cue id must be the page's, and no figure the input or the page's reading does not contain may appear - proof the copy lacks must be a [placeholder]. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same input into the page.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://fair-or-dark.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":"fair-or-dark"}' | 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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.

# input.json: the run input object - {"task": "map", "asset_type": "...", "challenge": "...", "copy": "...", "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 fair-or-dark:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="fair-or-dark:map:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream (apply)

The same run as Server-Sent Events. apply.json is a rewrites input, with map_handoff when it follows a map.

# 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: fair-or-dark:apply:$(shasum -a 256 apply.json | cut -c1-16):a1" --data @apply.json