Drive Attribution Desk from your own code
The page does its free work in the browser: it reads a channel table (spend, each ad platform's own count, web
analytics, the CRM or store, a self-reported survey) and optional converting touch paths, takes the CRM or store as
the source of truth for the count, measures the platforms' over-claim against it, computes every channel's share and
cost per conversion under every source, the direct and branded share and the survey-versus-tracking gaps, scores six
attribution models over the journeys and raises flags AT-01..AT-17. Its two paid lanes are available over HTTP. You
send one JSON object with task set to "readout" or "test", and the model
replies with one JSON object. The easiest way to build the facts string is the page itself:
Attrikit.buildInput(lane, Attrikit.analyze(sources, {paths, business, question}), {readoutHandoff}) in
attrikit.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 |
|---|---|
"readout" | The attribution readout: the decision; the source of truth and its count (never the platforms' sum); the platforms' total, the over-claim ratio and what it is made of; every channel read underrated, overrated, fair or unclear with a confidence; first-touch against last-touch for the channels that swing 10 points or more and the primary model for the business; the blind spots; the measurement that fits; the contested channel, its claimants and the test design that settles it; the allocation call with its confidence and 2 to 5 moves; what is missing. |
"test" | The incrementality test for the contested channel: the claim under test; the design (holdout audience, on/off, geo holdout, ghost ads, psa or survey) and why; exposed and control groups; duration; the outcome counted by the source of truth; the baseline in the page's numbers; setup steps; contamination guards; how to read lift, no lift and inconclusive; incremental CPA as the decision rule. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | readout or test |
sources | required | Your channel table and notes, verbatim. Past 9,000 characters the page cuts the middle on line boundaries and leaves a [attribution-desk: ...] marker; the page read all of it, so its totals are in facts. |
facts | required | A JSON string: summary, business, question, sources (the column read as each source), truth (source, column, total, basis, column_sum), totals, gap (platform_total, ga_total, truth, ratio, extra, ga_ratio), channels (channel, class, spend, platform, ga, crm, survey, shares, cac, survey_gap_pp, platform_vs_truth), blind (direct_branded_ga, direct_branded_crm, survey_only, dark_social_survey), models (null, or journeys, conversions, decay_basis and by_channel credit / share for first, last, last_non_direct, linear, time_decay, position), contested, flags, notes. It is Attrikit.factsOf(A). |
paths | optional | Converting journeys, one per line. Past 4,000 characters the page sends whole journeys only and says how many were held back. |
business | optional | b2b, ecom or blended. |
question | optional | The decision the numbers inform. |
readout_handoff | test only | A JSON string of a readout's truth{source, count}, contested{channel, claimants, why, method}, recommendation and blind_spots[], 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: "readout"
business (b2b|ecom|blended|unknown), question, truth{source, count, why, evidence[]}, gap{platform_total, ratio, read}, channels[{channel, read: underrated|overrated|fair|unclear, why, confidence: high|medium|low}] (every channel), models{first_vs_last[{channel, first, last, story}], primary: first|last|last_non_direct|linear|time_decay|position|data_driven, why}, blind_spots[{spot: direct|branded|dark_social|ai|cross_device|offline|view_through|redirects|other, finding, evidence[]}], measurement{fit: basics|mta_plus_tests|mmm_plus_tests, why}, contested{channel, claimants, why, method}, recommendation{call, confidence, moves[]}, missing[{item, why, get}].
task: "test"
source (readout handoff|from the input), channel, claim, method (geo holdout|on/off|psa|ghost ads|holdout audience|survey), method_why, groups{exposed, control}, duration, metric{primary, source}, baseline, setup[] (3 to 7), contamination[], readings[{outcome: lift|no lift|inconclusive, means, action}] (three), decision_rule, after[].
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.
Readout: B2B SaaS where three platforms claim 2.45x the deals
Input (sources and facts shortened here):
{
"task": "readout",
"sources": "Brindlecount - practice software for small accounting firms. B2B SaaS, sales-assisted, deals take about 60 days and usually involve a partner and an office manager.\nQ3 numbers. Closed-won deals in HubSpot: 64\nWe only look at last-click in GA4 today, and the board wants to know where Q4 budget should go.\nMeta and LinkedIn numbers are their default 7-day click, 1-day view windows.\n\nChannel,Spend,Google Ads,Meta,LinkedIn,GA... (790 characters in all)",
"facts": "(a JSON string; decoded: business b2b, truth 64 (sum of the HubSpot column (7 channels)), platforms 157 (2.45x), 8 channels: Paid search platform 58 / truth 14 / survey 9; Branded search platform 22 / truth 12 / survey 2; LinkedIn Ads platform 41 / truth 9 / survey 11; Meta prospecting platform 36 / truth 3 / survey 2; ..., models 8 journeys, contested Paid search, flags AT-01, AT-07, AT-07, AT-07, AT-03, AT-05, AT-09, AT-12)",
"paths": "LinkedIn Ads (day 0) > Organic search (day 9) > Branded search (day 41) > Direct (day 58) | x9\nLinkedIn Ads (day 0) > Direct (day 30) | x4\nAccounting podcast sponsorship (day 0) > Branded search (day 22) > Direct (day 47) | x7\nPaid search (day 0) > Direct (day 15) | x8\nPaid search (day 0) > Paid search (day 12) > Branded search (day 33) | x6\nPartner referrals (day 0) > Direct (day 26) | x6\nMeta prospecting (day 0) > ... (539 characters in all)",
"business": "b2b",
"question": "Where should the Q4 budget go?"
}
Reply:
{
"task": "readout",
"business": "b2b",
"question": "Where should the Q4 budget go?",
"truth": {
"source": "HubSpot",
"count": "64",
"why": "Brindlecount is a sales-assisted B2B business, so closed-won deals in HubSpot are where the money is real; the ad platforms and GA4 only explain where those 64 came from.",
"evidence": [
"Closed-won deals in HubSpot: 64"
]
},
"gap": {
"platform_total": "157",
"ratio": "2.45",
"read": "The platforms over-claim: Google Ads, Meta and LinkedIn together claim 157 against 64 won deals (the page's 2.45x), made of overlapping claims on the same deals plus 1-day view-through and 7-day click windows. Never add them up; GA4 over-counts too, at 108."
},
"channels": [
{
"channel": "Paid search",
"read": "overrated",
"why": "Google Ads claims 58 and GA4 31, but HubSpot credits 14 and buyers name it 9, so the platform claim is 4.14x the truth and its HubSpot cost per deal is $1,500, not the $362.07 Google reports.",
"confidence": "high"
},
{
"channel": "Branded search",
"read": "overrated",
"why": "HubSpot credits 12 and GA4 19, but only 2 buyers name it; branded search is where demand from the podcast, partners and LinkedIn cashes out, not a channel to grow.",
"confidence": "medium"
},
{
"channel": "LinkedIn Ads",
"read": "overrated",
"why": "LinkedIn claims 41 against 9 in HubSpot (4.56x), yet 11 buyers name it and it opens 27.1% of journeys, so the platform number is inflated while the channel itself does real top-of-funnel work.",
"confidence": "medium"
},
{
"channel": "Meta prospecting",
"read": "overrated",
"why": "Meta claims 36 against 3 in HubSpot (12x), only 2 buyers name it, and its HubSpot cost per deal is $3,000, the worst of any paid channel.",
"con
... (shortened here; the page shows it whole)
Test: the same company, carrying the readout
Input (with the readout handed over):
{
"task": "test",
"sources": "Brindlecount - practice software for small accounting firms. B2B SaaS, sales-assisted, deals take about 60 days and usually involve a partner and an office manager.\nQ3 numbers. Closed-won deals in HubSpot: 64\nWe only look at last-click in GA4 today, and the board wants to know where Q4 budget should go.\nMeta and LinkedIn numbers are their default 7-day click, 1-day view windows.\n\nChannel,Spend,Google Ads,Meta,LinkedIn,GA... (790 characters in all)",
"facts": "(a JSON string; decoded: business b2b, truth 64 (sum of the HubSpot column (7 channels)), platforms 157 (2.45x), 8 channels: Paid search platform 58 / truth 14 / survey 9; Branded search platform 22 / truth 12 / survey 2; LinkedIn Ads platform 41 / truth 9 / survey 11; Meta prospecting platform 36 / truth 3 / survey 2; ..., models 8 journeys, contested Paid search, flags AT-01, AT-07, AT-07, AT-07, AT-03, AT-05, AT-09, AT-12)",
"paths": "LinkedIn Ads (day 0) > Organic search (day 9) > Branded search (day 41) > Direct (day 58) | x9\nLinkedIn Ads (day 0) > Direct (day 30) | x4\nAccounting podcast sponsorship (day 0) > Branded search (day 22) > Direct (day 47) | x7\nPaid search (day 0) > Direct (day 15) | x8\nPaid search (day 0) > Paid search (day 12) > Branded search (day 33) | x6\nPartner referrals (day 0) > Direct (day 26) | x6\nMeta prospecting (day 0) > ... (539 characters in all)",
"business": "b2b",
"question": "Where should the Q4 budget go?",
"readout_handoff": "{\"truth\":{\"source\":\"HubSpot\",\"count\":\"64\"},\"contested\":{\"channel\":\"Paid search\",\"claimants\":\"Google Ads vs HubSpot\",\"why\":\"Google Ads claims 58 conversions while HubSpot credits 14 (4.14x), and it is the biggest line at $21,000 of spend.\",\"method\":\"on/off\"},\"recommendation\":\"For Q4, move money out of Meta prospecting t..."
}
Reply:
{
"task": "test",
"source": "readout handoff",
"channel": "Paid search",
"claim": "Google Ads says Paid search drove 58 conversions; HubSpot credits it 14 closed-won deals.",
"method": "on/off",
"method_why": "Brindlecount is B2B and sales-assisted with 64 closed-won deals in HubSpot, far too few for a per-user or geo split to read, so the readout's on/off for a quarter stands. Branded search is a separate line and stays on, so the test isolates the non-branded keywords.",
"groups": {
"exposed": "The on period: the quarter before the test, when Paid search ran at its Q3 level of $21,000.",
"control": "The off period: one full quarter starting [test start date] with every non-branded Paid search campaign paused and nothing else changed."
},
"duration": "One quarter off, the skill's B2B constant, then read closed-won deals for [days after the off quarter] longer because deals take about 60 days.",
"metric": {
"primary": "New HubSpot deals created in the window that reach closed-won, with demo requests created as the early read.",
"source": "HubSpot"
},
"baseline": "Paid search: 58 Google Ads conversions, 31 in GA4, 14 in HubSpot, 9 in the survey, $21,000 spend, $1,500 per HubSpot deal and $362.07 per Google Ads conversion (the page's arithmetic).",
"setup": [
"Freeze the comparison: export Q3 HubSpot deals created and closed-won, demo requests, and survey answers so the on period is fixed before anything changes.",
"Agree the read in advance: the metric, the off quarter, the [lag after the quarter] for late closes, and what counts as lift.",
"Pause all non-branded Paid search campaigns on [test start date]; keep Branded search, LinkedIn Ads, Meta prospecting and the podcast at their current budgets.",
"Tag every new HubSpot deal with its create da
... (shortened here; the page shows it whole)
Readout: a candle shop whose agency sums the platforms
Input:
{
"task": "readout",
"sources": "Tallowfen Candle Co. - DTC home fragrance, Shopify store, average order $58. September.\nShopify orders: 2,140\nOur agency adds up what Meta, Google and TikTok report to get total conversions, and says we should start a media mix model.\nWe sponsor two home-and-design podcasts.\n\n| Channel | Spend | Meta | Google Ads | TikTok | GA4 | Shopify | Post-purchase survey |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| Met... (883 characters in all)",
"facts": "(a JSON string; decoded: business ecom, truth 2140 (sum of the Shopify column (7 channels)), platforms 2980 (1.39x), 9 channels: Meta prospecting platform 980 / truth 520 / survey 260; Meta retargeting platform 640 / truth 240 / survey 20; Google Shopping platform 610 / truth 430 / survey 70; Branded search platform 450 / truth 300 / survey 15; ..., models none, contested Meta prospecting, flags AT-01, AT-07, AT-07, AT-13, AT-05, AT-06, AT-16, AT-15)",
"business": "ecom"
}
Reply:
{
"task": "readout",
"business": "ecom",
"question": "[the decision this informs]",
"truth": {
"source": "Shopify",
"count": "2140",
"why": "Shopify orders are where the money is real for a DTC store, and the Shopify column sums to the stated 2,140 orders.",
"evidence": [
"Shopify orders: 2,140"
]
},
"gap": {
"platform_total": "2980",
"ratio": "1.39",
"read": "Meta, Google Ads and TikTok together claim 2,980 conversions against 2,140 Shopify orders (1.39x, 840 extra, the page's arithmetic), so the platforms over-claim. The extra is overlapping claims on the same orders plus view-through and generous windows, so the agency's sum is not a conversion count."
},
"channels": [
{
"channel": "Meta prospecting",
"read": "fair",
"why": "Meta claims 980 against 520 in Shopify (1.88x), but Shopify's 24.3% share and the survey's 30.1% agree it is the biggest real driver.",
"confidence": "medium"
},
{
"channel": "Meta retargeting",
"read": "overrated",
"why": "Meta claims 640 against 240 in Shopify (2.67x), and only 2.3% of survey answers name it, so its survey cost per answer is $325.",
"confidence": "high"
},
{
"channel": "Google Shopping",
"read": "fair",
"why": "Google Ads claims 610, GA4 380 and Shopify 430 (1.42x); the survey's 8.1% is low because shoppers recall where they first heard, not where they clicked to buy.",
"confidence": "medium"
},
{
"channel": "Branded search",
"read": "overrated",
"why": "Branded search is where demand from elsewhere cashes out: Google claims 450 against 300 in Shopify (1.5x) and only 1.7% of buyers name it.",
"confidence": "high"
},
{
"channel": "TikTok ads",
"read": "overrated",
"why": "TikTok claims 300 against 90 in Shopify (3.33x), though t
... (shortened here; the page shows it whole)
Test: retargeting at 6x ROAS with flat revenue
Input:
{
"task": "test",
"sources": "Cairnwhistle - DTC hiking gear, Shopify, short cycle (most buy within 3 days of the first visit).\nMeta says our retargeting campaign has a 6x ROAS, so we keep scaling it, but revenue is not really going up.\nLast 30 days, Shopify orders: 1,860\n\nChannel\tSpend\tMeta\tGoogle Ads\tGA4\tShopify\nMeta prospecting\t$18,000\t610\t\t300\t340\nMeta retargeting\t$14,000\t820\t\t260\t290\nGoogle search non-brand\t$11,000\t\t400\t350\t360\nBranded search\t$... (575 characters in all)",
"facts": "(a JSON string; decoded: business ecom, truth 1860 (sum of the Shopify column (6 channels)), platforms 2220 (1.19x), 6 channels: Meta prospecting platform 610 / truth 340 / survey None; Meta retargeting platform 820 / truth 290 / survey None; Google search non-brand platform 400 / truth 360 / survey None; Branded search platform 390 / truth 300 / survey None; ..., models none, contested Meta retargeting, flags AT-07, AT-04, AT-16)",
"business": "ecom",
"question": "Is retargeting worth scaling?"
}
Reply:
{
"task": "test",
"source": "from the input",
"channel": "Meta retargeting",
"claim": "Meta says Meta retargeting drove 820 conversions at a 6x ROAS; Shopify credits it 290 orders.",
"method": "holdout audience",
"method_why": "Retargeting is the skill's holdout-audience case: the 140,000-person audience can be split per person, so a random withheld slice shows how many of these site visitors and add-to-carts buy anyway. With a short cycle and Shopify as the record, the read is fast and counted in real orders.",
"groups": {
"exposed": "The rest of the retargeting audience (site visitors and add-to-carts from the last 30 days) keeps seeing Meta retargeting at current spend.",
"control": "A random [holdout %] of that audience, split by a hash of email or customer ID before the test starts, uploaded to Meta as an exclusion audience on every retargeting ad set."
},
"duration": "[test length]: at least one full 30-day audience window, plus a 3-day tail because most buy within 3 days of the first visit.",
"metric": {
"primary": "Shopify orders per person in each group (and revenue per person once [average order value] is added)",
"source": "Shopify orders, matched to the exposed and control lists by email or customer ID"
},
"baseline": "Meta retargeting: $14,000 spend, 820 Meta conversions, 260 in GA4, 290 Shopify orders; $17.07 per Meta conversion vs $48.28 per Shopify order, and Meta claims 2.83x what Shopify credits (the page's arithmetic).",
"setup": [
"Export the retargeting audience with emails or customer IDs and assign each person to exposed or control by a fixed hash, so assignment never changes mid-test.",
"Upload the control list as a custom audience and exclude it from every Meta retargeting ad set, including any dynamic catalogue or Advan
... (shortened here; the page shows it whole)
Readout: a PLG app with no source of truth
Input:
{
"task": "readout",
"sources": "Scrivlet - a writing app with a free plan and paid teams (self-serve, PLG). We have GA4 and the ad platforms, nothing else yet.\nGA4 uses data-driven attribution. Half our signups show up as direct, so the team thinks direct is our best channel.\nPaid conversions last month by source:\n\nChannel,Spend,Google Ads,LinkedIn Ads,GA4\nDirect,,,,212\nGoogle search non-brand,$7,500,96,,61\nBranded search,$1,200,74,,48\nLinkedIn Ads,$... (498 characters in all)",
"facts": "(a JSON string; decoded: business blended, truth None (), platforms 222 (Nonex), 7 channels: Direct platform None / truth None / survey None; Google search non-brand platform 96 / truth None / survey None; Branded search platform 74 / truth None / survey None; LinkedIn Ads platform 52 / truth None / survey None; ..., models none, contested LinkedIn Ads, flags AT-02, AT-03, AT-04, AT-11)",
"business": "blended"
}
Reply:
{
"task": "readout",
"business": "blended",
"question": "Whether direct is really our best channel, as the team thinks",
"truth": {
"source": "none yet (no CRM, store, backend or billing count)",
"count": "",
"why": "Only GA4 and the ad platforms were pasted, and neither is where money is real, so nothing here can say how many paid conversions there were; get the paid-team count from billing first.",
"evidence": [
"We have GA4 and the ad platforms, nothing else yet."
]
},
"gap": {
"platform_total": "222",
"ratio": "",
"read": "Google Ads and LinkedIn Ads together claim 222 conversions (the page's arithmetic), but with no source of truth there is no ratio to measure the over-claim against. Those claims overlap with each other and with GA4, include each platform's own windows and possibly view-through, and must not be summed or added to GA4's 422."
},
"channels": [
{
"channel": "Direct",
"read": "overrated",
"why": "GA4 gives Direct 212 conversions, 50.2% of its column, but direct is where demand created elsewhere cashes out, not a channel you can buy more of.",
"confidence": "medium"
},
{
"channel": "Google search non-brand",
"read": "unclear",
"why": "Google Ads claims 96 while GA4 credits 61, a platform CAC of $78.13 against $122.95 in GA4; without a billing count neither can be called right.",
"confidence": "low"
},
{
"channel": "Branded search",
"read": "overrated",
"why": "Google Ads claims 74 and GA4 48, and its $16.22 platform CAC looks cheap because people searching your name were already coming.",
"confidence": "medium"
},
{
"channel": "LinkedIn Ads",
"read": "unclear",
"why": "LinkedIn claims 52 while GA4 credits 9, a CAC of $96.15 by the platform against $555.56 by GA4; GA4 m
... (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 readout, the source-of-truth count equal to the page's and never the platforms' sum or a platform; the over-claim copied and not called consistent; every channel the page read; direct and branded search never underrated; survey-only channels never high confidence; first and last shares equal to the page's models; no last-touch primary for B2B and no data-driven below about 300 conversions; the blind spots the numbers require; no media mix model below its scale; a contested channel from the table; no high confidence without a source of truth or a survey. For a test, the channel from the table, the outcome counted by the source of truth, three readings with their caveats, and incremental CPA in the decision rule. In both, quotes are checked word for word and no figure, window or duration 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://attribution-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://attribution-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"]
// attribution-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://attribution-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://attribution-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
}
// AttributionDesk.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 AttributionDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://attribution-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://attribution-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://attribution-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://attribution-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":"attribution-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": "attribution-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: "attribution-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": "attribution-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", "attribution-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: "attribution-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" => "attribution-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 = "attribution-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": "readout", "sources": "...", "paths": "...", "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": "readout", "sources": "...", "paths": "...", "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, sources, paths, business, 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 attribution-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="attribution-desk:readout:$(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 = "attribution-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 = `attribution-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 := "attribution-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 = "attribution-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 = "attribution-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 = "attribution-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 = $"attribution-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 (incrementality test)
The same run as Server-Sent Events. test.json is a test input, with readout_handoff when it follows a readout.
# 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: attribution-desk:test:$(shasum -a 256 test.json | cut -c1-16):a1" --data @test.json
rev = json.load(open("test.json")) # {"task": "test", "sources": "...", "facts": "...", "readout_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": "attribution-desk:test:" + 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("test.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": `attribution-desk:test:${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("test.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", "attribution-desk:test:"+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("test.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", "attribution-desk:test:" + 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("test.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "attribution-desk:test:#{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("test.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: attribution-desk:test:" . 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("test.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", $"attribution-desk:test:{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);