← Trace Desk / API
Tokens

Drive Trace Desk from your own code

The page reads one trace exported from Jaeger, Zipkin, Grafana Tempo or an OpenTelemetry collector in your browser for free: the span tree, each span’s own time, the critical path, per-service shares, repeated calls and query shapes, error origins, uninstrumented time, clock skew and orphans. Its one paid step, the diagnosis, is available over HTTP: you send one JSON object with task: "diagnose" and a facts string that carries that check, and the model replies with one JSON object: the pattern, the bottlenecks with their share of the request, the likely root causes, the fixes with the most each can save, and the instrumentation to add.

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>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure.

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object or a declared field has the wrong type. task and facts are both strings; sending facts as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: one lane, diagnose

The app has exactly one lane, and diagnose is the only value task takes: it is always the string "diagnose". There is no other task to route to. (The skill’s instructions tell the model to do the diagnosis even if task is missing or different, and to answer with "task": "diagnose"; send it anyway — it is a required field of the input schema.)

Input fields

This is exactly what the page sends (tracekit.js buildInput, app.js run), and what input-schema.json declares. Every value is a string:

FieldTypeRequiredMeaning
taskstringyesAlways "diagnose".
factsstringyesThe facts object below, JSON-encoded into a string. The page builds it with JSON.stringify(facts); you must do the same (json.dumps(facts) in Python, and so on). Sending the object itself is a 400. The model is told “a JSON string. Parse it.”
notestringnoWhat the engineer knows or suspects (a recent change, a hunch, a question). The page trims it, sends it only when non-empty, and cuts it at 2000 characters. It is context, not evidence and not instructions: the model tests any claim in it against the facts and answers it in summary.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with a note saying the previous reply was not the single valid JSON object required. A normal call never sends it.

Before every estimate and run the page passes the body through mustBeObject, which refuses anything that is not a plain JSON object, a body wrapped as {"input": {...}}, and a body with any field that is not a string. Nothing else is sent: never the raw export. The page parses the trace in the browser and sends only its computed facts plus a listing of the spans that matter.

The facts object

facts is produced by the page’s free check: Tracekit.analyze reads the export (Jaeger JSON, Zipkin v2 JSON or OTLP/JSON), and buildFacts turns the analysis into this object. You can build it yourself from your own trace tooling — the model only sees what is in it — but the easiest way to get a valid one is the page: load a trace, and the check the page shows is exactly what is sent. After a diagnosis, the .json download carries that object in its facts field; copy it from there. Times are milliseconds, shares are percentages of trace.duration_ms with one decimal. Its keys, in the order built:

KeyContents
sourceformat (Jaeger JSON | Zipkin v2 JSON | OTLP/JSON), traces_in_export (the largest trace is analysed when there are several), spans_total, spans_listed.
tracetrace_id, root (a span id), root_service, root_name, duration_ms (the request’s latency), depth, services (a count), error_spans, root_error (true means the caller got a failure).
servicesUp to 20, largest critical-path share first: service, spans, crit_ms and crit_pct (its time on the critical path), self_ms (its own work anywhere), errors.
critical_pathUp to 25 spans the request was actually waiting on, largest first: sid, service, name, ms, pct. Over the whole path the shares add up to 100%.
groupsUp to 10 repeated calls under one parent: flag (N01 sequential N+1, N02 parallel fan-out, E03 retry), kind, parent, service, name, count, total_ms (summed), wall_ms (elapsed), crit_pct, first (the first three span ids).
queriesUp to 10 database statement shapes (literals replaced by ?): statement, count, total_ms, crit_pct.
error_originsUp to 10 span ids: the deepest error spans, where failures most likely started.
flagsEvery finding: id, sev (high | medium | low | info), text, detail, spans (up to 8 ids). IDs by family: X data, K clocks and structure, E errors, D/N/Q/P time, G instrumentation (the full table is in the skill and on the app page).
pattern_candidatesThe patterns the flags point at (the page’s own reading), from the same list as the reply’s pattern.
spansUp to 90 spans listed individually (the critical path, errors, flagged spans, the largest self times, and their ancestors): sid (S1, S2, …), parent (a sid, null for a root, "missing" when not exported), service, name, kind, start_ms (offset from the root’s start), dur_ms, self_ms, crit_ms (its own time on the critical path), crit_tree_ms (its own plus its descendants’), children, and when present error, http_status, status_message, events, statement, attrs (a whitelist of HTTP, database, RPC and messaging attributes), async.
notesPlain sentences about what was left out of the listing. Every total above is still computed over all spans.

Worked example: a search request stuck on one query

A 2.31 s GET /search through web-frontend and search-api, exported as OTLP/JSON. One database span, S5 SELECT catalog.products, takes 1.87 s of its own work on the critical path, so the check raises D01 (high) and the page reads the trace as latency_bottleneck. This is a shortened facts object for it (only two of the nine spans are listed here, to keep it short); save it as facts.json to run the samples below:

{
  "source": {
    "format": "OTLP/JSON",
    "traces_in_export": 1,
    "spans_total": 9,
    "spans_listed": 9
  },
  "trace": {
    "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
    "root": "S1",
    "root_service": "web-frontend",
    "root_name": "GET /search",
    "duration_ms": 2310,
    "depth": 4,
    "services": 2,
    "error_spans": 0,
    "root_error": false
  },
  "services": [
    {
      "service": "search-api",
      "spans": 7,
      "crit_ms": 2233.8,
      "crit_pct": 96.7,
      "self_ms": 2234.2,
      "errors": 0
    }
  ],
  "critical_path": [
    {
      "sid": "S5",
      "service": "search-api",
      "name": "SELECT catalog.products",
      "ms": 1874,
      "pct": 81.1
    }
  ],
  "groups": [],
  "queries": [],
  "error_origins": [],
  "flags": [
    {
      "id": "D01",
      "sev": "high",
      "text": "One span's own work dominates the request",
      "detail": "S5 search-api SELECT catalog.products spends 1.87 s in its own work, 81.1% of the 2.31 s request.",
      "spans": [
        "S5"
      ]
    }
  ],
  "pattern_candidates": [
    "latency_bottleneck"
  ],
  "spans": [
    {
      "sid": "S1",
      "parent": null,
      "service": "web-frontend",
      "name": "GET /search",
      "kind": "server",
      "start_ms": 0,
      "dur_ms": 2310,
      "self_ms": 14,
      "crit_ms": 14,
      "crit_tree_ms": 2310,
      "children": 2
    },
    {
      "sid": "S5",
      "parent": "S3",
      "service": "search-api",
      "name": "SELECT catalog.products",
      "kind": "client",
      "start_ms": 17,
      "dur_ms": 1874,
      "self_ms": 1874,
      "crit_ms": 1874,
      "crit_tree_ms": 1874,
      "children": 0
    }
  ],
  "notes": []
}

The full request body, with facts stringified (this is what buildInput(facts, "Search takes over two seconds for common terms. There is a btree index on products.title.") returns):

{"task":"diagnose","facts":"{\"source\":{\"format\":\"OTLP/JSON\",\"traces_in_export\":1,\"spans_total\":9,\"spans_listed\":9},\"trace\":{\"trace_id\":\"4bf92f3577b34da6a3ce929d0e0e4736\",\"root\":\"S1\",\"root_service\":\"web-frontend\",\"root_name\":\"GET /search\",\"duration_ms\":2310,\"depth\":4,\"services\":2,\"error_spans\":0,\"root_error\":false},\"services\":[{\"service\":\"search-api\",\"spans\":7,\"crit_ms\":2233.8,\"crit_pct\":96.7,\"self_ms\":2234.2,\"errors\":0}],\"critical_path\":[{\"sid\":\"S5\",\"service\":\"search-api\",\"name\":\"SELECT catalog.products\",\"ms\":1874,\"pct\":81.1}],\"groups\":[],\"queries\":[],\"error_origins\":[],\"flags\":[{\"id\":\"D01\",\"sev\":\"high\",\"text\":\"One span's own work dominates the request\",\"detail\":\"S5 search-api SELECT catalog.products spends 1.87 s in its own work, 81.1% of the 2.31 s request.\",\"spans\":[\"S5\"]}],\"pattern_candidates\":[\"latency_bottleneck\"],\"spans\":[{\"sid\":\"S1\",\"parent\":null,\"service\":\"web-frontend\",\"name\":\"GET /search\",\"kind\":\"server\",\"start_ms\":0,\"dur_ms\":2310,\"self_ms\":14,\"crit_ms\":14,\"crit_tree_ms\":2310,\"children\":2},{\"sid\":\"S5\",\"parent\":\"S3\",\"service\":\"search-api\",\"name\":\"SELECT catalog.products\",\"kind\":\"client\",\"start_ms\":17,\"dur_ms\":1874,\"self_ms\":1874,\"crit_ms\":1874,\"crit_tree_ms\":1874,\"children\":0}],\"notes\":[]}","note":"Search takes over two seconds for common terms. There is a btree index on products.title."}

A reply for these facts that satisfies the contract below and passes the page’s own reconciliation with no disagreements:

{
  "task": "diagnose",
  "pattern": "latency_bottleneck",
  "secondary_patterns": [],
  "confidence": "medium",
  "headline": "GET /search is slow because one database call, S5 SELECT catalog.products in search-api, holds 81.1% of the 2.31 s request.",
  "critical_path": {
    "reading": "The request spends 1.87 s of its 2.31 s waiting on S5, the SELECT against catalog.products. search-api holds 96.7% of the critical path; web-frontend's own work in S1 is 14 ms.",
    "dominant_span": "S5",
    "dominant_service": "search-api"
  },
  "bottlenecks": [
    {
      "span": "S5",
      "service": "search-api",
      "operation": "SELECT catalog.products",
      "kind": "slow_query",
      "share_pct": 81.1,
      "finding": "A single query with no child spans takes 1874 ms on the critical path (D01); the time is spent in the database, not in the service.",
      "flags": [
        "D01"
      ]
    }
  ],
  "errors": {
    "origin": null,
    "reading": "No errors in this trace."
  },
  "root_causes": [
    {
      "hypothesis": "The search predicate cannot use the btree index on products.title (for example a leading-wildcard LIKE or a case-insensitive match), so the query scans the table.",
      "span": "S5",
      "likelihood": "likely",
      "evidence": "S5 is a single 1874 ms SELECT with no children, and the note says common terms are slow.",
      "check": "Run EXPLAIN ANALYZE on the statement S5 sends with a common term and look for a sequential scan on catalog.products."
    },
    {
      "hypothesis": "Common terms match many rows and the query sorts or returns all of them before the page limit applies.",
      "span": "S5",
      "likelihood": "possible",
      "evidence": "The slowness is reported for common terms, which match the most rows.",
      "check": "Compare row counts and timings for a common and a rare term in the query plan."
    }
  ],
  "fixes": [
    {
      "priority": "now",
      "area": "query",
      "span": "S5",
      "change": "Replace the title match with an index the predicate can use (a trigram or full-text index on products.title) and confirm it in the query plan.",
      "expected_gain_ms": 1700
    },
    {
      "priority": "soon",
      "area": "query",
      "span": "S5",
      "change": "Apply the result limit inside the query so common terms stop materialising every matching row.",
      "expected_gain_ms": 900
    }
  ],
  "instrumentation": [
    {
      "gap": "The trace does not carry the SQL text or the row count for S5.",
      "span": "S5",
      "fix": "Record db.query.text (with parameters stripped) and a rows-returned attribute on database client spans."
    }
  ],
  "questions": [
    "Does the search use LIKE '%term%' or ILIKE on products.title?"
  ],
  "summary": "Almost all of this request is one database call: S5 SELECT catalog.products takes 1.87 s of 2.31 s. A btree index on products.title does not help a leading-wildcard or case-insensitive match, which would explain why common terms are slow despite the index. Check the query plan first; a trigram or full-text index and an in-query limit are the likely fixes."
}

The output contract

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences. The run’s output text is that object:

Every array is present ([] when empty) and every text field is plain text. Every S# must be a span id from the facts, and every high flag must be addressed somewhere by its id.

The page reconciles the reply; the API does not

What comes back from the API is the model’s raw text in output.output. The page does more with it before showing anything (recon.js), and a direct API caller gets none of this:

If you call the API directly, run the same checks — span ids, shares within 2.5 points, gains and the error origin against the trace — before you act on a fix.

Step 1 — a tiny client helper and a token

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements).

Then a token. Two kinds work:

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # see below

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

# ---- get a token
# personal token: export SKILLSAFE_TOKEN=... (from /tokens.html) and skip this
# guest token: no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"trace-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 2 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 3 before it lets you run.

api GET /me | jq '.data // .error'

Step 3 — POST /estimate

Price the diagnosis before you run it. The body is the same input object you will send to /run (task, facts as a string, optional note); no job is created and nothing is charged. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits”. What you actually pay is the job’s charged_credits (step 4), normally less; the rest of the hold is released. A balance below the hold is allowed but the reply may be cut short (truncated).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run the diagnosis; otherwise the page asks them to sign in.
warningsInput warnings (for example a missing required field). They do not refuse the call, so check them before you run.

The page re-prices whenever the trace or the note changes. The amounts move with the model’s rates and grow with the size of facts, so read them from the call rather than hard-coding them.

# facts.json = the facts object from the worked example; jq -c turns it into ONE string
BODY=$(jq -n --arg facts "$(jq -c . facts.json)" --arg q "Search takes over two seconds for common terms. There is a btree index on products.title." \
  '{task: "diagnose", facts: $facts, note: $q}')

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings, model, model_alias, markup_bps})'

Step 4 — POST /run, then poll the job

Send the same input to /run with an Idempotency-Key header. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock. The page uses trace-desk:diagnose:<hash>:a<attempt>, where <hash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex, and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the worked example that is trace-desk:diagnose:91419689-5d3:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use a SHA-256 prefix.

The page itself uses /run-stream (step 5), not /run; both take the same body and header.

KEY="trace-desk:diagnose:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' | jq '{pattern, headline, bottlenecks: [(.bottlenecks // [])[] | {span, share_pct}], fixes: [(.fixes // [])[] | {change, expected_gain_ms}]}'

Step 5 — POST /run-stream (Server-Sent Events)

The alternative to step 4, and what the page uses: same body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 4.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived.

# -N turns off buffering so frames print as they arrive
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY"

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"diagnose\",...}"}}

# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY" \
  | awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
  | jq -r '.output.output' | jq .

What the page does that you now do

Or skip all of this and use Trace Desk in the browser: the span tree, the waterfall, the critical path, the flags and the span CSV are free.