← Kotlin Test Clinic / API
Tokens

Drive Kotlin Test Clinic from your own code

Everything the web page does is available over HTTP: paste Kotlin source and its tests in, get the same structured test-quality review back. The review knows Kotest, JUnit and kotlin.test, MockK, coroutine testing, Flow collection, property-based testing and Kover. The natural use is a CI job that re-reviews whenever a spec changes, or a script that runs the same ten-check table across every module in a monorepo and fails the build when one of them drifts into untested-risk.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

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

Send your app slug as X-App-Slug: kotlin-test-clinic and your token as Authorization: Bearer … on every call.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — files is the usual one — or a field is the wrong type.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="kotlin-test-clinic"
TOKEN="YOUR_TOKEN"        # from https://kotlin-test-clinic.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "X-App-Slug: $SLUG" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. You never need to open the developer console.

A guest token can call /me and /estimate. Running a review is metered, so it needs a personal token from signing in.

# A guest token is enough for /me and /estimate. Running a review is metered and
# needs a personal token: open the token page and press "Sign in".
#
#   https://kotlin-test-clinic.skillsafe.ai/tokens.html
#
# That page also gives you a ready-made shell export:
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: kotlin-test-clinic"

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
filesstring, requiredThe pasted files — production Kotlin source, its test files, and optionally build.gradle.kts, libs.versions.toml or a CI workflow. Put a // file: OrderService.kt marker line above each one so they can be told apart (a # file: marker is equivalent). This is the review's only evidence. A file whose middle has been removed should say so with a [... N characters cut ...] comment.
focusstringgeneral, mocking, coroutines, flows, property-based, coverage or spec-style. Emphasis, not exclusivity: a high-severity finding from another area is never suppressed.
contextstring, optionalFree-form notes: what the module does, what keeps breaking, what the team argued about, CI constraints, the deadline.
prescan_factsobject{resources: [{id,label}], flags: [{id,label}]} — what a deterministic client-side scan established: files, spec classes and styles, test counts, mock declarations, suspend functions, Flow usages and test-stack dependencies in resources; checks that fired in flags. Every flags id must come back in coverage_check, which is how you hold the model to the facts.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The actual charge is normally far lower than the hold, because the hold prices the full output cap.

INPUT='{"files": "// file: OrderService.kt\nclass OrderService(private val payments: PaymentService) {\n    suspend fun placeOrder(req: OrderRequest): Order =\n        Order(req.userId, payments.charge(req.total).status)\n}\n\n// file: OrderServiceTest.kt\nclass OrderServiceTest : FunSpec({\n    val payments: PaymentService = mockk(relaxed = true)\n    test(\"placeOrder succeeds\") {\n        Thread.sleep(500)\n        OrderService(payments).placeOrder(OrderRequest(\"u1\", 40))\n    }\n})", "focus": "coroutines", "context": "Payments module, 4 engineers. The suite is green but a checkout bug shipped last week.", "prescan_facts": {"resources": [{"id": "spec:OrderServiceTest", "label": "FunSpec, 1 test"}, {"id": "src:suspend", "label": "1 suspend function: placeOrder"}], "flags": [{"id": "coroutines:thread-sleep", "label": "1 Thread.sleep call in test code"}, {"id": "tests:no-assertions", "label": "1 test body asserts nothing"}]}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":1890,"min_credits":280,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; the charge afterwards is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The review JSON is the string at data.output.output.

Always send an Idempotency-Key. Derive it from the input, as the web app does (kotlin-test-clinic:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run — which is what makes a CI retry safe. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="kotlin-test-clinic:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events. The web app uses it to advance a staged progress display as sections arrive, and to keep whatever parsed if the stream dies mid-flight. The final done event carries charged_credits and the truncated flag.

# Server-sent events. Each `delta` carries a chunk of the JSON review; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"review_name\":\"OrderService"}
# event: delta  {"text":" — test review\",\"verdict\":\"needs-work\","}
# event: done   {"status":"succeeded","charged_credits":438,"truncated":false}

7. Parse the review and check the reconciliation

Two invariants are worth enforcing on your side, because the app enforces them too: every focus_areas[].finding_ids entry must name a real finding id, and every prescan_facts.flags id must appear exactly once in coverage_check. A flag missing from the reconciliation means the model quietly skipped a fact you established — treat that as a failed run, not a passing one, and retry with a retry_note naming the missing ids.

The truncated flag on the done event (and on the finished job) means the reply hit the output cap. What you hold is a prefix, not a review: retry with a retry_note asking for fewer, denser findings rather than trying to repair the JSON.

# The review JSON is a string inside the envelope, so unwrap it twice.
REVIEW=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$REVIEW" | python3 -c '
import sys, json
r = json.load(sys.stdin)
print(r["verdict"], "|", r["verdict_line"])
print(r["stack"], r["test_file_count"], "test files,", r["test_case_count"], "test cases")
for c in r["checks"]:
    print(f"  {c[\"status\"]:8} {c[\"check\"]}")
for f in r["findings"]:
    print(f"  {f[\"id\"]} {f[\"priority\"]:8} {f[\"category\"]:14} {f[\"resource\"]}")
'

# Every prescan flag id must come back exactly once in coverage_check.
printf '%s' "$REVIEW" | python3 -c '
import sys, json
seen = [c["id"] for c in json.load(sys.stdin)["coverage_check"]]
want = ["coroutines:thread-sleep", "tests:no-assertions"]
missing = [i for i in want if seen.count(i) != 1]
if missing:
    raise SystemExit("unreconciled prescan flags: " + ", ".join(missing))
print("coverage_check reconciles")
'

The output contract

data.output.output is a JSON string holding one object. This is exactly what the web app parses, so anything that renders here will render there:

{
  "review_name": "OrderService — test review",
  "verdict":     "well-tested | needs-work | untested-risk",
  "verdict_line": "one sentence justifying the verdict and naming the thing that decides it",
  "stack":       "Kotest 5 + MockK + kotlinx-coroutines-test",
  "test_file_count": 1,
  "test_case_count": 1,
  "exec_summary":   "2-3 paragraphs separated by blank lines",
  "assumptions":    ["..."],
  "open_questions": ["..."],
  "inventory": [
    { "kind": "TestClass", "name": "OrderServiceTest", "value": "FunSpec, 1 test",
      "role": "sole coverage of OrderService" }
  ],
  "checks": [
    { "check": "Virtual time in coroutine tests", "status": "fail",
      "evidence": "Thread.sleep(500) in the test body",
      "requirement": "runTest with advanceTimeBy" }
  ],
  "findings": [
    {
      "id": "KT-001",
      "category":   "framework | spec-style | assertions | mocking | coroutines | flows | property-based | coverage | structure | flakiness",
      "severity":   "low | medium | high",
      "likelihood": "low | medium | high",
      "priority":   "critical | high | medium | low",
      "resource": "Test/'placeOrder succeeds'",
      "problem":  "...",
      "impact":   "...",
      "fix":      "...",
      "snippet":  "corrected Kotlin / Gradle fragment, or \"\""
    }
  ],
  "coverage_check": [
    { "id": "coroutines:thread-sleep", "addressed": true, "note": "KT-002." }
  ],
  "suggested_tests": "a ready-to-paste Kotest spec, as a JSON string",
  "commands":    ["./gradlew test  # the whole suite before pushing"],
  "quick_wins":  ["..."],
  "focus_areas": [{ "area": "...", "why": "...", "finding_ids": ["KT-001"] }],
  "summary": "closing paragraph"
}

Every key

keytypemeaning
review_namestringShort title naming the module and the outcome, e.g. "OrderService — test review".
verdictenumwell-tested, needs-work or untested-risk. The single value a CI gate should branch on.
verdict_linestringOne sentence justifying the verdict and naming the single thing that decides it.
stackstringThe test stack the paste shows, e.g. "Kotest 5 + MockK + kotlinx-coroutines-test", or "none visible". Never guessed.
test_file_countnumberHow many test files the paste contains.
test_case_countnumberHow many test cases those files declare.
exec_summarystringTwo to three paragraphs on the dominant themes, separated by blank lines.
assumptionsstring[]Explicit assumptions filling gaps in the paste — which module the Gradle file belongs to, whether CI runs the suite.
open_questionsstring[]Questions whose answers would change the review or its ordering.
inventoryobject[]{kind, name, value, role}. kind is one of SourceClass, SourceFunction, TestClass, SpecStyle, Test, Mock, SuspendFunction, Flow, Dependency, GradlePlugin, Command.
checksobject[]{check, status, evidence, requirement}. Always the same ten checks in the same fixed order — render by index, do not search by name.
findingsobject[]{id, category, severity, likelihood, priority, resource, problem, impact, fix, snippet}. Ids are sequential KT-001, KT-002, … Always at least one entry — for a well-tested paste they are the improvements worth making, honestly prioritized low. snippet is a pasteable Kotlin or Gradle fragment or "".
coverage_checkobject[]{id, addressed, note}. One entry per prescan_facts.flags id, exactly once, and no ids the prescan did not send. addressed: false means deliberately set aside, with the reason in note.
suggested_testsstringA complete, compilable Kotest spec for the highest-value gap the review found — imports included, spec style matching the project's, mocks stubbed with the right every/coEvery variant, and only classes and functions visible in the paste. "" when the paste gives nothing to anchor one on.
commandsstring[]Ordered shell commands, each with a trailing comment. Read-and-verify only — nothing that publishes, deploys or deletes. Always ends with ./gradlew test, or ./gradlew test koverVerify when the paste shows Kover.
quick_winsstring[]One-line changes worth doing immediately.
focus_areasobject[]{area, why, finding_ids}. Every id in finding_ids must exist in findings.
summarystringClosing paragraph: what to do first and what remains after that.

The enums

fieldvaluesnotes
verdictwell-tested, needs-work, untested-riskwell-tested: the suite runs, asserts behaviour, handles coroutines correctly, and the remaining findings are improvements. needs-work: real tests exist but named findings undermine their protection — flaky time handling, mock hygiene, assertion-free bodies, framework mixing. untested-risk: the pasted production code has no meaningful coverage in the paste, and the review's centre of gravity is the TDD plan and the first failing spec.
findings[].categoryframework, spec-style, assertions, mocking, coroutines, flows, property-based, coverage, structure, flakinessframework and spec-style are separate on purpose: mixing Kotest with JUnit in one module and mixing three Kotest spec styles are different problems with different migrations.
findings[].severity
findings[].likelihood
low, medium, highSeverity is how much protection is lost; likelihood is how often it bites — how reachable the gap is from the code as pasted.
findings[].prioritycritical, high, medium, lowSeverity by likelihood. critical is reserved for something meaning the tests are not protecting the code today: a suite that cannot run (Kotest on Gradle without useJUnitPlatform()), tests that pass without asserting anything on the core behaviour, or a suspend-stubbing misuse that keeps the suite green while the mocked path is never exercised.
checks[].statuspass, fail, partial, unknownunknown is a legitimate answer when the paste does not show enough to decide, and is preferred over a guess — no build.gradle.kts means the coverage gate is unknown, not fail. partial means the practice is present in some places and missing in others.

The ten checks

checks always carries these ten, in this order, on every run — so a table can be rendered by index and two reviews of the same module are diffable row by row:

1.  Single test framework                  6. Suspend stubs use coEvery/coVerify
2.  Consistent spec style                  7. Virtual time in coroutine tests
3.  Behaviour over implementation          8. Failure paths and edge cases tested
4.  Real values for data classes           9. Property-based tests where laws exist
5.  Explicit mock stubbing and lifecycle  10. Coverage gate configured

The review only cites what the paste actually contains: classes, functions, specs, test names, mocks, dependencies and Gradle blocks that appear in files. An absence — no test file for a pasted class, no runTest despite suspend functions, no Kover block — is itself a finding, named in resource or marked (missing from the paste). And commands is read-and-verify only: nothing in it publishes, deploys or deletes.

Your saved reviews

Every run the app completes is written to the reviews collection, so a review follows the user across devices. It is declared acl_read: owner and acl_write: user: rows are scoped to the calling subject, which means a script must reuse one token across the run and the query or it will see an empty collection. Each POST /guest mints a new guest subject, so guest tokens are not a way to share history.

fieldtypemeaning
uidstringThe app's own id for the run — stable across a re-render.
titlestringreview_name from the reply.
verdictstringwell-tested, needs-work or untested-risk.
stackstringThe test stack the paste showed, e.g. Kotest 5 + MockK.
input_hashstringHash of the submitted input — the cheap way to tell whether a module actually changed between runs.
findings_countnumberfindings.length.
critical_countnumberHow many findings came back priority: "critical".
test_countnumbertest_case_count from the reply — how many test cases the paste declared.
ran_attimestampWhen the run completed. The natural sort key.

Those nine fields are declared, and therefore filterable and orderable. The rest of the document — the whole review — round-trips intact but is not indexed. embed is ["title", "verdict", "stack"], so POST /collections/reviews/similar with a text or a record_id finds past reviews that read like this one: useful for "have we seen this test smell before?" across a fleet of modules.

Every where entry must be an operator object — eq, ne, lt, lte, gt, gte, in (up to 20 values) or contains. The bare-value shorthand {"verdict": "untested-risk"} is rejected. Records come back wrapped: data.records[].doc holds the fields, alongside a record_id.

# Your saved reviews, newest first.
call collections/reviews/query '{"sort":{"field":"ran_at","dir":"desc"},"limit":10}'

# Only the ones that came back untested-risk with something critical in them.
call collections/reviews/query '{"where":{"verdict":{"eq":"untested-risk"},"critical_count":{"gte":1}},
  "sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# Past reviews that read like this one, using the declared embed fields.
call collections/reviews/similar '{"text":"Thread.sleep in a Kotest spec that asserts nothing","limit":5}'

A CI gate

The verdict is the natural exit code. Fail the job when a module drifts into untested-risk, warn on needs-work, and pass on well-tested — with the Idempotency-Key derived from the input so a re-run of the same commit replays instead of re-billing. Comparing input_hash against the last row in the reviews collection tells you whether it is even worth spending the credits.

VERDICT=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(json.load(sys.stdin)["verdict"])')
CRITICAL=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(sum(1 for f in json.load(sys.stdin)["findings"] if f["priority"]=="critical"))')

case "$VERDICT" in
  untested-risk) echo "::error::Kotlin test review: untested risk ($CRITICAL critical)"; exit 1 ;;
  needs-work)    echo "::warning::Kotlin test review: needs work"; exit 0 ;;
  well-tested)   echo "Kotlin test review: well tested"; exit 0 ;;
esac