← Clj Desk / API
Tokens

Drive Clj Desk from your own code

Base URL https://api.skillsafe.ai/v1/app-api. One Clojure namespace goes in, one structured result comes back. Everything on this page uses the same token the web app uses, which you can read off the tokens page.

The task field comes first

This app has four lanes over one work object, and task selects which one you get. It is the field to decide before any other: it changes the body key you get back, the posture vocabulary, the area vocabulary, the artifacts, and the price. Omit it and the model picks the lane your input best fits and names its choice in verdict — convenient interactively, not something to rely on in a script.

taskLaneWhat it readsBody keypostureArtifacts
reviewStyle reviewEvery definition in file order: naming (nouns for pure functions, ! for side effects, ? for predicates, kebab-case, clojure.core shadowing), docstrings on public vars, private-by-default, form length and nesting, the style guide’s “use that instead” table, and the REST, module and Toucan conventions where present.vars[]mergeable / mergeable-with-changes / needs-reworka rewritten form, a paste-ready PR comment block
docstringsDocstring contractsThe prose on its own. Every sentence against the rewrite test and the ownership test; implementation context relocated to an inline comment rather than deleted; only blather deleted. Per-var verdict plus the full replacement text.docstrings[]contracts-clear / contracts-mixed / contracts-misleadingone Clojure block of rewritten defn heads
schemasMalli schemasRoute params, query params, body and response for every endpoint — or, with no endpoints, the fn-arg and fn-return schemas of the mu/defn surface. Checks the validation-timing trap first.schemas[]fully-specified / partly-specified / unspecifiedthe mr/def registrations and rewritten endpoint heads
mutantsMutation coverageNamed mutants per operator — conditional boundary, negated conditional, arithmetic, constant, boolean, return value, collection, guard removal — each marked killed, survived or uncertain against the assertions you actually pasted.mutants[]well-covered / partly-covered / weakly-covereddeftest forms that kill the survivors

The envelope

Every response has the same shape. Check ok before touching data.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}
HTTPerror.codeWhat to do
401UNAUTHORIZEDThe token is missing, malformed or expired. Get a fresh one from the tokens page.
402INSUFFICIENT_CREDITSThe balance is below min_credits. /estimate is free, so check it first.
403FORBIDDENA guest token tried a metered call. /run and /run-stream need a personal token.
404NOT_FOUNDUsually a job id that does not exist, or a mistyped path.
409IDEMPOTENCY_CONFLICTThe same Idempotency-Key was reused with a different body. Change the key or send the original body.
422VALIDATION_ERRORThe input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key.
429RATE_LIMITEDBack off and retry; do not tight-loop.
503UPSTREAM_UNAVAILABLEThe model provider is briefly unavailable. Retry with the same idempotency key.

Input fields

The request body is the input object itself. Do not wrap it in an input key: a wrapped body returns 200 while hiding task from the model, so you silently get whichever lane it guessed.

FieldTypeRequiredMeaning
taskstringrecommendedOne of review, docstrings, schemas, mutants.
sourcestringyesThe Clojure text. Two namespaces in one string is the normal case — a source file and its test file pasted back to back are told apart by content, because the reader groups top-level forms under whichever ns form precedes them. A ;; file: name.clj line is honoured as a separator but never required.
notesstringnoWhat you are about to do with the namespace. Short and concrete sharpens the result a lot.
prescanobjectnoThe deterministic facts and flags the browser’s Clojure reader computes: the ns form and its aliases, every definition with its arglists and docstring size, the per-function mutation surface, the anti-pattern shapes, the test cross-reference. The web app always sends it, and the prompt requires one coverage_check entry per flag id. Omit it and you get a result with an empty coverage_check — still useful, but nothing holds the model to arithmetic.
clip_notestringnoSet this when you have truncated a long namespace yourself, so the model knows what it is missing. Cut on whole top-level forms; half a function is worse than no function.

Output contract

job.output is a JSON string; parse it. Inside is one object: a common envelope shared by all four lanes, plus that lane’s own body key from the table above. Taken from the parser the web app itself uses.

{
  "task":           "review" | "docstrings" | "schemas" | "mutants",
  "title":          string,
  "posture":        one of the lane's three values,
  "confidence":     "high" | "medium" | "low",
  "verdict":        string,                     // one sentence, PR-comment ready
  "exec_summary":   string,
  "findings":       [{"id":       "R-001" | "D-001" | "S-001" | "M-001",
                      "severity": "critical" | "high" | "medium" | "low",
                      "area":     one of the lane's areas,
                      "target":   string,       // the var, the route, the line
                      "title":    string,
                      "evidence": string,
                      "impact":   string,
                      "remedy":   string,
                      "blocks":   boolean,      // must land before merge
                      "cites":    [string]}],   // reader flag ids and var names
  "coverage_check": [{"id":     prescan flag id,
                      "status": "confirmed" | "set-aside" | "contradicted",
                      "note":   string}],
  "artifacts":      [{"name": string, "language": "clojure" | "markdown" | "csv" | "edn" | "text",
                      "content": string}],
  "assumptions":    [string],
  "open_questions": [string],
  "next_steps":     [string],
  "summary":        string,

  // exactly one of these, matching "task":
  "vars":       [{"name","visibility","kind","lines","grade","issue","note"}],
  "docstrings": [{"var","verdict","grade","was","now","relocated_to","issue"}],
  "schemas":    [{"target","layer","current","proposed","grade","issue"}],
  "mutants":    [{"id","target","operator","mutation","status","killed_by","note"}]
}
EnumValues
gradeok · watch · broken
visibilitypublic · private
kindfn · macro · multi · method · protocol · record · type · interface · var · schema · endpoint · setting · test
docstrings[].verdictkeep · tighten · relocate · delete · add
schemas[].layerroute-param · query-param · body · response · fn-arg · fn-return
mutants[].operatorconditional-boundary · negate-conditional · arithmetic · boolean-literal · constant · return-value · collection · guard-removal
mutants[].statuskilled · survived · uncertain
area, reviewnaming · documentation · organization · style · tests · modules · rest-api · database · correctness · portability
area, docstringscontract · implementation-leak · ownership · duplication · absent · formatting · style
area, schemasroute-params · query-params · body · response · timing · nullability · reuse · error-messages · naming
area, mutantsconditional-boundary · negate-conditional · arithmetic · boolean-literal · constant · return-value · collection · guard-removal · coverage-gap

1. A tiny client helper

Every endpoint below returns the same {ok, data, error} envelope and takes the same two headers, so one small helper covers the whole API. Get your token from the tokens page — no developer console required.

# Every call needs the same two headers. Keep the token out of your shell history:
# read it from a file you control, or paste it into a variable in a subshell.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

call() {  # call <method> <path> [json]
  curl -sS -X "$1" "$BASE/$2" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    ${3:+--data "$3"}
}

2. Check the session and the balance

GET /me tells you whether the token is a guest or a personal session, and what the credit balance is. Do this before a metered call so a shortfall is something you handle rather than a 402 you are surprised by.

call GET me

3. Price a lane for free

POST /estimate costs nothing and starts no job. It returns hold_credits (what will be reserved, priced against the full output cap), min_credits, and the model binding. The hold differs per lane, because the lanes have different prompts and output caps — estimate the lane you are actually about to run.

# /estimate is FREE and runs no job. It is the honest way to price a lane.
SRC=$(python3 -c 'import json,sys;print(json.dumps(open("reports_api.clj").read()))')
call POST estimate "{\"task\":\"review\",\"source\":$SRC}"

4. Run a lane and poll for it

POST /run is metered and returns a job_id; poll GET /jobs/{id} until status is succeeded or failed. Always send an Idempotency-Key: it makes a retry after a network blip free instead of double-billing. Include the lane in the key — two lanes over one namespace are two distinct runs and must not collide.

# Metered. The Idempotency-Key makes a retry safe: the same key returns the
# same job instead of billing twice.
KEY="clj-desk:review:$(shasum -a 256 reports_api.clj | cut -c1-16):a0"
SRC=$(python3 -c 'import json,sys;print(json.dumps(open("reports_api.clj").read()))')
JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data "{\"task\":\"review\",\"source\":$SRC}" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while :; do
  OUT=$(call GET "jobs/$JOB")
  echo "$OUT" | grep -q '"status":"succeeded"' && break
  echo "$OUT" | grep -q '"status":"failed"' && { echo "$OUT"; exit 1; }
  sleep 2
done
echo "$OUT"

5. Stream it instead

POST /run-stream returns server-sent events. A full lane takes tens of seconds, so streaming lets you show progress. Concatenate every delta, then parse the accumulated text as one JSON object. The same Idempotency-Key rules apply.

# Server-sent events. Each data: line carries a delta; the terminal event
# carries the whole output. Useful because a full review takes tens of seconds.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data "{\"task\":\"mutants\",\"source\":$SRC}"

One worked example per lane

The same namespace, four requests. Only task changes.

task: "review" — Style review

// request
{"task": "review",
 "source": "(ns acme.reports.api\n  \"HTTP surface for saved reports.\"\n  ...)",
 "notes": "Opening a PR tomorrow.",
 "prescan": {"facts": {...}, "flags": [{"id": "C-NO-DOCSTRING:score", ...}], "counts": {...}}}

// response body, abridged
{"task": "review", "posture": "mergeable-with-changes", "confidence": "high",
 "verdict": "Two blockers before merge: a process-wide atom and a mutating fn with no bang.",
 "findings": [{"id": "R-001", "severity": "high", "area": "organization",
               "target": "cache", "blocks": true, "cites": ["C-TOP-LEVEL-ATOM:21"], ...}],
 "vars": [{"name": "score", "visibility": "public", "kind": "fn", "lines": 9,
           "grade": "watch", "issue": "No docstring on a public ranking function.",
           "note": "Turns a report map into a ranking number."}],
 "coverage_check": [{"id": "C-NO-DOCSTRING:score", "status": "confirmed", "note": "..."}],
 "artifacts": [{"name": "pr-comments.md", "language": "markdown", "content": "..."}]}

task: "docstrings" — Docstring contracts

// request
{"task": "docstrings", "source": "...", "prescan": {...}}

// response body, abridged
{"task": "docstrings", "posture": "contracts-mixed",
 "verdict": "Four contracts are fine; one narrates the implementation and asserts conformance.",
 "docstrings": [{"var": "normalize-tags",
                 "verdict": "tighten",
                 "grade": "watch",
                 "was": "This function loops over the tags and internally lowercases ...",
                 "now": "The tags, lower-cased, blanks removed, duplicates dropped, order kept.",
                 "relocated_to": "an inline comment above the ->> thread, where distinct+vec is chosen",
                 "issue": "Every sentence describes the implementation or asserts a default."}],
 "artifacts": [{"name": "docstrings.clj", "language": "clojure", "content": "..."}]}

Note the key is literally "var". was is the current docstring verbatim; now is the complete replacement text, not a diff. A var with no docstring comes back with verdict: "add" and an empty was.

task: "schemas" — Malli schemas

// request
{"task": "schemas", "source": "...", "prescan": {...}}

// response body, abridged
{"task": "schemas", "posture": "partly-specified",
 "verdict": "The response schema will fail on every request: ms/TemporalString cannot match a Java Time object.",
 "findings": [{"id": "S-001", "severity": "critical", "area": "timing",
               "target": "::Report", "blocks": true, ...}],
 "schemas": [{"target": "::Report", "layer": "response",
              "current": "[:created_at ms/TemporalString]",
              "proposed": "[:created_at :any]",
              "grade": "broken",
              "issue": "Response schemas validate before JSON serialisation."},
             {"target": "POST /report", "layer": "query-param",
              "current": "",
              "proposed": "[:map [:archived {:default false} [:maybe ms/BooleanValue]]]",
              "grade": "broken",
              "issue": "No schema on any parameter position."}],
 "artifacts": [{"name": "schemas.clj", "language": "clojure", "content": "..."}]}

task: "mutants" — Mutation coverage

// request
{"task": "mutants", "source": "the namespace AND its test namespace", "prescan": {...}}

// response body, abridged
{"task": "mutants", "posture": "weakly-covered",
 "verdict": "Nine of fourteen mutants survive; score is never named in a test.",
 "mutants": [{"id": "M3", "target": "score", "operator": "conditional-boundary",
              "mutation": "(> views 1000) -> (>= views 1000)",
              "status": "survived", "killed_by": "",
              "note": "ranking-test only compares two reports at 2000 and 5 views, so no assertion sits on the boundary."},
             {"id": "M1", "target": "get-report-title", "operator": "negate-conditional",
              "mutation": "(not (nil? (:name report))) -> (nil? (:name report))",
              "status": "killed", "killed_by": "get-report-title-test",
              "note": "Both branches are asserted, so inverting the test flips both results."}],
 "artifacts": [{"name": "killing-tests.clj", "language": "clojure", "content": "..."}]}

A killed row always names a real deftest from the source you sent. If it cannot, the status comes back uncertain instead — nothing here runs a mutation harness, so a guess would be worse than an honest gap. Send the test namespace: without it every status is uncertain and the lane becomes test authoring rather than coverage analysis.

Costs, and what not to do on a schedule

/me, /estimate and /guest are free and start no job. /run and /run-stream are metered against the caller’s wallet. The hold you see from /estimate prices the full output cap; what you are actually charged is usually far lower and comes back as charged_credits on the finished job.

The holds differ per lane, and by a lot: mutants and schemas write more output than review does. Estimate the lane you are about to run, not the one you ran last time. If you wire this into CI, send an Idempotency-Key derived from the file content and the lane so a re-run on an unchanged file costs nothing new, and gate the call on the file having actually changed.

What this app does not do

It reads Clojure text and returns judgement over it. There is no REPL, no nREPL, no clj-kondo, no test runner and no mutation harness behind this API. Nothing is evaluated, compiled, linted or measured by running it. A mutant’s status is a read of the assertions you sent; a response schema is derived from what the body plainly returns. The prompt forbids claiming otherwise and reports what only a real run could settle in open_questions.

Some conventions are the Metabase house style rather than universal Clojure — the module layout, the ms/* schema aliases, Toucan 2, api.macros/defendpoint. Where the code you send is plainly not a Metabase namespace, the general Clojure rule is applied and the finding says so.

Credits

Clj Desk is a derived work built on four agent skills published by the Metabase team: @metabase/clojure-review, @metabase/clojure-write, @metabase/add-malli-schemas and @metabase/mutation-testing. It is not a republication of those skills.