Driving SARIF Desk from your own code
Everything the web app's four lanes do is available over HTTP. Send a task, a digest of your SARIF 2.1.0 log, and the read your side made of it, and get back one JSON object. The SARIF reader the browser runs for free — the parse, the twenty-nine deterministic checks, the stratified sample, the report-confidence score — is not re-run server-side. If you drive the API directly you build the digest and you build facts, because facts.flags is the only thing the model is held accountable to.
The task field comes first
This is a multi-lane app with one system prompt and one model. Which lane you get is decided entirely by task. Send it on every call, including /estimate — hold_credits differs per lane because the prompts and output caps differ, and pricing one lane while running another is the most common mistake against this API.
task | The question it answers | What body carries | Extra input it reads |
|---|---|---|---|
triage | Which of these findings are real? | calls[], one per sampled result, plus uncovered_note | — |
remediate | What gets fixed, in what order? | plan[], sequencing_note, accepted_risks[] | — |
tune | Which rules are earning their noise? | rules[] with config snippets, policy_note, combined_config | config_language |
report | Does this ship? | gate, gate_reason, sections[], top_risks[], conditions[], release_note | — |
If task is missing or unrecognised the model picks the closest lane, follows that lane's contract exactly, names the lane it chose in the task field of its reply, and says in the first sentence of summary why. It never blends two lanes.
Base URL and headers
| Thing | Value |
|---|---|
| Base URL | https://api.skillsafe.ai/v1/app-api |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model |
| App identity | carried by the token. There is no X-App-Slug header. The one place the slug sarif-desk appears is the body of POST /guest |
| Idempotency | Idempotency-Key: <string> on /run and /run-stream. Hash (task, digest, context, config_language, attempt) — the lane must be in the key, because two lanes over one report are two runs and must never collide |
| Call | Path | Costs |
|---|---|---|
| Mint a guest token | POST /v1/app-api/guest | free, and the only call whose body is not a lane input |
| Who am I | GET /v1/app-api/me | free |
| Price an input | POST /v1/app-api/estimate | free, creates no job |
| Run a lane | POST /v1/app-api/run | metered; reserves hold_credits |
| Run a lane, streamed | POST /v1/app-api/run-stream | metered; the same run as /run |
| Poll a job | GET /v1/app-api/jobs/{job_id} | free |
The response envelope
Every response, success or failure, is the same shape. Read ok before you touch data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "insufficient_credits", "message": "...", "details": { ... }}}
| Code | HTTP | What to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed, or was revoked. Mint a new one from /guest or sign in. |
forbidden | 403 | A guest token tried to run a metered lane. Runs need a personal token unless the publisher sponsors guests. |
insufficient_credits | 402 | The balance is below min_credits for this lane. Some responses spell this one payment_required; treat both the same. /estimate is free — call it first and never submit into a 402. |
validation_error | 400 | The body was not the input object, or a field had the wrong type. Most often: report_digest was sent as an object instead of a JSON string, or the whole input was wrapped in {"input": ...}. |
rate_limited | 429 | Back off and retry with jitter. Do not tight-loop; the poll in step 5 sleeps two seconds between reads for this reason. |
not_found | 404 | Wrong path, or a job_id that does not belong to this token. |
internal_error | 500 | Retry once with the SAME Idempotency-Key; a retry under the same key never double-bills. |
The input object
| Field | Type | Meaning |
|---|---|---|
task | string | Required. One of triage, remediate, tune, report |
report_digest | string | A JSON string — your digest of the log, serialised. Sent when the log parsed. Not an object: the web app sends JSON.stringify(digest, null, 1) and caps it at 20,000 characters |
raw_report | string | Sent instead of report_digest when the log did not parse: the raw text, head and tail kept, the middle dropped with a marker on both sides, capped at 24,000 characters |
parse_error | string | Why it did not parse. Sent only alongside raw_report |
context | string | Optional. The stack, what is shipping, what worries you. The web app clips at 4,000 characters on a line boundary and says so in the clipped text |
config_language | string | codeql, semgrep, eslint, sonarqube, trivy or generic. Read by tune, which writes its snippets in that syntax; harmless elsewhere |
facts | object | Your own computed read of the whole file: totals, per-rule and per-file counts, coverage, a confidence score, and the flags array. Treated as authoritative — see below |
retry_note | string | Optional. The web app sets this on its single reformat retry, when a reply did not parse as one JSON object |
Send report_digest or the raw_report/parse_error pair, never both. With raw_report there are no refs, no totals and no flags, so the prompt forces the lane's worst verdict, puts the parse failure itself in findings as SDF-001, and returns an empty reconciliation.
You build the digest. Nothing rebuilds it for you.
A real SARIF log is routinely two to forty megabytes and is mostly one rule repeated. The browser parses the whole file locally and sends a digest: every aggregate computed over all N results, the catalog of rules that actually produced results, and a stratified sample of the results themselves — worst band first, at most three per rule so a nine-hundred-hit lint rule cannot crowd out one critical injection, with every rule guaranteed at least one example.
None of that happens server-side. The API takes your report_digest and your facts at face value. Two consequences, and they are the whole reason this section exists:
- Send a prefix of the raw log as
raw_reportand you will get a confident reading of one rule's output and nothing else. Digest it first. - Send
facts.flags: []andreconciliationcomes back empty — the reply is then unfalsifiable. Every distinctflags[].idyou send comes back as exactly onereconciliationentry with a status ofconfirmed,clearedornot-assessedand a one-sentence note. That is the only mechanical check there is on whether the model read your report.
The digest object, before you stringify it. Keys are what the prompt reads by name, so keep them:
{
"report": {
"sarif_version": "2.1.0",
"runs": 2,
"tools": [
{"name": "CodeQL", "version": "2.16.3", "results": 1961},
{"name": "gitleaks", "version": "8.18.2", "results": 33}
]
},
"totals": {
"results": 1994,
"by_level": {"error": 38, "warning": 1420, "note": 536, "none": 0},
"by_band": {"critical": 2, "high": 36, "medium": 1420, "low": 536, "none": 0},
"suppressed": 7,
"duplicates": 12,
"files": 214,
"rules_with_results": 23
},
"rules": [
{"id": "js/unused-local-variable", "hits": 902, "name": "Unused variable",
"level": "note", "description": "A local variable is never read.",
"precision": "very-high", "tags": ["maintainability"]},
{"id": "js/missing-rate-limiting", "hits": 318, "name": "Missing rate limiting",
"level": "warning", "security_severity": 5.0, "precision": "high",
"tags": ["security", "external/cwe/cwe-770"]},
{"id": "js/sql-injection", "hits": 3, "name": "Database query built from user-controlled sources",
"level": "error", "security_severity": 9.8, "precision": "high",
"tags": ["security", "external/cwe/cwe-089"]},
{"id": "generic-api-key", "hits": 1, "name": "Generic API key",
"level": "error", "tags": ["secret"]}
],
"top_files": [
{"file": "src/billing/invoice.js", "count": 41},
{"file": "src/api/routes.js", "count": 33}
],
"sampled_results": [
{"ref": "R-001", "rule": "js/sql-injection", "level": "error", "band": "critical",
"file": "src/billing/invoice.js", "line": 142, "tool": "CodeQL",
"security_severity": 9.8, "precision": "high",
"message": "This query depends on a user-provided value.",
"snippet": "db.query(`SELECT * FROM invoices WHERE id = ${req.params.id}`)",
"dataflow_steps": 4, "tags": ["security", "external/cwe/cwe-089"]},
{"ref": "R-002", "rule": "js/sql-injection", "level": "error", "band": "critical",
"file": "src/api/routes.js", "line": 88, "tool": "CodeQL",
"security_severity": 9.8, "precision": "high",
"message": "This query depends on a user-provided value.", "dataflow_steps": 6},
{"ref": "R-003", "rule": "generic-api-key", "level": "error", "band": "high",
"file": "config/staging.env", "line": 12, "tool": "gitleaks",
"message": "Generic API key detected."},
{"ref": "R-014", "rule": "js/missing-rate-limiting", "level": "warning", "band": "medium",
"file": "src/api/routes.js", "line": 51, "tool": "CodeQL",
"security_severity": 5.0, "precision": "high",
"message": "This route handler performs authorisation but is not rate-limited.",
"suppressed": {"kind": "inSource", "justification": "behind the internal gateway"},
"baseline": "unchanged"},
{"ref": "R-027", "rule": "js/unused-local-variable", "level": "note", "band": "low",
"file": "src/billing/invoice.js", "line": 9, "tool": "CodeQL",
"precision": "very-high", "message": "Unused variable legacyRate."}
],
"coverage_note": "This is a DIGEST, not the whole log. 1994 result(s) were parsed in the browser; 5 of them are quoted below, chosen worst-severity-first with at most 3 per rule so that no single noisy rule crowds out a severe one. 4 of 23 rules that produced results are represented, and every count in `totals` is over all 1994 results. Never describe the sampled_results list as the complete set of findings, and never state a total that contradicts `totals`."
}
And the facts object, which travels as a real object, not a string:
{
"parsed": true,
"sarif_version": "2.1.0",
"tools": [{"name": "CodeQL", "results": 1961}, {"name": "gitleaks", "results": 33}],
"totals": {"runs": 2, "results": 1994, "rules_declared": 412, "rules_with_results": 23,
"files_touched": 214, "suppressed": 7, "duplicates": 12},
"by_level": {"error": 38, "warning": 1420, "note": 536, "none": 0},
"by_band": {"critical": 2, "high": 36, "medium": 1420, "low": 536, "none": 0},
"by_baseline": {"new": 61, "unchanged": 1933},
"top_rules": [{"rule": "js/unused-local-variable", "count": 902},
{"rule": "js/missing-rate-limiting", "count": 318},
{"rule": "js/sql-injection", "count": 3},
{"rule": "generic-api-key", "count": 1}],
"top_files": [{"file": "src/billing/invoice.js", "count": 41},
{"file": "src/api/routes.js", "count": 33}],
"concentration": {"top_rule_share_pct": 45, "top_file_share_pct": 2},
"coverage": {"with_physical_location": 1994, "with_fingerprint": 0,
"with_security_severity": 74, "with_code_flow": 39},
"report_confidence": {"score": 61, "band": "usable-with-caveats"},
"flags": [
{"id": "SD29", "severity": "blocker",
"title": "1 result(s) embed what looks like a live credential",
"refs": ["R-003"], "ref_count": 1},
{"id": "SD15", "severity": "warn", "title": "No result carries a fingerprint",
"refs": [], "ref_count": 0},
{"id": "SD23", "severity": "warn", "title": "One rule is 45% of the whole report",
"refs": [], "ref_count": 0},
{"id": "SD18", "severity": "note",
"title": "7 result(s) are suppressed in the report itself",
"refs": ["R-014"], "ref_count": 7}
]
}
Flag ids run SD01 to SD29 and each has a fixed meaning in the prompt. Four of them change the reply's behaviour outright, so compute them or accept that you lose the guarantee:
| Flag | Severity | What it forces |
|---|---|---|
SD29 | blocker | A result embeds what looks like a live credential. The summary must open by saying the report itself is now a secret and the credential must be rotated — and the value is never quoted back. In remediate, the rotation is step 1, before any code fix. |
SD27 | blocker | A run did not complete. An empty section then means unscanned, not clean, and must be said so before anything reassuring. |
SD28 | warn | Tool execution errors in the run. Same rule as SD27. |
SD23 | warn | One rule is a large share of the whole report. In tune, that rule must be the first entry in body.rules. |
report_confidence.band is one of trustworthy, usable-with-caveats, shaky, not-trustworthy (or unreadable when nothing parsed). The web app scores 100 and subtracts 25 per blocker, 8 per warn and 2 per note; copy that or use your own, but send something — the report lane reads it when deciding a gate.
Refs are the only way to point at a finding
Every entry in sampled_results carries a ref such as R-001. That ref is the handle: findings[].ref, body.calls[].ref and body.plan[].addresses[] all quote it, and nothing else identifies a result. The prompt forbids inventing one, renumbering, or guessing a ref for a result the digest did not carry.
The app audits this. Every ref in the reply is resolved back against the report that was parsed at the moment of the run — so a later edit to the paste box cannot retroactively change what counts as fabricated — and any ref that does not resolve is rendered with an explicit marker rather than drawn as if it were real. Reproduce that check if you drive the API directly: keep the map from ref to file and line on your side, and treat an unresolvable ref as a failed reply, not as a finding.
Refs must be stable within one run and unique across the whole digest, including across merged runs and tools. The triage lane is stricter still: one calls[] entry per sampled_results entry, in the same order, none skipped and none added.
The output contract
Every lane returns one JSON object — no prose around it, no code fence — with the same envelope; only body differs.
{
"task": "triage",
"title": "one line naming the report and the job, under 90 chars",
"verdict": "one of the lane's allowed verdicts",
"summary": "two to five sentences",
"assumptions": ["..."],
"open_questions": ["..."],
"findings": [{"id":"SDF-001","severity":"critical|high|medium|low",
"rule_id":"","ref":"R-001","location":"path:line",
"issue":"","why":"","fix":""}],
"reconciliation": [{"flag_id":"SD29","status":"confirmed|cleared|not-assessed","note":""}],
"next_lane": "triage|remediate|tune|report|",
"body": { }
}
findings is for problems with the report or the posture it describes — a scan that covers nothing, a threshold that lets a critical through, a suppression that hides a real bug. It is not a copy of the scanner's results; those live in the lane body. Ids run sequentially from SDF-001, and an empty findings array is a valid and often correct answer. next_lane is a recommendation for what to do next, or "".
Allowed verdicts, per lane: triage → mostly-real / mixed / mostly-noise; remediate → plan-ready / plan-with-unknowns / cannot-plan; tune → tunable / partially-tunable / already-tight; report → pass / pass-with-conditions / block, repeated in body.gate.
Three house rules shape every reply and are worth knowing before you parse one. A report is not the code, so the text says "the report claims" wherever the distinction matters. Severity is never invented — the model may argue a severity is wrong, but as an argument, not by restating it. And a suppressed result is treated as a question rather than as absent: it gets named, with a view on whether the suppression looks defensible.
One worked example per lane
In each request below, report_digest is the stringified digest from the section above and facts is the object next to it. Both are elided here only for width.
task: "triage" — Call each finding real or not
Request
{"task": "triage", "report_digest": "{\"report\":{...},\"totals\":{...}}",
"context": "Node 20 billing service, ships Thursday. The invoice path handles card data.",
"config_language": "codeql", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "triage",
"title": "CodeQL + gitleaks, billing service - 1,994 results, 5 sampled",
"verdict": "mixed",
"summary": "This report carries a credential: R-003 is a live-looking API key in config/staging.env, so the log itself is now a secret and that key must be rotated before anything else. Of the five results sampled, two SQL-injection hits carry dataflow paths and read as real...",
"assumptions": ["config/staging.env is deployed, not a template"],
"open_questions": ["Is the internal gateway that justifies the R-014 suppression reachable from the public edge?"],
"findings": [{"id":"SDF-001","severity":"critical","rule_id":"generic-api-key","ref":"R-003",
"location":"config/staging.env:12",
"issue":"The report embeds the matched credential, so the SARIF file is itself a secret",
"why":"This log is in the CI artifact store and in whatever chat it was pasted into",
"fix":"Rotate the key, then re-scan with the snippet region disabled"}],
"reconciliation": [
{"flag_id":"SD29","status":"confirmed","note":"R-003 is a generic API key with the value in the snippet."},
{"flag_id":"SD15","status":"confirmed","note":"No result carries partialFingerprints, so nothing can be de-duplicated across runs."},
{"flag_id":"SD23","status":"confirmed","note":"js/unused-local-variable is 902 of 1,994 results."},
{"flag_id":"SD18","status":"not-assessed","note":"Only one of the seven suppressed results was sampled."}
],
"next_lane": "remediate",
"body": {
"calls": [
{"ref":"R-001","rule_id":"js/sql-injection","location":"src/billing/invoice.js:142",
"call":"true-positive","confidence":"high",
"exploitability":"Any caller of GET /invoices/:id controls the interpolated value",
"reasoning":"The snippet shows template interpolation straight into db.query, and the digest reports a four-step dataflow path",
"evidence_needed":""},
{"ref":"R-002","rule_id":"js/sql-injection","location":"src/api/routes.js:88",
"call":"needs-context","confidence":"medium",
"exploitability":"Depends on whether the route is behind the gateway",
"reasoning":"Six dataflow steps but no snippet was sampled, so the sink is unverified",
"evidence_needed":"The snippet at routes.js:88 and the middleware chain for that route"},
{"ref":"R-027","rule_id":"js/unused-local-variable","location":"src/billing/invoice.js:9",
"call":"suppress","confidence":"high","exploitability":"None; this is a maintainability rule",
"reasoning":"Real but not worth a finding at 902 hits - it belongs in the tune lane, not here",
"evidence_needed":""}
],
"uncovered_note": "1,989 of 1,994 results were not sampled, almost all of them the two highest-count rules; nothing here speaks to them."
}
}
One call per entry in sampled_results, in the SAME order, none skipped and none
added. `confidence: "high"` is only allowed where the digest actually showed
enough - a snippet, a dataflow path, a precise rule. A rule id and a file name
alone is low or medium, and `evidence_needed` must then say what is missing.
task: "remediate" — Plan the fixes, in order
Request
{"task": "remediate", "report_digest": "{...}", "context": "...",
"config_language": "codeql", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "remediate",
"verdict": "plan-with-unknowns",
"body": {
"plan": [
{"order":1,"step":"Rotate the staging API key and invalidate the leaked value",
"addresses":["R-003"],"control":"credential rotation",
"change":"Issue a new key in the provider console, update the staging secret store, revoke the old key. Do not edit config/staging.env in place - the old value stays in git history.",
"effort":"S","owner_hint":"platform on-call",
"verification":"The old key returns 401 against the provider's whoami endpoint"},
{"order":2,"step":"Parameterise both invoice-lookup queries",
"addresses":["R-001","R-002"],"control":"parameterised queries",
"change":"Replace the template literal with db.query(\"SELECT * FROM invoices WHERE id = $1\", [req.params.id]) in invoice.js:142 and the equivalent in routes.js:88",
"effort":"M","owner_hint":"billing team",
"verification":"js/sql-injection returns zero results on the next scan, and a request with id=1%20OR%201=1 returns 400"}
],
"sequencing_note": "The rotation is first even though the injections score higher: the key is already exposed, while the injections need a request. Severity and risk-reduction-per-unit-of-effort disagree here, and the exposure wins.",
"accepted_risks": ["The 318 missing-rate-limiting results are not addressed in this plan; they need an edge decision, not a code change."]
}
}
Order by risk reduction per unit of effort, not by severity alone - and say so in
`sequencing_note` when the two disagree. A rotation always precedes a code fix.
Twelve hits of one rule in one file are ONE step, not twelve, and `addresses`
lists the refs it closes. Every `verification` must be something that could
actually fail; "confirm it is fixed" is not one.
task: "tune" — Cut the noise this scanner makes
Request
{"task": "tune", "report_digest": "{...}", "context": "...",
"config_language": "codeql", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "tune",
"verdict": "tunable",
"body": {
"config_language": "codeql",
"rules": [
{"rule_id":"js/unused-local-variable","action":"scope",
"rationale":"902 of 1,994 results, 45% of the whole report, and every sampled hit is in generated or vendored code",
"config_snippet":"paths-ignore:\n - \"**/generated/**\"\n - \"src/vendor/**\"",
"expected_noise_drop":"about 610 of 902 results","review_after":"one release"},
{"rule_id":"js/missing-rate-limiting","action":"downgrade",
"rationale":"318 results, security-severity 5.0 - a real security rule, so it is not suppressed; its level drops while the edge decision is pending",
"config_snippet":"query-filters:\n - include:\n id: js/missing-rate-limiting\n severity: warning",
"expected_noise_drop":"0 results; this changes the level, not the count","review_after":"when the gateway audit lands"},
{"rule_id":"js/sql-injection","action":"keep",
"rationale":"3 results, security-severity 9.8, precision high - nothing to tune",
"config_snippet":"","expected_noise_drop":"none","review_after":""}
],
"policy_note": "The policy is: security rules are scoped or downgraded, never disabled...",
"combined_config": "paths-ignore:\n - \"**/generated/**\"\n - \"src/vendor/**\"\nquery-filters:\n - include:\n id: js/missing-rate-limiting\n severity: warning"
}
}
One entry per rule in the digest's `rules` array, using the ids and hit counts you
sent. A rule with a security-severity or a `security` tag is a security rule and
is NEVER suppressed or disabled to reduce noise - it is scoped or downgraded, and
what is given up is stated. `expected_noise_drop` is arithmetic on the counts you
sent, not a feeling. When SD23 fired, the busiest rule is the first entry.
`config_snippet` must be valid in `config_language`; where the exact syntax is
uncertain the fragment shrinks and the uncertainty goes to `open_questions`.
task: "report" — Write the release-gate report
Request
{"task": "report", "report_digest": "{...}",
"context": "Release 4.12 goes out Thursday. Reviewer is the release manager.",
"config_language": "generic", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "report",
"verdict": "block",
"body": {
"gate": "block",
"gate_reason": "The report carries a live-looking credential (R-003) and two critical injection findings with dataflow paths in the billing path. Either alone would attach conditions; together they block.",
"audience": "the release manager and the billing on-call engineer",
"sections": [
{"heading":"What was scanned, and what was not",
"body":"Two runs over 214 files: CodeQL (1,961 results) and gitleaks (33)...\n\nNo result carries a fingerprint, so this report cannot be diffed against the last one..."},
{"heading":"What the report found",
"body":"1,994 results: 2 critical, 36 high, 1,420 medium, 536 low..."},
{"heading":"What is being accepted","body":"The 318 rate-limiting results..."},
{"heading":"What happens next","body":"Rotate, then patch, then re-scan..."}
],
"top_risks": [
"A staging API key is readable in the SARIF log, which is in the CI artifact store",
"Two user-controlled SQL sinks in the invoice path, both with dataflow paths"
],
"conditions": [
"The key in config/staging.env is rotated and the old value revoked",
"R-001 and R-002 are parameterised and a re-scan returns zero js/sql-injection results"
],
"release_note": "Release 4.12 is blocked on the security gate. The static-analysis log for this build contains a live-looking staging API key, so the log is being treated as a secret and the key is being rotated..."
}
}
`body.gate` repeats `verdict`. A blocker in facts.flags - a failed run, a report
carrying a credential, results with no locations - can force `block` on its own,
because an untrustworthy report cannot clear a release. `conditions` must be empty
when the gate is a clean `pass` and non-empty otherwise. `release_note` is plain
text a human pastes into a ticket: no markdown headings, no JSON, under 1200 chars.
Step by step
1. A tiny client helper
Nine lines of plumbing, reused by every step below. Replace YOUR_TOKEN with the token from step 2 — read it from your secret store or your environment at run time, and keep it out of source control and out of your shell history.
# Every call below uses these two. The token comes from step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read())
if not payload.get("ok"):
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Falls back to the placeholder so the snippet runs as written.
func token() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class SarifDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.method(method, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // parse with your JSON library; check "ok" before "data"
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $method, string $path, ?array $body = null): array {
$headers = ["Authorization: Bearer " . TOKEN];
$opts = ["http" => ["method" => $method, "ignore_errors" => true]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class SarifDesk {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null) {
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
if (!root.GetProperty("ok").GetBoolean()) {
var err = root.GetProperty("error");
throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
}
return root.GetProperty("data").Clone();
}
}
2. Get a token
A guest token is free to mint and is enough for /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in — the app has a token page that reveals, copies and replaces the token this browser already holds, so you never need a storage inspector.
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"sarif-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_..."}}
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "sarif-desk"}).encode(),
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as r:
TOKEN = json.loads(r.read())["data"]["token"]
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "sarif-desk" })
});
const TOKEN = (await res.json()).data.token;
b, _ := json.Marshal(map[string]string{"slug": "sarif-desk"})
res, _ := http.Post(base+"/guest", "application/json", bytes.NewReader(b))
defer res.Body.Close()
// decode into envelope, then env.Data -> {"token": "...", "guest_id": "..."}
String body = "{\"slug\":\"sarif-desk\"}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
String json = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()).body();
// json.data.token is your guest token
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, JSON.generate({ "slug" => "sarif-desk" }),
"Content-Type" => "application/json")
guest_token = JSON.parse(res.body)["data"]["token"]
<?php
$opts = ["http" => [
"method" => "POST",
"header" => "Content-Type: application/json",
"content" => json_encode(["slug" => "sarif-desk"]),
]];
$raw = file_get_contents("https://api.skillsafe.ai/v1/app-api/guest", false,
stream_context_create($opts));
$token = json_decode($raw, true)["data"]["token"];
var content = new StringContent("{\"slug\":\"sarif-desk\"}", Encoding.UTF8, "application/json");
var res = await Client.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", content);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var token = doc.RootElement.GetProperty("data").GetProperty("token").GetString();
3. Check who you are and what you can spend
Free. Returns subject_type (user or guest), credits, and the profile when there is one. Compare credits against the min_credits from step 4 before you submit; that is how the web app keeps its run button from ever posting into a 402.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":48210,...}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
fmt.Println(string(data))
String me = call("GET", "/me", null);
System.out.println(me);
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the lane you are about to run
Free, and it creates no job. Estimate the same input you are about to run, task included: hold_credits differs per lane, because a tune reply that writes a config for twenty-three rules and a triage reply that writes a call for sixty sampled results have different output caps. Pricing triage and then running report gives you a number that means nothing.
# input.json is the whole input object: task, report_digest (a STRING),
# context, config_language, facts.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":5240,"min_credits":320,"sponsor_enabled":false}}
digest = build_digest(sarif) # your code: totals, rules, sampled_results
facts = build_facts(sarif) # your code: totals, coverage, flags[]
INPUT = {
"task": "triage",
"report_digest": json.dumps(digest), # a STRING, not an object
"context": "Node 20 billing service, ships Thursday.",
"config_language": "codeql",
"facts": facts,
}
est = call("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], est["min_credits"])
# Free, and it creates no job. Re-estimate for every lane you intend to run:
# hold_credits differs per task because the prompts and output caps differ.
const INPUT = {
task: "triage",
report_digest: JSON.stringify(digest), // a STRING, not an object
context: "Node 20 billing service, ships Thursday.",
config_language: "codeql",
facts
};
const est = await call("POST", "/estimate", INPUT);
console.log(est.model_alias, est.hold_credits, est.min_credits);
digestJSON, _ := json.Marshal(digest) // report_digest is a STRING
input := map[string]any{
"task": "triage",
"report_digest": string(digestJSON),
"context": "Node 20 billing service, ships Thursday.",
"config_language": "codeql",
"facts": facts,
}
data, err := call("POST", "/estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(data))
// report_digest is a JSON STRING: serialise the digest, then embed it as a value.
String inputJson = mapper.writeValueAsString(Map.of(
"task", "triage",
"report_digest", mapper.writeValueAsString(digest),
"context", "Node 20 billing service, ships Thursday.",
"config_language", "codeql",
"facts", facts));
String est = call("POST", "/estimate", inputJson);
System.out.println(est);
input = {
"task" => "triage",
"report_digest" => JSON.generate(digest), # a STRING, not a Hash
"context" => "Node 20 billing service, ships Thursday.",
"config_language" => "codeql",
"facts" => facts
}
est = call("POST", "/estimate", input)
puts "#{est["model_alias"]} #{est["hold_credits"]} #{est["min_credits"]}"
<?php
$input = [
"task" => "triage",
"report_digest" => json_encode($digest), // a STRING, not an array
"context" => "Node 20 billing service, ships Thursday.",
"config_language" => "codeql",
"facts" => $facts,
];
$est = call("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], "\n";
var input = new Dictionary<string, object> {
["task"] = "triage",
["report_digest"] = JsonSerializer.Serialize(digest), // a STRING
["context"] = "Node 20 billing service, ships Thursday.",
["config_language"] = "codeql",
["facts"] = facts
};
var est = await Call(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
5. Run a lane and poll it
Metered. Submit, take job_id, then poll GET /jobs/{job_id} until status is succeeded, failed or cancelled. output.output is the JSON string described in the output contract — parse it, do not regex it. Put the lane in the Idempotency-Key: the web app builds sarif-desk:{lane}:{hash of digest + context + config_language}:a{attempt}, so two lanes over one report can never collide and its single reformat retry gets its own key.
# 1. submit
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: sarif-desk:triage:$(shasum -a 256 input.json | cut -c1-16):a1" \
-d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll to terminal
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee /dev/stderr | grep -q '"status":"succeeded"'; do sleep 2; done
import hashlib, time
key = "sarif-desk:" + INPUT["task"] + ":" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
req = urllib.request.Request(BASE + "/run",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
result = json.loads(job["output"]["output"]) # the envelope from the contract
# The two checks the web app makes on every reply, and you should too.
sent_flags = {f["id"] for f in INPUT["facts"]["flags"]}
got_flags = {r["flag_id"] for r in result["reconciliation"]}
assert sent_flags == got_flags, ("unreconciled", sent_flags - got_flags)
known_refs = {r["ref"] for r in digest["sampled_results"]}
quoted = {c.get("ref") for c in result["body"].get("calls", [])}
fabricated = {r for r in quoted if r and r not in known_refs}
print(result["verdict"], "fabricated refs:", sorted(fabricated))
const key = `sarif-desk:${INPUT.task}:${hash(JSON.stringify(INPUT))}:a1`;
const res = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const { job_id } = (await res.json()).data;
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job_id}`);
} while (!["succeeded", "failed", "cancelled"].includes(job.status));
const result = JSON.parse(job.output.output);
// Audit the refs before you render anything.
const known = new Set(digest.sampled_results.map(r => r.ref));
const bad = (result.body.calls || []).filter(c => c.ref && !known.has(c.ref));
console.log(result.verdict, "fabricated refs:", bad.map(c => c.ref));
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "sarif-desk:triage:"+hash(b)+":a1")
// submit, read data.job_id, then GET /jobs/{id} every 2s until status is terminal,
// then json.Unmarshal(data.output.output) and check every ref against your digest.
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "sarif-desk:triage:" + hash(inputJson) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
String submitted = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, poll GET /jobs/{id}, then parse data.output.output
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "sarif-desk:#{input["task"]}:#{hash(input)}:a1"
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
job = nil
loop do
job = call("GET", "/jobs/#{job_id}")
break if %w[succeeded failed cancelled].include?(job["status"])
sleep 2
end
result = JSON.parse(job["output"]["output"])
<?php
$opts = ["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: sarif-desk:triage:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1",
]),
"content" => json_encode($input),
]];
$raw = file_get_contents(BASE . "/run", false, stream_context_create($opts));
$jobId = json_decode($raw, true)["data"]["job_id"];
// then poll GET /jobs/{id} until status is terminal, and json_decode
// data.output.output into the envelope
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"sarif-desk:triage:{Hash(input)}:a1");
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
// read data.job_id, poll GET /jobs/{id}, then parse data.output.output
6. Or stream it
The same run, delivered as SSE. Three event types: job once the run is accepted, delta for each chunk of text, and result at the end carrying the full output and charged_credits. The web app maps the arrival of the envelope keys onto its five progress stages — reserving credits, reading the report, weighing each finding, naming the evidence gaps, reconciling with the browser's flags — and you can do the same by watching for "verdict", then "findings", then "reconciliation" to appear in the accumulating text.
curl -N -s -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: sarif-desk:triage:abc123def4567890:a1" \
-d @input.json
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"task\":\"triage\","}
# event: result data: {"output":{"output":"..."},"charged_credits":3180}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf, event = "", None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
buf += payload["text"]
elif event == "result":
buf = payload["output"]["output"]
result = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "", event = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") text += payload.text;
if (event === "result") text = payload.output.output;
}
}
}
const result = JSON.parse(text);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
// "event: delta" then "data: {...}" - accumulate payload.text
_ = line
}
HttpResponse<java.util.stream.Stream<String>> res =
CLIENT.send(streamRequest, HttpResponse.BodyHandlers.ofLines());
StringBuilder text = new StringBuilder();
res.body().forEach(line -> {
// "event: delta" then "data: {...}" - append the "text" field
});
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(stream_req) do |res|
res.read_body do |chunk|
# split on newlines, read "event:" then "data:" lines
end
end
end
<?php
$stream = fopen(BASE . "/run-stream", "r", false, stream_context_create($opts));
while (($line = fgets($stream)) !== false) {
// "event: delta" then "data: {...}"
}
fclose($stream);
using var stream = await Client.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await stream.Content.ReadAsStreamAsync());
string? line;
while ((line = await reader.ReadLineAsync()) != null) {
// "event: delta" then "data: {...}"
}
Four things worth knowing
Estimate is free and creates no job. It returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits, min_credits and sponsor_enabled. Compare hold_credits against the balance from /me before you submit; a 402 after submitting is a bug in your client, not in the user's wallet.
A run between min_credits and hold_credits still executes, with a reduced output cap, and returns "truncated": true. On triage that shows up as fewer calls than sampled_results — surface it rather than presenting a partial triage as a complete one.
A reply that does not parse is worth exactly one retry. The web app re-sends the identical input with a retry_note naming the parse failure and a fresh Idempotency-Key ending :a2, and stops there. Two retries on one input is a loop, and it bills twice.
The API executes nothing. No scanner is run, no repository is read, no code is compiled. Every reply is a reading of the digest and the facts you sent — which is why a digest built from a truncated log produces a confident answer about a report that does not exist, and why the prompt forbids claiming the code does anything, only that the report claims it.