Drive Track Desk from your own code
Everything the web app does over the network, you can do yourself. Base URL:
https://api.skillsafe.ai/v1/app-api
Every response is the same envelope. On success you get data; on failure you get
error, and never both.
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "...", "details": { ... } } }
The task field comes first
Track Desk is one app with four lanes over one work object — your tracking plan. Every
request carries an explicit task, and the reply answers that lane and only that lane.
Two lanes over the same paste are two distinct runs and must use two distinct idempotency keys.
| task | lane | what it returns | body keys |
|---|---|---|---|
audit | Audit the plan | Scores naming, properties, privacy, coverage and governance out of five, ranks the findings and lists the quick wins. | scorecard, quick_wins |
taxonomy | Standardise the taxonomy | Settles one convention and returns a rename list, a property dictionary and a deprecation list. | convention, renames, property_dictionary, deprecations |
metrics | Build the metric tree | Turns the events into metrics with explicit event-based formulas, guardrails and the decision each supports, plus the blind spots. | metric_tree, blind_spots |
spec | Write the implementation spec | Ordered Analytics.js track / identify / page / group / alias calls with typed payloads, the identity model and a QA checklist. | identity, calls, qa_checklist |
An absent or unrecognised task does not error: the model picks the closest lane and
names the lane it chose in the reply's own task field, so you can detect the fallback.
Input contract
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | One of audit, taxonomy, metrics, spec. |
plan | string | yes | The tracking plan. A markdown table, CSV, TSV, semicolon-delimited text, a JSON array of events, or instrumentation code — the app reads all of them. Clip to about 26,000 characters; the web app cuts whole rows out of the middle and announces the cut in-band. |
destination | string | no | ga4, segment, posthog, amplitude, mixpanel or mixed. Decides which platform limits are enforced. |
convention | string | no | auto, object_action_title, snake_case or verb_object_snake. |
questions | string | no | The decisions the plan must support. Read by the metrics lane; ignored elsewhere. |
product_context | string | no | One line about the product. |
prescan_facts | object | no | The browser linter's output. Its flags[] each carry an id, and the model must reconcile every one of them in coverage_check. Send an empty flags array if you have no linter of your own. |
Output contract
One JSON object. These keys are present in every lane:
{
"task": "audit",
"title": "string",
"verdict": "ready | needs-work | rebuild",
"headline": "string",
"summary": "string",
"findings": [
{ "id": "F-001", "severity": "critical|high|medium|low",
"area": "naming|properties|privacy|coverage|governance",
"flag": "TS-004 or empty", "title": "string", "detail": "string",
"fix": "string", "events": ["string"] }
],
"coverage_check": [ { "flag_id": "TS-001", "status": "confirmed|adjusted|set-aside", "note": "string" } ],
"assumptions": ["string"],
"open_questions": ["string"],
"next_step": "string"
}
Plus exactly one lane body, described per lane below.
Error codes
| HTTP | code | what to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The input object was malformed. Read error.details. |
| 401 | UNAUTHORIZED | The token is missing, expired or from another app. Mint a new one. |
| 402 | PAYMENT_REQUIRED | The balance is under min_credits. Estimate first — estimating is free. |
| 404 | NOT_FOUND | Wrong slug or wrong job id. |
| 409 | CONFLICT | The idempotency key was reused with a different body. |
| 429 | RATE_LIMITED | Back off and retry; never tight-loop. |
| 5xx | INTERNAL | Retry once with the SAME idempotency key so the run is not billed twice. |
1. Get a token
Every call needs an app token, and the token is what identifies the app —
this is the one call that names the slug, and every later path is slug-free because the bearer token
already carries it. The easiest way to get a token is the token page: it
reads the token this browser already holds, shows it masked, and copies a ready-made shell export.
You can also mint a guest token yourself. A guest has a wallet of its own and its records are scoped
to that guest identity, so keep the same token across every call — a second
POST /guest mints a new guest whose collection reads come back empty.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug": "track-desk"}'
import requests
payload = {
"slug": "track-desk"
}
r = requests.post("https://api.skillsafe.ai/v1/app-api/guest",
headers={"Authorization": "Bearer " + TOKEN},
json=payload)
print(r.json()["data"])
const payload = {
"slug": "track-desk"
};
const r = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const { data } = await r.json();
console.log(data);
payload := []byte(`{"slug": "track-desk"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
String payload = """
{
"slug": "track-desk"
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(payload)).build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
System.out.println(res.body());
payload = {
"slug" => "track-desk"
}
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$payload = json_decode(<<<JSON
{
"slug": "track-desk"
}
JSON, true);
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
echo curl_exec($ch);
var payload = @"{""slug"": ""track-desk""}";
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());
2. Check who you are and what you can afford
/me is free. It returns subject_type (user or
guest) and credits. Compare credits against the
min_credits from step 3 before you ever call /run.
curl -s "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer YOUR_TOKEN"
import requests
r = requests.get("https://api.skillsafe.ai/v1/app-api/me",
headers={"Authorization": "Bearer " + TOKEN})
print(r.json()["data"])
const r = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
headers: { Authorization: `Bearer ${TOKEN}` }
});
const { data } = await r.json();
console.log(data);
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token)
.GET().build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
System.out.println(res.body());
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $token]);
echo curl_exec($ch);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = await client.GetStringAsync("https://api.skillsafe.ai/v1/app-api/me");
Console.WriteLine(body);
3. Estimate — free, no job created
/estimate costs nothing and creates nothing. It returns model,
model_alias, markup_bps, hold_credits and
min_credits. Estimate per lane: the hold differs between lanes
because the prompts and output caps differ, so never show one lane's hold for another lane's run.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "audit", "plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |", "destination": "ga4", "convention": "auto", "questions": "", "product_context": "self-serve B2C subscription app, web only", "prescan_facts": {"event_count": 2, "dominant_casing": "Title Case", "flags": [{"id": "TS-001", "code": "naming.mixed_casing", "area": "naming", "severity": "high", "title": "Event names use 2 different casing conventions", "events": ["checkout_started"]}]}}'
import requests
payload = {
"task": "audit",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
r = requests.post("https://api.skillsafe.ai/v1/app-api/estimate",
headers={"Authorization": "Bearer " + TOKEN},
json=payload)
print(r.json()["data"])
const payload = {
"task": "audit",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
};
const r = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const { data } = await r.json();
console.log(data);
payload := []byte(`{"task": "audit", "plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |", "destination": "ga4", "convention": "auto", "questions": "", "product_context": "self-serve B2C subscription app, web only", "prescan_facts": {"event_count": 2, "dominant_casing": "Title Case", "flags": [{"id": "TS-001", "code": "naming.mixed_casing", "area": "naming", "severity": "high", "title": "Event names use 2 different casing conventions", "events": ["checkout_started"]}]}}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
String payload = """
{
"task": "audit",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(payload)).build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
System.out.println(res.body());
payload = {
"task" => "audit",
"plan" => "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan => free, source => organic |\n| checkout_started | payment sheet opens | cart_value => 42.50 |",
"destination" => "ga4",
"convention" => "auto",
"questions" => "",
"product_context" => "self-serve B2C subscription app, web only",
"prescan_facts" => {
"event_count" => 2,
"dominant_casing" => "Title Case",
"flags" => [
{
"id" => "TS-001",
"code" => "naming.mixed_casing",
"area" => "naming",
"severity" => "high",
"title" => "Event names use 2 different casing conventions",
"events" => [
"checkout_started"
]
}
]
}
}
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$payload = json_decode(<<<JSON
{
"task": "audit",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
JSON, true);
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
echo curl_exec($ch);
var payload = @"{""task"": ""audit"", ""plan"": ""| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |"", ""destination"": ""ga4"", ""convention"": ""auto"", ""questions"": """", ""product_context"": ""self-serve B2C subscription app, web only"", ""prescan_facts"": {""event_count"": 2, ""dominant_casing"": ""Title Case"", ""flags"": [{""id"": ""TS-001"", ""code"": ""naming.mixed_casing"", ""area"": ""naming"", ""severity"": ""high"", ""title"": ""Event names use 2 different casing conventions"", ""events"": [""checkout_started""]}]}}";
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/estimate", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());
A successful estimate proves the token, the input shape and the model binding are
all valid. Expect "model_alias": "gpt-terra" and "markup_bps": 1000.
4. Run and poll
/run is metered. Always send an Idempotency-Key, and make it include
the lane: two lanes over the same plan are two distinct runs and must not collide on one key. If a
request times out, retry with the same key — that is what stops a network blip
billing you twice.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: track-desk:taxonomy:9f2a1c04:a1" \
-d '{"task": "taxonomy", "plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |", "destination": "ga4", "convention": "auto", "questions": "", "product_context": "self-serve B2C subscription app, web only", "prescan_facts": {"event_count": 2, "dominant_casing": "Title Case", "flags": [{"id": "TS-001", "code": "naming.mixed_casing", "area": "naming", "severity": "high", "title": "Event names use 2 different casing conventions", "events": ["checkout_started"]}]}}'
import requests
payload = {
"task": "taxonomy",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
r = requests.post("https://api.skillsafe.ai/v1/app-api/run",
headers={"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "track-desk:taxonomy:9f2a1c04:a1"},
json=payload)
print(r.json()["data"])
const payload = {
"task": "taxonomy",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
};
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "track-desk:taxonomy:9f2a1c04:a1"
},
body: JSON.stringify(payload)
});
const { data } = await r.json();
console.log(data);
payload := []byte(`{"task": "taxonomy", "plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |", "destination": "ga4", "convention": "auto", "questions": "", "product_context": "self-serve B2C subscription app, web only", "prescan_facts": {"event_count": 2, "dominant_casing": "Title Case", "flags": [{"id": "TS-001", "code": "naming.mixed_casing", "area": "naming", "severity": "high", "title": "Event names use 2 different casing conventions", "events": ["checkout_started"]}]}}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "track-desk:taxonomy:9f2a1c04:a1")
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
String payload = """
{
"task": "taxonomy",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "track-desk:taxonomy:9f2a1c04:a1")
.POST(BodyPublishers.ofString(payload)).build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
System.out.println(res.body());
payload = {
"task" => "taxonomy",
"plan" => "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan => free, source => organic |\n| checkout_started | payment sheet opens | cart_value => 42.50 |",
"destination" => "ga4",
"convention" => "auto",
"questions" => "",
"product_context" => "self-serve B2C subscription app, web only",
"prescan_facts" => {
"event_count" => 2,
"dominant_casing" => "Title Case",
"flags" => [
{
"id" => "TS-001",
"code" => "naming.mixed_casing",
"area" => "naming",
"severity" => "high",
"title" => "Event names use 2 different casing conventions",
"events" => [
"checkout_started"
]
}
]
}
}
uri = URI("https://api.skillsafe.ai/v1/app-api/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "track-desk:taxonomy:9f2a1c04:a1"
req.body = payload.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$payload = json_decode(<<<JSON
{
"task": "taxonomy",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
JSON, true);
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
"Idempotency-Key: track-desk:taxonomy:9f2a1c04:a1",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
echo curl_exec($ch);
var payload = @"{""task"": ""taxonomy"", ""plan"": ""| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |"", ""destination"": ""ga4"", ""convention"": ""auto"", ""questions"": """", ""product_context"": ""self-serve B2C subscription app, web only"", ""prescan_facts"": {""event_count"": 2, ""dominant_casing"": ""Title Case"", ""flags"": [{""id"": ""TS-001"", ""code"": ""naming.mixed_casing"", ""area"": ""naming"", ""severity"": ""high"", ""title"": ""Event names use 2 different casing conventions"", ""events"": [""checkout_started""]}]}}";
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("Idempotency-Key", "track-desk:taxonomy:9f2a1c04:a1");
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/run", content);
Console.WriteLine(await res.Content.ReadAsStringAsync());
/run returns { "job_id": "job_..." }. Poll it until the
status is terminal, then read output.output — that string is the JSON object
described above.
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_abc123" \
-H "Authorization: Bearer YOUR_TOKEN"
import requests
r = requests.get("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123",
headers={"Authorization": "Bearer " + TOKEN})
print(r.json()["data"])
const r = await fetch("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123", {
headers: { Authorization: `Bearer ${TOKEN}` }
});
const { data } = await r.json();
console.log(data);
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/jobs/job_abc123", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123"))
.header("Authorization", "Bearer " + token)
.GET().build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
System.out.println(res.body());
uri = URI("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . $token]);
echo curl_exec($ch);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = await client.GetStringAsync("https://api.skillsafe.ai/v1/app-api/jobs/job_abc123");
Console.WriteLine(body);
5. Stream it instead
/run-stream is the same run over Server-Sent Events. Each data: line
carries a delta; concatenate them and parse the result as JSON. This is what the web
app uses, and it is why the progress card can advance on real section headings rather than a
character count.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: track-desk:spec:9f2a1c04:a1" \
-d '{"task":"spec","plan":"...","destination":"ga4"}'
import json, requests
with requests.post("https://api.skillsafe.ai/v1/app-api/run-stream",
headers={"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "track-desk:spec:9f2a1c04:a1"},
json=payload, stream=True) as r:
buf = ""
for line in r.iter_lines(decode_unicode=True):
if line and line.startswith("data: "):
ev = json.loads(line[6:])
if ev.get("delta"):
buf += ev["delta"]
print(json.loads(buf))
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "track-desk:spec:9f2a1c04:a1"
},
body: JSON.stringify(payload)
});
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue;
const ev = JSON.parse(line.slice(6));
if (ev.delta) buf += ev.delta;
}
}
console.log(JSON.parse(buf));
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run-stream", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "track-desk:spec:9f2a1c04:a1")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
var buf strings.Builder
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") { continue }
var ev struct{ Delta string `json:"delta"` }
json.Unmarshal([]byte(line[6:]), &ev)
buf.WriteString(ev.Delta)
}
fmt.Println(buf.String())
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "track-desk:spec:9f2a1c04:a1")
.POST(BodyPublishers.ofString(payload)).build();
StringBuilder buf = new StringBuilder();
client.send(req, BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data: "))
.forEach(l -> buf.append(deltaOf(l.substring(6))));
System.out.println(buf);
uri = URI("https://api.skillsafe.ai/v1/app-api/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "track-desk:spec:9f2a1c04:a1"
req.body = payload.to_json
buf = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
ev = JSON.parse(line[6..])
buf << ev["delta"].to_s
end
end
end
end
puts buf
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run-stream");
$buf = "";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
"Idempotency-Key: track-desk:spec:9f2a1c04:a1",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$buf) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$ev = json_decode(substr($line, 6), true);
$buf .= $ev["delta"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
echo $buf;
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("Idempotency-Key", "track-desk:spec:9f2a1c04:a1");
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/run-stream", content,
HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
while (await reader.ReadLineAsync() is string line)
{
if (!line.StartsWith("data: ")) continue;
var ev = JsonDocument.Parse(line[6..]).RootElement;
if (ev.TryGetProperty("delta", out var d)) buf.Append(d.GetString());
}
Console.WriteLine(buf.ToString());
6. One worked example per lane
The same plan, four lanes, four different bodies. Send the input on the left, get the shape on the right. Every lane carries the same envelope; only the body differs.
task: "audit" — Audit the plan
Scores naming, properties, privacy, coverage and governance out of five, ranks the findings and lists the quick wins.
Request:
{
"task": "audit",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
Reply (abridged — the real reply fills every field):
{
"task": "audit",
"title": "Subscription app tracking plan",
"verdict": "needs-work",
"headline": "Two events describe the same signup under two spellings, and one property carries a raw email address.",
"summary": "...",
"findings": [
{ "id": "F-001", "severity": "critical", "area": "privacy", "flag": "TS-003",
"title": "Signup Completed carries a raw email address",
"detail": "...", "fix": "Send a pseudonymous user_id and join to the email in the warehouse.",
"events": ["Signup Completed"] }
],
"scorecard": [
{ "area": "naming", "score": 2, "status": "partial", "note": "Title Case and snake_case are both in use." },
{ "area": "properties", "score": 3, "status": "partial", "note": "..." },
{ "area": "privacy", "score": 0, "status": "fail", "note": "..." },
{ "area": "coverage", "score": 2, "status": "partial", "note": "..." },
{ "area": "governance", "score": 1, "status": "fail", "note": "..." }
],
"quick_wins": ["Rename checkout_started to Checkout Started", "Drop the email property"],
"coverage_check": [ { "flag_id": "TS-001", "status": "confirmed", "note": "Two conventions, confirmed." } ],
"assumptions": [], "open_questions": [], "next_step": "Remove the email property before anything else."
}
task: "taxonomy" — Standardise the taxonomy
Settles one convention and returns a rename list, a property dictionary and a deprecation list.
Request:
{
"task": "taxonomy",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
Reply (abridged — the real reply fills every field):
{
"task": "taxonomy",
"title": "Subscription app tracking plan",
"verdict": "needs-work",
"headline": "One convention - Object Action, Title Case, past tense - fixes every name in the plan.",
"summary": "...",
"findings": [ ... ],
"convention": {
"name": "Object Action, Title Case",
"casing": "Title Case", "pattern": "<Object> <PastTenseVerb>", "tense": "past",
"rules": ["The verb is always last and always past tense.",
"Words are separated by a single space, each capitalised."],
"examples": ["Signup Completed", "Checkout Started"]
},
"renames": [
{ "from": "checkout_started", "to": "Checkout Started",
"reason": "The only snake_case name in an otherwise Title Case plan." }
],
"property_dictionary": [
{ "property": "plan", "type": "string", "meaning": "The plan tier at the moment the event fired.",
"required_on": ["Signup Completed"], "example": "free" }
],
"deprecations": [],
"coverage_check": [ { "flag_id": "TS-001", "status": "confirmed", "note": "..." } ],
"assumptions": [], "open_questions": [], "next_step": "Apply the renames, then re-run the audit."
}
task: "metrics" — Build the metric tree
Turns the events into metrics with explicit event-based formulas, guardrails and the decision each supports, plus the blind spots.
Request:
{
"task": "metrics",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "where in the trial do accounts that never convert drop out",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
Reply (abridged — the real reply fills every field):
{
"task": "metrics",
"title": "Subscription app tracking plan",
"verdict": "needs-work",
"headline": "Signup and checkout are both measurable; nothing after the first payment is.",
"summary": "...",
"findings": [ ... ],
"metric_tree": [
{ "id": "M-001", "metric": "Signup to checkout rate", "stage": "activation",
"definition": "Share of signups that open the payment sheet within 7 days.",
"formula": "count(distinct user where checkout_started) / count(distinct user where Signup Completed), 7-day window",
"events_used": ["Signup Completed", "checkout_started"],
"guardrail": "Checkout abandonment must not rise while this improves.",
"decision": "Whether to move the paywall earlier in onboarding." }
],
"blind_spots": [
{ "question": "Do organic signups retain better than paid ones at day 90?",
"why": "No event fires after checkout, so there is no day-90 signal at all.",
"missing_events": ["Subscription Renewed", "Subscription Cancelled"] }
],
"coverage_check": [ { "flag_id": "TS-001", "status": "set-aside", "note": "Naming does not affect the formulas." } ],
"assumptions": [], "open_questions": [], "next_step": "Add a renewal event before asking about day 90."
}
task: "spec" — Write the implementation spec
Ordered Analytics.js track / identify / page / group / alias calls with typed payloads, the identity model and a QA checklist.
Request:
{
"task": "spec",
"plan": "| Event | When it fires | Properties |\n| --- | --- | --- |\n| Signup Completed | account row written | plan: free, source: organic |\n| checkout_started | payment sheet opens | cart_value: 42.50 |",
"destination": "ga4",
"convention": "auto",
"questions": "",
"product_context": "self-serve B2C subscription app, web only",
"prescan_facts": {
"event_count": 2,
"dominant_casing": "Title Case",
"flags": [
{
"id": "TS-001",
"code": "naming.mixed_casing",
"area": "naming",
"severity": "high",
"title": "Event names use 2 different casing conventions",
"events": [
"checkout_started"
]
}
]
}
}
Reply (abridged — the real reply fills every field):
{
"task": "spec",
"title": "Subscription app tracking plan",
"verdict": "needs-work",
"headline": "Four calls, in order, with the email property replaced by a pseudonymous id.",
"summary": "...",
"findings": [ ... ],
"identity": {
"anonymous_id": "Set by Analytics.js on first load and persisted in a first-party cookie.",
"user_id": "The account row id, known the moment Signup Completed fires.",
"alias_on": "Immediately before the first identify call after signup.",
"traits": ["plan", "signup_source"],
"notes": "On sign-out call analytics.reset() so the next visitor gets a fresh anonymous id."
},
"calls": [
{ "order": 1, "method": "page", "name": "Pricing", "when": "The pricing route paints.",
"code": "analytics.page('Pricing', {\n path: '/pricing'\n});", "properties": [] },
{ "order": 2, "method": "track", "name": "Signup Completed",
"when": "The account row is written.",
"code": "analytics.track('Signup Completed', {\n plan: 'free',\n signup_source: 'organic'\n});",
"properties": [
{ "name": "plan", "type": "string", "example": "free", "required": true }
] }
],
"qa_checklist": ["Confirm no request body contains an email address."],
"coverage_check": [ { "flag_id": "TS-001", "status": "adjusted", "note": "..." } ],
"assumptions": [], "open_questions": [], "next_step": "Ship the page and track calls, then verify in the network tab."
}
Reconciliation, and why it matters
The web app runs a linter in the browser before it ever calls the API, and passes the flags it
found into prescan_facts. The model must account for every one of them in
coverage_check — confirmed, adjusted or
set-aside, with a reason. The app renders any flag the reply never mentioned as
missing, in red, rather than dropping it.
If you are driving the API yourself, you can send "prescan_facts": {"flags": []} and
skip the whole mechanism — or send your own flags in the same shape and get the same
accountability.
Rate limits and cost
/estimate,/meand/guestare free./runand/run-streamreservehold_creditsand settle atcharged_credits, which is usually far lower because the hold prices the full output cap.- If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced output cap and the reply carries"truncated": true. Treat that as a partial answer, not a complete one. - On
429, back off. Never tight-loop.
Track Desk is a derived work of @coreyhaines31/analytics-tracking,
@supabase/telemetry-standards, @ncklrs/product-analyst and
@sickn33/segment-cdp.
Back to the app · Manage your token