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
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what 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)
| field | when | content |
|---|---|---|
task | required | map or apply |
asset_type | required | landing page, pricing page, email, checkout or signup, onboarding screen, ad, popup or modal, other. |
challenge | required | low conversions, price objections, building trust, increasing urgency, retention and churn, decision paralysis, onboarding, growth stalling. |
product | optional | What is sold, to whom - one line. |
copy | required | The 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. |
facts | required | A 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[]. |
question | optional | What you want to know; answered in summary. |
map_handoff | apply, optional | A 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_note | optional | Only 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://fair-or-dark.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"]
// fair-or-dark.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://fair-or-dark.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://fair-or-dark.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
}
// FairOrDark.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 FairOrDark {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://fair-or-dark.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://fair-or-dark.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://fair-or-dark.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://fair-or-dark.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":"fair-or-dark"}' | 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": "fair-or-dark"})["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: "fair-or-dark" })).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": "fair-or-dark"}, 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", "fair-or-dark"), 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: "fair-or-dark" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "fair-or-dark"])["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 = "fair-or-dark" }))!["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 (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}'
inp = json.load(open("input.json")) # {"task": "map", "asset_type": "...", "challenge": "...", "copy": "...", "facts": "...", ...}
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, asset_type, challenge, product, copy, facts, question, ...}
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 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}'
import hashlib, time
key = "fair-or-dark:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `fair-or-dark:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "fair-or-dark:" + 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 = "fair-or-dark:" + 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 = "fair-or-dark:#{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["headline"]].inspect
$key = "fair-or-dark:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"fair-or-dark:{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 (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
rev = json.load(open("apply.json")) # {"task": "apply", "asset_type": "...", "challenge": "...", "copy": "...", "facts": "...", "map_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "fair-or-dark:apply:" + hashlib.sha256(json.dumps(rev).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 rev = JSON.parse(readFileSync("apply.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `fair-or-dark:apply:${createHash("sha256").update(JSON.stringify(rev)).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("apply.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", "fair-or-dark:apply:"+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 rev = java.nio.file.Files.readString(java.nio.file.Path.of("apply.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "fair-or-dark:apply:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("apply.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "fair-or-dark:apply:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
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
$rev = file_get_contents("apply.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: fair-or-dark:apply:" . substr(hash("sha256", $rev), 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 rev = File.ReadAllText("apply.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"fair-or-dark:apply:{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);