Drive Churn Desk from your own code
The page does its free work in the browser: it reads a churn picture (customers, cancellations, MRR, how many were
failed payments, failed payments and recoveries, cancel-flow sessions and saves, cancel reasons with counts, the
cancel flow today, how failed payments are handled) and the plans and prices, computes churn, the involuntary share,
recovery and save rates against the skill's benchmarks, maps cancel reasons to the eight exit-survey categories,
checks the five cancel-flow stages and six dunning steps, computes what a 20-30% discount saves on each plan and
raises flags CH-01..CH-23. Its three paid lanes are available over HTTP. You send one JSON object with
task set to "audit", "flow" or "dunning", and the model replies
with one JSON object. The easiest way to build the facts string is the page itself:
Churnkit.buildInput(lane, Churnkit.analyze(stack, {plans, business}), {auditHandoff}) in
churnkit.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>. All three 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: three lanes
| task | what the model does |
|---|---|
"audit" | Where subscribers leak: the churn picture restated; the leak (voluntary, involuntary, both or unclear) from the page's split; each rate the page computed read against the skill's benchmarks; the five cancel-flow stages (trigger, survey, offer, confirm, post) and six dunning steps (predunning, retry, declines, emails, grace, updater) graded in place, weak, missing or unknown with quotes; the cancel reasons getting the wrong offer; the risks (dark patterns, guilt copy, deep or blanket discounts, blame, cancel on a failed payment) with severity; 3 to 6 fixes in order, each naming the lane that builds it; and what is missing. |
"flow" | The cancel flow: one survey question with 5 to 8 reasons in your order ending with other; one reason-matched offer and fallback per reason with the dollar amount from your prices; the survey, offer, confirmation and after-cancel screens with a visible way to keep cancelling; high-value routing; guardrails; what to measure; one A/B test. |
"dunning" | The failed-payment recovery: pre-dunning (card-expiry reminders, the card updater, a backup method); retry plans for soft, hard and authentication declines (hard declines never retried); 3 to 5 plain-text emails from day 0 with a direct update link and no blame; the grace period and what follows; setup steps for your billing provider; recovery targets. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | audit, flow or dunning |
stack | required | Your churn picture, verbatim. Past 9,000 characters the page cuts the middle on a line boundary and leaves a [churn-desk: ...] marker. |
facts | required | A JSON string: summary, business, provider, numbers (customers, churned, voluntary, involuntary, churn_rate, involuntary_share, mrr, arpu, failed, recovered, recovery_rate, sessions, saved, save_rate, acceptance_rate, reactivation_rate, mrr_lost, revenue_churn, unrecovered, involuntary_mrr_lost - only those it could read), basis, bands (good|on target|below target|poor per rate), reasons (rows with category and share, by_category), stages and dunning (id, status: found|said missing|unclear|not found, evidence), retry_count, email_count, offers, plans, savings (discounts, downgrades, annual), flags, notes. It is Churnkit.factsOf(A). |
plans | optional | Plans and prices, pause or downgrade availability, tone, your discount cap. Cut in the middle past 4,000 characters. |
business | optional | b2c, b2b or freemium. |
audit_handoff | flow and dunning only | A JSON string of an audit's leak, fixes[{fix, lane}], mismatches[{reason, problem}] and risks[{risk, severity}], for the same notes. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
All lanes share an envelope: task, headline, questions[], summary. Reason ids everywhere: price, usage, feature, competitor, technical, temporary, closed, other.
task: "audit"
picture{numbers, flow_today, dunning_today}, leak (voluntary|involuntary|both|unclear), leak_why, metrics[{metric: churn|recovery|save|acceptance|reactivation, value, benchmark, read: good|on target|below target|poor|unknown}], stages[{stage: trigger|survey|offer|confirm|post, status: in place|weak|missing|unknown, finding, evidence[]}] (five), mismatches[{reason, offer_today, problem, evidence[]}], risks[{risk, severity: high|medium|low, evidence[]}], dunning[{step: predunning|retry|declines|emails|grace|updater, status, finding, evidence[]}] (six), fixes[{fix, lane: flow|dunning|none, why, moves}] (3 to 6), missing[{item, why, get}].
task: "flow"
source (audit handoff|from the input), business, survey{question, reasons[{id, label}]} (5 to 8), offers[{reason, primary, fallback, savings}], screens{survey, offer, confirm, post_cancel}, routing, guardrails[], measure[{metric, target}], test{variable, hypothesis, metric}.
task: "dunning"
source, provider (stripe|chargebee|paddle|recurly|braintree|other|unknown), predunning[{when, action}], retries[{decline: soft|hard|authentication, examples, plan}] (three), emails[{day, tone, subject, body}] (3 to 5), grace{days, then}, setup[], targets[{metric, target}].
Worked examples
These are the page's own bundled examples (invented companies and numbers), with the input as app.js builds it and the saved model reply the page replays for free.
Audit: a budgeting app with one 50% offer for everyone
Input (notes and facts shortened here):
{
"task": "audit",
"stack": "Centwhistle - a personal budgeting app (B2C, iOS, Android and web). Billing through Stripe, monthly and annual.\n\nSeptember numbers:\nActive subscribers at the start of September: 8,400\nCancelled in September: 546\nMRR: $92,400\nOf the 546, 151 were failed payments that never recovered; the other 395 cancelled themselves.\nFailed payments in September: 380, recovered: 229.\n\nCancel flow today:\nCancel button in Settings > B... (1114 characters in all)",
"facts": "(a JSON string; decoded: business b2c, provider stripe, churn_rate 6.5, involuntary_share 27.66, recovery_rate 60.26, save_rate 10.49, bands {\"churn\": \"below target\", \"recovery\": \"good\", \"save\": \"below target\"}, reasons price 28.77%, usage 34.91%, competitor 10.85%, feature 14.62%, technical 5.66%, temporary 3.77%, other 1.42%, stages trigger found; survey said missing; offer found; confirm found; post not found, dunning predunning not found; retry found; declines not found; emails found; grace not found; updater not found, flags CH-01, CH-03, CH-04, CH-07, CH-09, CH-13, CH-16, CH-17, CH-21)",
"plans": "Plus: $12/month, or $96/year on annual.\nThere is a free tier (one bank account, no goals).\nPause is not built yet, but we can build a 1-3 month pause.\nTone: friendly and direct. We will not offer more than 30% off.",
"business": "b2c"
}
Reply:
{
"task": "audit",
"picture": {
"numbers": "8,400 subscribers at the start of September, 546 cancelled (395 cancelled themselves, 151 were failed payments that never recovered), MRR $92,400; 380 failed payments, 229 recovered; 610 cancel flow sessions, 64 saved.",
"flow_today": "Cancel button in Settings > Billing leads to one screen with guilt copy, the same 50% off for 6 months offer for everyone, then immediate cancellation with access ending that day; no exit survey.",
"dunning_today": "Stripe Smart Retries are on and Stripe sends its default failed-payment email; nothing else described."
},
"leak": "voluntary",
"leak_why": "The page's arithmetic puts involuntary churn at 27.66% of the 546 cancels, so 395 of them were voluntary. Failed-payment recovery is already 60.26%, while the cancel flow saves only 10.49% of sessions.",
"metrics": [
{
"metric": "churn",
"value": "6.5%",
"benchmark": "under 5% for B2C",
"read": "below target"
},
{
"metric": "recovery",
"value": "60.26%",
"benchmark": "dunning recovery 50-60%; good is 60%+",
"read": "good"
},
{
"metric": "save",
"value": "10.49%",
"benchmark": "save rate 25-35%",
"read": "below target"
}
],
"stages": [
{
"stage": "trigger",
"status": "in place",
"finding": "There is a self-serve cancel button in the app's billing settings.",
"evidence": [
"Cancel button in Settings > Billing."
]
},
{
"stage": "survey",
"status": "missing",
"finding": "No exit survey exists; the only screen is guilt copy instead of a help-us-improve question.",
"evidence": [
"We have no exit survey.",
"Are you sure you want to abandon your budget?"
]
},
{
"stage": "offer",
"status": "weak",
"finding": "One 50% off for 6 months
... (shortened here; the page shows it whole)
Cancel flow: the same app, carrying the audit
Input (with the audit handed over):
{
"task": "flow",
"stack": "Centwhistle - a personal budgeting app (B2C, iOS, Android and web). Billing through Stripe, monthly and annual.\n\nSeptember numbers:\nActive subscribers at the start of September: 8,400\nCancelled in September: 546\nMRR: $92,400\nOf the 546, 151 were failed payments that never recovered; the other 395 cancelled themselves.\nFailed payments in September: 380, recovered: 229.\n\nCancel flow today:\nCancel button in Settings > B... (1114 characters in all)",
"facts": "(a JSON string; decoded: business b2c, provider stripe, churn_rate 6.5, involuntary_share 27.66, recovery_rate 60.26, save_rate 10.49, bands {\"churn\": \"below target\", \"recovery\": \"good\", \"save\": \"below target\"}, reasons price 28.77%, usage 34.91%, competitor 10.85%, feature 14.62%, technical 5.66%, temporary 3.77%, other 1.42%, stages trigger found; survey said missing; offer found; confirm found; post not found, dunning predunning not found; retry found; declines not found; emails found; grace not found; updater not found, flags CH-01, CH-03, CH-04, CH-07, CH-09, CH-13, CH-16, CH-17, CH-21)",
"plans": "Plus: $12/month, or $96/year on annual.\nThere is a free tier (one bank account, no goals).\nPause is not built yet, but we can build a 1-3 month pause.\nTone: friendly and direct. We will not offer more than 30% off.",
"business": "b2c",
"audit_handoff": "{\"leak\":\"voluntary\",\"fixes\":[{\"fix\":\"Replace the single 50% off screen with a 5-8 reason help-us-improve survey and one offer per reason: a 1-3 month pause for usage, 20-30% off for 2-3 months for price.\",\"lane\":\"flow\"},{\"fix\":\"Remove the abandon and lost-forever copy, keep a visible continue-cancelling option, keep ac..."
}
Reply:
{
"task": "flow",
"source": "audit handoff",
"business": "b2c",
"survey": {
"question": "Help us improve Centwhistle: what is the main reason you are cancelling?",
"reasons": [
{
"id": "usage",
"label": "I am not using it enough"
},
{
"id": "price",
"label": "It costs too much for what I use"
},
{
"id": "feature",
"label": "It does not sync with my bank or credit union"
},
{
"id": "competitor",
"label": "I am switching to another app"
},
{
"id": "technical",
"label": "I keep hitting bugs or sync errors"
},
{
"id": "temporary",
"label": "I only needed it for a while, like tax season"
},
{
"id": "other",
"label": "Something else (tell us)"
}
]
},
"offers": [
{
"reason": "usage",
"primary": "Pause for 1-3 months instead of cancelling; your budget and goals stay put [build pause]",
"fallback": "A short setup guide or call to rebuild a budget that fits how they actually spend",
"savings": ""
},
{
"reason": "price",
"primary": "25% off Plus for 3 months, within your 30% cap",
"fallback": "Switch to the free tier (one bank account, no goals) and keep their data",
"savings": "$3 a month, $9 over three months (the page's arithmetic on $12 Plus)"
},
{
"reason": "feature",
"primary": "Roadmap preview: tell them where credit union bank sync stands and offer to notify them when it ships",
"fallback": "A workaround such as [manual import or CSV upload steps] for that account",
"savings": ""
},
{
"reason": "competitor",
"primary": "A short comparison of Centwhistle against [the other app] plus 20% off for 2 months",
"fallback": "A short feedback session on what the other app does better",
"savings": "$2.40 a month,
... (shortened here; the page shows it whole)
Audit: B2B scheduling software that cancels after one retry
Input:
{
"task": "audit",
"stack": "Rotaquill - staff scheduling software for outpatient clinics. B2B, team plans, admins manage seats. Billing on Chargebee, monthly only.\n\nLast month:\nAccounts at the start of the month: 1,240\nChurned accounts: 41\nMRR: $318,000\n26 of the 41 churned accounts were failed payments; 15 cancelled on purpose.\nFailed payments last month: 88, recovered: 21.\n\nHow failed payments work today:\nWhen a card fails, Chargebee retries ... (930 characters in all)",
"facts": "(a JSON string; decoded: business b2b, provider chargebee, churn_rate 3.31, involuntary_share 63.41, recovery_rate 23.86, bands {\"churn\": \"below target\", \"recovery\": \"poor\"}, reasons none, stages trigger said missing; survey said missing; offer found; confirm not found; post not found, dunning predunning said missing; retry found; declines not found; emails found; grace said missing; updater said missing, flags CH-02, CH-11, CH-16, CH-01, CH-09, CH-13, CH-14, CH-15, CH-17, CH-18)",
"plans": "Team: $199/month (up to 15 seats). Business: $399/month (unlimited seats, SSO).\nMost failed cards are company cards that expired or were replaced.",
"business": "b2b"
}
Reply:
{
"task": "audit",
"picture": {
"numbers": "1,240 accounts at the start of the month, 41 churned (26 failed payments, 15 on purpose), MRR $318,000; 88 failed payments, 21 recovered.",
"flow_today": "Admins cannot cancel in the app and must email support; customer success calls any account above $1,000 MRR before the cancellation goes through; no exit survey.",
"dunning_today": "Chargebee retries once after 3 days, then cancels the same day; one email at the first failure that says the account is overdue; no card-expiry reminders and no card updater."
},
"leak": "involuntary",
"leak_why": "By the page's arithmetic, 26 of 41 churned accounts (63.41%) were failed payments, and only 21 of 88 failed payments were recovered (23.86%). The page puts the MRR from the 67 unrecovered payments at $17182.15, against $10514.45 lost to all 41 churned accounts.",
"metrics": [
{
"metric": "churn",
"value": "3.31%",
"benchmark": "under 2% (B2B)",
"read": "below target"
},
{
"metric": "recovery",
"value": "23.86%",
"benchmark": "dunning recovery 50-60%; under 30% is poor",
"read": "poor"
}
],
"stages": [
{
"stage": "trigger",
"status": "weak",
"finding": "There is no in-app cancel; every admin, including those below the $1,000 MRR routing threshold, has to email support, which breaks the easy-cancel rule.",
"evidence": [
"Admins cannot cancel in the app; they have to email support to cancel."
]
},
{
"stage": "survey",
"status": "missing",
"finding": "There is no exit survey, so cancel reasons are only captured when support remembers to log them.",
"evidence": [
"No exit survey; support writes the reason in the CRM when they remember."
]
},
{
"stage": "offer",
"status": "weak",
"finding":
... (shortened here; the page shows it whole)
Cancel flow: a course membership with one-click cancel
Input:
{
"task": "flow",
"stack": "Drawquarry - an online drawing course membership. B2C, about 3,100 members, monthly billing on Paddle.\nMonthly churn: 9%.\nRight now cancel happens instantly from the account page: one click and it is done. No survey, no offer, no email after.\n\nWe emailed 40 people who cancelled last month and asked why. Replies:\n- Not enough time to draw: 15\n- Too expensive: 11\n- Finished the course I wanted: 8\n- Switching to Easelor... (482 characters in all)",
"facts": "(a JSON string; decoded: business b2c, provider paddle, churn_rate 9, bands {\"churn\": \"below target\"}, reasons usage 37.5%, price 27.5%, temporary 20%, competitor 10%, other 5%, stages trigger found; survey said missing; offer said missing; confirm not found; post said missing, dunning predunning not found; retry not found; declines not found; emails not found; grace not found; updater not found, flags CH-01, CH-08, CH-09, CH-17, CH-19)",
"plans": "Full: $29/month (all courses, live critiques).\nLite: $15/month (recorded videos only, no critiques).\nAnnual Full: $240/year.\nWe can build a 1-3 month pause. Tone: warm, a little playful. No dark patterns: cancel must stay one click away.",
"business": "b2c"
}
Reply:
{
"task": "flow",
"source": "from the input",
"business": "b2c",
"survey": {
"question": "Help us make Drawquarry better: what is the main reason you are cancelling?",
"reasons": [
{
"id": "usage",
"label": "I don't have enough time to draw right now"
},
{
"id": "price",
"label": "It costs too much for what I use"
},
{
"id": "temporary",
"label": "I finished the course I wanted"
},
{
"id": "competitor",
"label": "I'm moving to another course site"
},
{
"id": "feature",
"label": "I can't find the course or topic I want"
},
{
"id": "technical",
"label": "Videos or the site keep giving me trouble"
},
{
"id": "other",
"label": "Something else (tell us in a few words)"
}
]
},
"offers": [
{
"reason": "usage",
"primary": "Pause your membership for 1-3 months [build pause]; billing stops and your progress waits for you.",
"fallback": "A short-sessions practice plan so they can draw in the time they have.",
"savings": ""
},
{
"reason": "price",
"primary": "25% off Full for 3 months.",
"fallback": "Switch to Lite at $15/month (recorded videos, no critiques).",
"savings": "$7.25 a month, $21.75 over 3 months (page's arithmetic); Lite saves $14 a month."
},
{
"reason": "temporary",
"primary": "Pause for 1-3 months [build pause] and come back for the next course.",
"fallback": "Switch to Lite at $15/month to keep the recorded videos.",
"savings": "Lite saves $14 a month against Full (page's arithmetic)."
},
{
"reason": "competitor",
"primary": "Show what Full has that a recorded-only course does not (live critiques), plus 20% off Full for 2 months.",
"fallback": "Ask for a short feedback chat on what drew them to Eas
... (shortened here; the page shows it whole)
Payment recovery: a snack box losing 1,020 cards a month
Input:
{
"task": "dunning",
"stack": "Munchorrow - a monthly snack box subscription, B2C, 21,000 active subscribers, $34/month. Billing on Recurly.\nFailed payments per month: about 1,020. Recovered: 388.\nToday: Recurly retries every failed card 4 times over 10 days, whatever the decline reason.\nOne email at the first failure, designed in our brand template with a big banner image; it links to the login page, not straight to the card form.\nAfter the last ... (605 characters in all)",
"facts": "(a JSON string; decoded: business b2c, provider recurly, involuntary_share 40, recovery_rate 38.04, bands {\"recovery\": \"below target\"}, reasons none, stages trigger not found; survey not found; offer not found; confirm not found; post not found, dunning predunning said missing; retry found; declines said missing; emails found; grace not found; updater unclear, flags CH-13, CH-14, CH-23, CH-18, CH-22)",
"plans": "One plan: $34/month. Boxes ship on the 5th of each month.\nTone: cheerful, short. We can send email and SMS.",
"business": "b2c"
}
Reply:
{
"task": "dunning",
"source": "from the input",
"provider": "recurly",
"predunning": [
{
"when": "30, 15 and 7 days before the card expires",
"action": "Send a short, cheerful email (and an SMS on the 7-day one) asking them to update the card before their next Munchorrow box, with a direct [Update payment link]."
},
{
"when": "Now, once",
"action": "Confirm the Recurly account updater is switched on so expiring and reissued cards refresh without the subscriber doing anything."
},
{
"when": "Right after a failed payment is recovered",
"action": "Ask the subscriber to add a backup payment method so the next failure can fall back to it."
}
],
"retries": [
{
"decline": "soft",
"examples": "Insufficient funds, processor timeout, issuer temporarily unavailable",
"plan": "Retry 3-5 times over 7-10 days, on days 1, 3, 5 and 7 after the failure, and on the day of the month that worked before."
},
{
"decline": "hard",
"examples": "Stolen or lost card, closed account, invalid card number",
"plan": "Never retry. Send the day 0 email and SMS at once asking for a new card through the [Update payment link]."
},
{
"decline": "authentication",
"examples": "3D Secure or SCA challenge required",
"plan": "Do not retry blind. Send them straight to a page where they confirm or update the payment, then charge once it is confirmed."
}
],
"emails": [
{
"day": 0,
"tone": "friendly alert",
"subject": "Your Munchorrow payment didn't go through",
"body": "Hi [first name],\n\nYour payment for this month's Munchorrow box didn't go through. It happens, and it is a quick fix.\n\nUpdate your card here (no login needed): [Update payment link]\n\nOnce it is updated, your next box ships as usual.\n\nQuestions? Reply
... (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 leak agreeing with the page's voluntary / involuntary split, every metric read equal to the page's benchmark read and no rate the page could not compute, five stages and six dunning steps with none graded in place where the notes say it does not exist, contact-only cancellation and cancel-on-failure carried as high risks, and 3 to 6 fixes with the first on the bigger leak; for a flow, 5 to 8 distinct reasons ending with other and ordered by the data, one offer per reason, discounts within 30%, the user's cap and never 50%, pauses of three months or less, no offer for a closed business, a visible way to keep cancelling, access to the end of the billing period and no guilt copy; for a recovery plan, soft, hard and authentication plans with hard declines never retried, 3 to 5 emails from day 0 with a direct update link and no blame, a grace period and the provider the notes name. In every lane, quotes are checked word for word and no figure 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 notes 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://churn-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://churn-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"]
// churn-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://churn-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://churn-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
}
// ChurnDesk.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 ChurnDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://churn-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://churn-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://churn-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://churn-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":"churn-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": "churn-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: "churn-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": "churn-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", "churn-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: "churn-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" => "churn-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 = "churn-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", "stack": "...", "plans": "...", "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", "stack": "...", "plans": "...", "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, stack, plans, business, 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 churn-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="churn-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 = "churn-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 = `churn-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 := "churn-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 = "churn-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 = "churn-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 = "churn-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 = $"churn-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 (cancel flow)
The same run as Server-Sent Events. flow.json is a flow 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: churn-desk:flow:$(shasum -a 256 flow.json | cut -c1-16):a1" --data @flow.json
rev = json.load(open("flow.json")) # {"task": "flow", "stack": "...", "plans": "...", "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": "churn-desk:flow:" + 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("flow.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": `churn-desk:flow:${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("flow.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", "churn-desk:flow:"+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("flow.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", "churn-desk:flow:" + 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("flow.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "churn-desk:flow:#{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("flow.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: churn-desk:flow:" . 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("flow.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", $"churn-desk:flow:{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);