Drive Paywall Desk from your own code
The page does its free work in the browser: it reads the copy of an in-app paywall or upgrade screen (buttons in
[brackets]) and its context, names the trigger point, finds which of the seven paywall components are on the screen,
classifies every button, checks a stated annual saving against the prices, computes the funnel rates and raises flags
PW-01..PW-20. Its two paid lanes are available over HTTP. You send one JSON object with task set to
"audit" or "redesign", and the model replies with one JSON object. The easiest way to build
the facts string is the page itself:
Paykit.buildInput(lane, Paykit.analyze(screen, {context, trigger, question}), {auditHandoff}) in
paykit.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 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 |
|---|---|
"audit" | The paywall audit: whether it is in scope (an in-app moment, not a public pricing page); the trigger point; the moment (what the user feels, whether value came before the ask); the seven components (headline, value_demo, comparison, pricing, social_proof, cta, escape_hatch) each strong, weak or missing with a fix; the issues (dark patterns, conversion killers, timing, frequency, pricing, copy) with the page's flag ids; the frequency verdict and rules; the funnel read and its leak; 3 to 6 fixes in order; a verdict ship, fix_first, rebuild or out_of_scope. |
"redesign" | The screen rewritten for its trigger: headline, subhead, value demonstration, a free-versus-paid comparison, the price line from your prices, social proof (or a placeholder), a call to action that names the benefit and a neutral way out; 2 to 4 variants; when, never-when, per-session cap and a cool-down in days; trial reminders at 7, 3 and 1 days for a trial; what happens after the upgrade; 2 to 4 tests with their metrics. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | audit or redesign |
screen | required | The screen's copy, verbatim, buttons in [brackets]. Past 6,000 characters the page cuts the middle on line boundaries and leaves a [paywall-desk: ...] marker. |
facts | required | A JSON string: summary, trigger (type, basis, evidence), components (found, evidence, note for each of the seven), found, buttons (text, kind cta|escape|other, generic, shaming), cta, escape, prices (amount, currency, period, billed_annually, raw, source), plans, annual (basis, monthly, annual_total, annual_per_month, twelve_months, saving_amount, saving_pct, stated_pct, matches), funnel (impressions, clicks, upgrades, free_users, stated_rate, ctr, completion, overall, free_to_paid, reach, biggest_drop), scope, flags, notes. It is Paykit.factsOf(A). |
context | optional | The product, what is free and paid, prices, what triggers the screen, when and how often, numbers. Cut past 4,000 characters. |
trigger | optional | feature_gate, usage_limit, trial_expiration, time_based or tier_upgrade, when the user chose it. |
question | optional | What the user wants to know. |
audit_handoff | redesign only | A JSON string of an audit's trigger, verdict, missing[], weak[], issues[{flag, issue, fix}] and fixes[], for the same input. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, headline, questions[], summary.
task: "audit"
scope{in_scope, note}, trigger{type: feature_gate|usage_limit|trial_expiration|time_based|tier_upgrade|unclear, why, evidence[]}, moment{user_state, value_before_ask: yes|no|unclear, why}, components[{component, status: strong|weak|missing, finding, evidence[], fix}] (exactly seven, in order), issues[{flag, kind: dark_pattern|conversion_killer|timing|frequency|pricing|copy, severity: high|medium|low, issue, evidence[], fix}], frequency{verdict: ok|too_often|unknown, rules[]}, numbers{read, leak: impression_to_click|click_to_upgrade|unknown}, fixes[] (3 to 6), verdict (ship|fix_first|rebuild|out_of_scope).
task: "redesign"
source (audit handoff|from the input), trigger, screen{headline, subhead, value_demo, comparison[{feature, free, paid}], pricing, social_proof, cta, escape_hatch, note}, variants[{element: headline|cta|value_demo|pricing, text, angle: benefit|loss|social|usage}] (2 to 4), rules{show_when[], never_when[], per_session, cooldown}, sequence[{when, channel: email|in-app|push, message}] (trial expiration only), after_upgrade[], tests[{test, hypothesis, metric, variant}] (2 to 4), metrics[].
Worked examples
These are the page's own bundled examples (invented products and numbers), with the input as app.js builds it and the saved model reply the page replays for free.
Audit: a project-limit wall with a lone "Upgrade" button
Input (screen and facts shortened here):
{
"task": "audit",
"screen": "You've reached your project limit\n\nFree accounts can have 3 projects.\n\n[Upgrade]\n",
"facts": "(a JSON string; decoded: trigger usage_limit (read from the context), 2 of 7 components - missing: value_demo, comparison, pricing, social_proof, escape_hatch, cta \"Upgrade\", escape null, prices $12/month, $120/year, $24/month, annual saving 16.7%, funnel ctr 6.47%, completion 13.14%, flags PW-01, PW-03, PW-04, PW-05, PW-06, PW-09, PW-07, PW-08)",
"context": "Tasquarrow - project management for small agencies. Free plan: 3 projects, 2 members, no time tracking. Pro: $12/month per seat, or $120/year per seat. Team: $24/month per seat.\nThe modal appears when a free user clicks \"New project\" with 3 projects already open. It blocks the new-project form; the only button is Upgrade, and the user has to press Escape or reload to get back to their board.\nLast 30 days: 4,820 paywa... (571 characters in all)",
"question": "Why do so few people upgrade at the 4th project?"
}
Reply:
{
"task": "audit",
"scope": {
"in_scope": true,
"note": ""
},
"trigger": {
"type": "usage_limit",
"why": "The modal fires when a free user clicks New project with 3 projects already open, the Free plan's project cap.",
"evidence": [
"The modal appears when a free user clicks \"New project\" with 3 projects already open",
"Free accounts can have 3 projects."
]
},
"moment": {
"user_state": "The user is mid-task, starting a new project for their agency, usually right after inviting a second teammate. The modal stops them cold, so they are frustrated and their attention is on the work, not on plans.",
"value_before_ask": "yes",
"why": "The wall is hit after real use: three projects already open and a teammate invited, typically in week 2."
},
"components": [
{
"component": "headline",
"status": "weak",
"finding": "The headline only states the limit and says nothing about what Pro would let the team do next.",
"evidence": [
"You've reached your project limit"
],
"fix": "Lead with the benefit, for example \"Keep every client project in one place with Pro\"."
},
{
"component": "value_demo",
"status": "missing",
"finding": "Nothing shows what the user has built or what Pro adds, such as more projects, more members or time tracking.",
"evidence": [],
"fix": "Show the user's three current projects and an empty slot for the fourth they were about to create."
},
{
"component": "comparison",
"status": "missing",
"finding": "The user cannot see what Free and Pro differ on, even though the context names the three limits.",
"evidence": [],
"fix": "Add a short Free versus Pro table: projects, members and time tracking."
},
{
"component": "pricing",
"status": "missing",
"finding": "
... (shortened here; the page shows it whole)
Redesign: the same wall, carrying the audit
Input (with the audit handed over):
{
"task": "redesign",
"screen": "You've reached your project limit\n\nFree accounts can have 3 projects.\n\n[Upgrade]\n",
"facts": "(a JSON string; decoded: trigger usage_limit (read from the context), 2 of 7 components - missing: value_demo, comparison, pricing, social_proof, escape_hatch, cta \"Upgrade\", escape null, prices $12/month, $120/year, $24/month, annual saving 16.7%, funnel ctr 6.47%, completion 13.14%, flags PW-01, PW-03, PW-04, PW-05, PW-06, PW-09, PW-07, PW-08)",
"context": "Tasquarrow - project management for small agencies. Free plan: 3 projects, 2 members, no time tracking. Pro: $12/month per seat, or $120/year per seat. Team: $24/month per seat.\nThe modal appears when a free user clicks \"New project\" with 3 projects already open. It blocks the new-project form; the only button is Upgrade, and the user has to press Escape or reload to get back to their board.\nLast 30 days: 4,820 paywa... (571 characters in all)",
"question": "Why do so few people upgrade at the 4th project?",
"audit_handoff": "{\"trigger\":\"usage_limit\",\"verdict\":\"rebuild\",\"missing\":[\"value_demo\",\"comparison\",\"pricing\",\"social_proof\",\"escape_hatch\"],\"weak\":[\"headline\",\"cta\"],\"issues\":[{\"flag\":\"PW-01\",\"issue\":\"The modal has no escape hatch, so it works as a hard block on the free plan.\",\"fix\":\"Add a \\\"Not now\\\" button that closes the modal and ..."
}
Reply:
{
"task": "redesign",
"source": "audit handoff",
"trigger": "usage_limit",
"screen": {
"headline": "Keep every client project on one board with Pro",
"subhead": "Your Free plan holds 3 projects; Pro lets your team open the next one now, without closing anything.",
"value_demo": "Show the user's own board with their 3 projects plus a greyed-in 4th card titled with what they were creating, and a time tracking column switched on: \"This is your board on Pro.\"",
"comparison": [
{
"feature": "Projects",
"free": "3 projects",
"paid": "[Pro project limit]"
},
{
"feature": "Members",
"free": "2 members",
"paid": "[Pro member limit]"
},
{
"feature": "Time tracking",
"free": "Not included",
"paid": "[Pro time tracking]"
}
],
"pricing": "Pro: $12/month per seat, or $120/year per seat (save $24 a seat, 16.7%).",
"social_proof": "[customer quote from an agency on Pro] or \"[number of agencies on Pro] agencies run their client work on Tasquarrow Pro\"",
"cta": "Create my 4th project with Pro",
"escape_hatch": "Not now, back to my board",
"note": "Prefer to stay on Free? Archive a project to make room, and nothing you have built is lost."
},
"variants": [
{
"element": "headline",
"text": "Your team is growing. Give every client its own project.",
"angle": "usage"
},
{
"element": "cta",
"text": "Unlock more projects for my team",
"angle": "benefit"
},
{
"element": "value_demo",
"text": "Show the user's 3 project names with a line: \"Your 4th project is one step away; don't squeeze it into an old one.\"",
"angle": "loss"
},
{
"element": "pricing",
"text": "Pro from $10 a seat per month, billed $120/year per seat.",
"angle": "benefit"
}
],
"rules": {
"s
... (shortened here; the page shows it whole)
Audit: a trial-expired screen converting at 2%
Input:
{
"task": "audit",
"screen": "Your trial has expired\n\nYour 14-day free trial of Quillorn Pro has ended. To keep using Quillorn, choose a plan.\n\nMonthly - $15/month\nAnnual - $144/year (save 25%)\n\n[Choose a plan]\n[Log out]\n",
"facts": "(a JSON string; decoded: trigger trial_expiration (chosen on the page), 3 of 7 components - missing: value_demo, comparison, social_proof, escape_hatch, cta \"Choose a plan\", escape null, prices $15/month, $144/year, annual saving 20%, funnel read, flags PW-01, PW-04, PW-05, PW-06, PW-09, PW-15, PW-18, PW-19, PW-08)",
"context": "Quillorn - a writing app for newsletter writers. 14-day free trial of Pro, no card required. After the trial, the account drops to Free (3 drafts, no scheduling, no custom domain).\nUsers see this full-screen page the first time they open the app after day 14. There is no email or in-app reminder before the trial ends.\nTrial users who reach day 14: 2,300 a month. Upgrade rate from this screen is 2%.\nTrial users write ... (480 characters in all)",
"trigger": "trial_expiration"
}
Reply:
{
"task": "audit",
"scope": {
"in_scope": true,
"note": ""
},
"trigger": {
"type": "trial_expiration",
"why": "The trigger was chosen on the page, and the screen fires the first time the user opens the app after the 14-day Pro trial ends.",
"evidence": [
"Your 14-day free trial of Quillorn Pro has ended",
"the first time they open the app after day 14"
]
},
"moment": {
"user_state": "A newsletter writer opens Quillorn to keep working on drafts and is stopped by a full-screen wall with no warning, so they are surprised and mid-task.",
"value_before_ask": "yes",
"why": "The ask comes after a full trial in which users write on average 9 drafts and schedule 4 issues, so value came first; the problem is the surprise, not the timing."
},
"components": [
{
"component": "headline",
"status": "weak",
"finding": "The headline states the lock, not what the writer keeps by upgrading.",
"evidence": [
"Your trial has expired"
],
"fix": "Lead with the benefit, such as \"Keep scheduling your newsletter with Quillorn Pro\"."
},
{
"component": "value_demo",
"status": "missing",
"finding": "Nothing shows the drafts and scheduled issues the writer created during the trial.",
"evidence": [],
"fix": "Show the user's own trial usage: [drafts written] drafts and [issues scheduled] scheduled issues, plus what is affected on Free."
},
{
"component": "comparison",
"status": "missing",
"finding": "The screen never says the account drops to Free or what Free keeps, so it reads as a full lockout.",
"evidence": [],
"fix": "Add a short Free versus Pro table: 3 drafts, no scheduling and no custom domain on Free against Pro."
},
{
"component": "pricing",
"status": "weak",
"finding": "Both prices ar
... (shortened here; the page shows it whole)
Audit: a fitness paywall with guilt-trip copy and a restarting timer
Input:
{
"task": "audit",
"screen": "[Hero image: athlete mid-sprint]\nUnlock your best body with Stridevane Premium\n\nPersonalized plans, advanced stats, and 400+ guided workouts.\nJoin 2 million athletes\n\nOFFER ENDS IN 09:59\n$59.99/year - just $4.99/month (save 70%)\n$9.99/month\n\n[Start my 7-day free trial]\nNo thanks, I don't want to get fit\n",
"facts": "(a JSON string; decoded: trigger unclear (not read), 6 of 7 components - missing: comparison, cta \"Start my 7-day free trial\", escape \"No thanks, I don't want to get fit\", prices $59.99/year, $4.99/month, $9.99/month, annual saving 50%, funnel none, flags PW-02, PW-03, PW-10, PW-11, PW-09, PW-15, PW-16)",
"context": "Stridevane - a mobile fitness app (iOS and Android). The paywall shows on the first launch, straight after the onboarding quiz, before the user has done a workout.\nThe close X appears after 5 seconds in the top corner, in light grey.\nIt shows again every app open until they subscribe or close it 3 times.\nThe countdown restarts at 10 minutes every time the paywall opens.\n",
"question": "Is this paywall hurting us?"
}
Reply:
{
"task": "audit",
"scope": {
"in_scope": true,
"note": ""
},
"trigger": {
"type": "time_based",
"why": "The page's scan said unclear, but the context says it fires on a moment in the app's lifecycle (first launch, after the onboarding quiz, then every app open), not at a feature gate, a usage limit or a trial end, so time-based is the closest type.",
"evidence": [
"The paywall shows on the first launch, straight after the onboarding quiz",
"It shows again every app open until they subscribe or close it 3 times."
]
},
"moment": {
"user_state": "A brand-new user has just answered the onboarding quiz and expects to see their plan or start a first workout; instead they hit a full-screen ask before Stridevane has shown them anything.",
"value_before_ask": "no",
"why": "PW-10: the context says it shows before the user has done a workout, so there is no aha moment yet to build the ask on."
},
"components": [
{
"component": "headline",
"status": "strong",
"finding": "It leads with what the user gets and names the plan, Stridevane Premium.",
"evidence": [
"Unlock your best body with Stridevane Premium"
],
"fix": ""
},
{
"component": "value_demo",
"status": "weak",
"finding": "A stock hero image of an athlete tells rather than shows; the user sees nothing of their own plan or stats.",
"evidence": [
"[Hero image: athlete mid-sprint]"
],
"fix": "Replace the hero with a preview of the personalized plan built from the user's quiz answers, with the Premium parts visibly locked."
},
{
"component": "comparison",
"status": "missing",
"finding": "Nothing says what the free app keeps and what only Premium adds, so the user cannot tell what they lose by saying no.",
"evidence": [],
"fix": "
... (shortened here; the page shows it whole)
Redesign: a feature gate for Advanced Analytics
Input:
{
"task": "redesign",
"screen": "Advanced Analytics is a Pro feature\n\n[Lock icon]\n\nUpgrade to Pro to use Advanced Analytics.\n\n[Upgrade to Pro - $29/mo]\n[Close]\n",
"facts": "(a JSON string; decoded: trigger feature_gate (chosen on the page), 4 of 7 components - missing: value_demo, comparison, social_proof, cta \"Upgrade to Pro - $29/mo\", escape \"Close\", prices $29/mo, $29/month, $290/year, annual saving 16.7%, funnel none, flags PW-06, PW-09, PW-08)",
"context": "Tallymorrow - a dashboard tool for Shopify stores. Free: the overview dashboard, 30 days of history, 1 store. Pro ($29/month or $290/year): Advanced Analytics (cohort retention, LTV by channel, product affinity), 2 years of history, 5 stores, scheduled email reports.\nThe gate opens when a free user clicks the \"Advanced Analytics\" tab in the sidebar.\nAbout 1,400 stores on Pro. Pro users open Advanced Analytics on 68% ... (440 characters in all)",
"trigger": "feature_gate"
}
Reply:
{
"task": "redesign",
"source": "from the input",
"trigger": "feature_gate",
"screen": {
"headline": "See which channels bring your most valuable customers",
"subhead": "Advanced Analytics adds cohort retention, LTV by channel and product affinity to your Tallymorrow dashboard.",
"value_demo": "Show a blurred preview of the LTV by channel and cohort retention charts built from this store's own last 30 days, with the caption: \"Your store's channels, ranked by lifetime value. Unlock to see the full numbers.\"",
"comparison": [
{
"feature": "Advanced Analytics (cohort retention, LTV by channel, product affinity)",
"free": "Not included",
"paid": "Included"
},
{
"feature": "History",
"free": "30 days",
"paid": "2 years"
},
{
"feature": "Stores",
"free": "1 store",
"paid": "5 stores"
},
{
"feature": "Scheduled email reports",
"free": "Not included",
"paid": "Included"
}
],
"pricing": "Pro: $29/month, or $290/year billed annually.",
"social_proof": "About 1,400 stores on Pro. Pro users open Advanced Analytics on 68% of weekly sessions.",
"cta": "Start seeing LTV by channel",
"escape_hatch": "Continue with Free",
"note": "Your overview dashboard stays free. [cancellation terms]"
},
"variants": [
{
"element": "headline",
"text": "Find out which customers come back and why",
"angle": "benefit"
},
{
"element": "headline",
"text": "Join about 1,400 stores on Pro",
"angle": "social"
},
{
"element": "cta",
"text": "Unlock Advanced Analytics for $29/month",
"angle": "benefit"
},
{
"element": "pricing",
"text": "$290/year for Pro, or $29/month",
"angle": "benefit"
}
],
"rules": {
"show_when": [
"A free user clicks the \"Advanced
... (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 an audit, the trigger you chose kept, seven components in order, none called present that the page found missing without a quote and none called strong that the page faults, every high-severity flag carried and never downgraded, value before ask never "yes" when the page flagged an early prompt, frequency never "ok" when flagged, the funnel leak equal to the page's, no "ship" while a high flag stands, and a public page left unoptimised. For a redesign, every screen element written, a benefit headline, a call to action that is not generic, a neutral way out, comparison rows from your features, only your prices and only the real saving, a cool-down in days, reminders at 7, 3 and 1 days for a trial, and tests with metrics. In both, quotes are checked word for word and no price, count, rate, duration or social-proof number may be invented. 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://paywall-desk.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://paywall-desk.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"]
// paywall-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://paywall-desk.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://paywall-desk.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
}
// PaywallDesk.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 PaywallDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://paywall-desk.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://paywall-desk.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://paywall-desk.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://paywall-desk.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":"paywall-desk"}' | 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": "paywall-desk"})["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: "paywall-desk" })).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": "paywall-desk"}, 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", "paywall-desk"), 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: "paywall-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "paywall-desk"])["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 = "paywall-desk" }))!["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": "audit", "screen": "...", "context": "...", "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": "audit", "screen": "...", "context": "...", "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, screen, context, trigger, question, facts, ...}
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 paywall-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="paywall-desk:audit:$(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 = "paywall-desk:" + 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("verdict"))
import { createHash } from "node:crypto";
const key = `paywall-desk:${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.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "paywall-desk:" + 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 = "paywall-desk:" + 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 = "paywall-desk:#{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["verdict"]].inspect
$key = "paywall-desk:" . $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["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"paywall-desk:{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 (redesign)
The same run as Server-Sent Events. redesign.json is a redesign input, with audit_handoff when it follows an audit.
# 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: paywall-desk:redesign:$(shasum -a 256 redesign.json | cut -c1-16):a1" --data @redesign.json
rev = json.load(open("redesign.json")) # {"task": "redesign", "screen": "...", "facts": "...", "audit_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": "paywall-desk:redesign:" + 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("redesign.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": `paywall-desk:redesign:${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("redesign.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", "paywall-desk:redesign:"+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("redesign.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", "paywall-desk:redesign:" + 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("redesign.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "paywall-desk:redesign:#{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("redesign.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: paywall-desk:redesign:" . 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("redesign.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", $"paywall-desk:redesign:{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);