← Track Desk / API
Manage your token

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.

tasklanewhat it returnsbody keys
auditAudit the planScores naming, properties, privacy, coverage and governance out of five, ranks the findings and lists the quick wins.scorecard, quick_wins
taxonomyStandardise the taxonomySettles one convention and returns a rename list, a property dictionary and a deprecation list.convention, renames, property_dictionary, deprecations
metricsBuild the metric treeTurns the events into metrics with explicit event-based formulas, guardrails and the decision each supports, plus the blind spots.metric_tree, blind_spots
specWrite the implementation specOrdered 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

fieldtyperequiredmeaning
taskstringyesOne of audit, taxonomy, metrics, spec.
planstringyesThe 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.
destinationstringnoga4, segment, posthog, amplitude, mixpanel or mixed. Decides which platform limits are enforced.
conventionstringnoauto, object_action_title, snake_case or verb_object_snake.
questionsstringnoThe decisions the plan must support. Read by the metrics lane; ignored elsewhere.
product_contextstringnoOne line about the product.
prescan_factsobjectnoThe 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

HTTPcodewhat to do
400VALIDATION_ERRORThe input object was malformed. Read error.details.
401UNAUTHORIZEDThe token is missing, expired or from another app. Mint a new one.
402PAYMENT_REQUIREDThe balance is under min_credits. Estimate first — estimating is free.
404NOT_FOUNDWrong slug or wrong job id.
409CONFLICTThe idempotency key was reused with a different body.
429RATE_LIMITEDBack off and retry; never tight-loop.
5xxINTERNALRetry 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"}'

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"

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"]}]}}'

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"]}]}}'

/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"

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"}'

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_checkconfirmed, 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

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