← Coroutine Desk / API
Tokens

Drive Coroutine Desk from your own code

The page reads the pasted Kotlin behind one feature in the browser for free — one or more files joined into one paste, each introduced by a // file: <path> line, plus build.gradle.kts (or a libs.versions.toml) for the Kotlin and kotlinx.coroutines versions — with its line numbers. It lexes strings, string templates, raw strings, char literals, backtick names and comments out first, rebuilds the brace structure (classes, funs, lambdas, loops, try/catch), matches every trailing lambda to its call, works out the scope and dispatcher of every line of coroutine code, and inventories every fun and whether it is suspend, every coroutine builder (launch, async, runBlocking, withContext, coroutineScope, flow, callbackFlow ...) with the scope and dispatcher it runs on, every call coroutine code makes that blocks its thread, every catch around suspending calls and whether it lets CancellationException through, every StateFlow, SharedFlow and Channel property and what is done to it, and every collect and whether code after it can run. Then it runs 17 deterministic checks drawn from coroutine practice (catalogue ids KC-01..KC-17, plus KC-18 for context): launch or async on GlobalScope or a throwaway CoroutineScope(...), runBlocking in coroutine code or on the main thread, Thread.sleep in coroutine code, blocking network, database and wait calls (OkHttp execute(), URL.readText(), JDBC, Future.get(), CountDownLatch.await(), BlockingQueue.take()) and blocking file I/O off Dispatchers.IO, catches and runCatching that swallow CancellationException, loops that never suspend or check for cancellation, async whose Deferred is never awaited, Job() or SupervisorJob() passed to a builder, callbackFlow without awaitClose, a MutableStateFlow holding a mutable collection or changed in place, value = value... instead of update { }, mutable flows exposed as public properties, code after collecting a flow that never completes, suspendCoroutine instead of suspendCancellableCoroutine, credential literals, and async { }.await() at once. Nothing is compiled or run: every flag is a reading of the text. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "fix", the paste as code and the page’s check as a facts string, and the model replies with one JSON object: an audit that rates the four areas, answers every flag and gives exact before/after edits per finding, or the whole fixed file(s) with a list of every change tied to the page’s flags.

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. A plain-text summary of the app for agents is at /llms.txt. Both lanes run on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

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 (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff 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: two lanes

task routes the run, and it takes exactly two values: "audit" and "fix". Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own Kotkit.buildInput turns any lane that is not "fix" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the code the way a senior Kotlin engineer reviews coroutine code before it ships: says what the feature does and what runs concurrently in it, rates the four areas (structure, threads, cancellation, flows) as sound, fragile or broken, confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what freezes, deadlocks or crashes first (runBlocking, Thread.sleep, loops that never suspend, callbackFlow without awaitClose), then blocking calls off Dispatchers.IO, then lost cancellation, failures and scopes, then flow state and collection, then hygiene — with a plan that ends in a test under runTest (kotlinx-coroutines-test) and a cancellation test.blocked | needs_work | readyBefore shipping: does every coroutine have a scope that cancels it, does anything block its thread, does cancellation get through every catch and loop, does state reach the collectors, and what should change first.
"fix"Writes the fixed version of the pasted code: the whole of every file it changes (at most one new file next to them), with coroutines moved onto a scope that owns them, blocking calls inside withContext(Dispatchers.IO) or replaced by delay, CancellationException rethrown, loops that suspend or check ensureActive(), awaitClose in every callbackFlow, and state held immutably and changed with update { } — plus a changes list tied to the page flags, the verify steps (the build, e.g. ./gradlew build, and the runTest test to write), and what it left open. Every fun keeps its name, parameters and suspend or not, and no public signature changes unless the note asks for it.fixed | partialWhen you want the code changed, not a list of findings: a feature you already know is broken, or what is left after an audit.

The page chains the two: after an audit, “Apply every edit and fix the rest” puts every audit edit into the paste, switches to the fix lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "fix", the edited paste as code, facts recomputed from the edited paste, and that string. The page drops the handoff as soon as the paste is edited again.

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read as Kotlin (“Nothing was pasted.”, “No Kotlin file was found. Paste .kt source, with a // file: <path>.kt line above each file.”, or “This does not read as Kotlin source: no fun, class, object, import or package was found.”), and it warns before a run on a paste with no coroutine code (“No suspend fun and no coroutine was found: there may be little coroutine code here to audit.”, or “... to fix.”).

Input fields

This is exactly what the page sends (kotkit.js buildInput(lane, A, note, handoff), checked by mustBeObject; app.js currentInput and run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string. buildInput emits them in the order task, code, facts, note, audit_handoff:

FieldTypeRequiredMeaning
taskstringyes"audit" or "fix" (the lanes above).
codestringyesThe pasted Kotlin, each file introduced by a // file: <path> line (# file: for a .toml version catalog); line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files, with a // [coroutine-desk: not sent: ...] line at the end (below).
factsstringyesThe page’s free check, Kotkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
audit_handoffstringnofix lane only: a JSON string built from an audit reply on this code (recon.js fixHandoff, below). buildInput drops it for the audit lane.
notestringnoWhat the engineer says: the goal, a constraint (“no new dependencies”, “leave the repository API alone”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against code 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 “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject only insists on a plain object that is not wrapped in {"input": ...}; it is buildInput that leaves a blank note out, which is why an empty note is simply absent.

The code string

code is the paste itself. A line // file: <path> or # file: <path> (a path with a . or / in it) starts each file, and a --- line just before it only separates files. Files dropped on the page (or a dropped module folder) are joined that way by Kotkit.joinFiles: each file under its // file: line (# file: for a .toml file), trailing whitespace removed, joined by newlines, ending in a newline. A paste with no file line is read as one file, Main.kt (or build.gradle.kts when it reads as a build file). The page normalises line endings to \n. A .kt or .kts path is checked as Kotlin; build.gradle(.kts), settings.gradle(.kts) and a .versions.toml are gradle (the Kotlin plugin version, the kotlinx-coroutines-* artifacts and their version give facts.build); anything else is other. Kotlin indents with four spaces and the paste keeps them. Send the text you want the reply to quote: every file in an audit reply must be a path from facts.files, every line must fall inside that file, every evidence must be copied from it, and every edit’s before must occur in it exactly once.

The page reads up to 400 000 characters (MAX_TEXT, cut at a line) and 40 files (MAX_FILES), and sends at most 60 000 characters (MAX_SEND): past that buildInput drops whole files from the end — the last file that is neither Kotlin nor a Gradle file first, then the last file — until the rest fits, cuts a single remaining file at a line (“the end of <path>”) if it still does not, and ends the string with one line naming what it left out:

// [coroutine-desk: not sent: app/src/main/java/com/acme/B.kt, app/src/main/java/com/acme/C.kt]

The model is told never to quote or rewrite what was not sent, and facts.notes gets the sentence “Not sent (the paste is over 60,000 characters): ... The page checked them; the run did not see them.” facts is still computed from the whole paste. The worked examples below are small, so their code is the paste unchanged.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: Kotkit.analyze splits the paste into files, lexes them, rebuilds their brace structure, works out the scope and dispatcher of the coroutine code, inventories it and runs the rules, and Kotkit.facts(A) turns that analysis into the object below (it does not depend on the lane: an audit and a fix run of the same paste carry the same facts). It is how the model knows which flags to answer, which file paths and line ranges to name, and which functions must survive. Its top-level keys, in the order built:

KeyContents
buildkotlin and coroutines (the versions a pasted build file declares, or null), coroutines_artifacts (core, android, test ...), gradle_pasted, android (the build or the code is Android: the Android Gradle plugin, androidx. or android. imports, viewModelScope, lifecycleScope), and counts: kotlin_files, functions, suspend_funs, builders, flows.
filesEvery file: path (use it verbatim), kind (kotlin, gradle, other), line and end_line (the paste lines of the file’s content, without its // file: line).
functionsEvery fun (up to 200), interface members included: name (Class.fun for members), file, line, suspend, private.
coroutinesEvery coroutine builder (up to 120): file, line, func, builder (launch, async, runBlocking, withContext, callbackFlow, stateIn ...), scope (the receiver it is called on, or null), dispatcher (io, default, main, unconfined, injected — a dispatcher passed in — or null when the code does not say), in_loop, kept (for async: whether its Deferred is used; null otherwise).
blocking_callsEvery call coroutine code makes that blocks its thread (up to 120): file, line, func, call, id (the catalogue id it falls under), dispatcher (the one it runs on; caller for a suspend fun with no withContext of its own: it runs on whatever dispatcher its caller uses).
catchesEvery catch around a suspending call in coroutine code (up to 60): file, line, func, type, suspends (the suspending calls it wraps), rethrows (it throws, checks for CancellationException or calls ensureActive(), or a CancellationException catch comes first), in_loop.
state_flowsEvery MutableStateFlow, MutableSharedFlow, StateFlow, SharedFlow and Channel property (up to 60): name, kind, file, line, visibility, holds_mutable, changed_in_place and read_modify_write (how many places do each).
collectsEvery collect (up to 60): file, line, func, flow, hot (a StateFlow or SharedFlow, which never completes), code_after (the first line after it in the same coroutine, or null).
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (KC-01..KC-18), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, only when it covers more than one). An audit must answer every non-info flag by its uid; a fix must close every high and medium flag or name it in open.
verdict_candidateThe audit verdict the flags point at (the page’s own reading): blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read or not sent (empty for a paste that fits).

The simplest source is kotkit.js itself: the page loads it from /kotkit.js, and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (with the 60 000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact them back into the page’s string byte for byte. Save the paste as orders-paste.txt and redirect the output into orders-check.json:

// build-check.js -- run: node build-check.js orders-paste.txt > orders-check.json   (kotkit.js saved next to it)
const fs = require("fs");
const Kotkit = require("./kotkit.js");
const A = Kotkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Kotkit.buildInput("audit", A, "", null);        // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
console.error(A.files.filter((f) => f.kind === "kotlin").length + " Kotlin file(s), " + A.suspend_funs + " suspend fun(s) of " + A.functions.length + " fun(s), " + A.builders.length + " coroutine builder(s), " + A.flows.length + " flow propert(ies); " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the orders example it reports 2 Kotlin file(s), 4 suspend fun(s) of 9 fun(s), 3 coroutine builder(s), 2 flow propert(ies); 6 flags (2 high, 3 medium, 1 low); verdict_candidate blocked; for the tracker example, 1 Kotlin file(s), 3 suspend fun(s) of 7 fun(s), 2 coroutine builder(s), 0 flow propert(ies); 3 flags (2 high, 1 medium, 0 low); verdict_candidate blocked. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so a minimal object works — "{}", or a hand-built one such as {"flags": [], "notes": ["No page check was run; read the code directly."]} — and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm, dismiss or close, with no files there are no paths and line ranges to name, with no build the model does not know the kotlinx.coroutines version or whether the code is Android, and with no functions nothing says what must survive a fix and stay suspend or not. Build it with kotkit.js as above when you can.

The audit_handoff string

Only for task: "fix", and only for the code the audit was about with every audit edit applied. The page builds both from the normalised audit reply res (recon.js normalize, below) and the analysis A of the audited paste, in recon.js fixHandoff:

const run = Recon.applyFindings(A, res.findings, Kotkit);   // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
  applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it

That is {"verdict", "headline", "findings": [{id, severity, file, line, title, why, edits}], "applied", "failed"}, where each finding’s edits is the number of edits it carried. applyFindings applies every finding’s edits with Kotkit.applyEdits, in the order listed: each before must occur exactly once in its file as the earlier edits left it (leading and trailing blank lines ignored), or that edit is reported as failed and skipped — it is never guessed. The handoff is a previous reply, not evidence: the model checks it against code and fixes what is left. This script does the whole step — the edited paste, its recomputed facts and the handoff string, in the same shape as build-check.js prints, so the samples read it the same way:

// handoff.js -- run: node handoff.js orders-paste.txt orders-reply.txt > orders-fix-check.json   (kotkit.js, recon.js next to it)
const fs = require("fs");
const Kotkit = require("./kotkit.js");
const Recon = require("./recon.js");
const A = Kotkit.analyze(fs.readFileSync(process.argv[2], "utf8"));           // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.fixHandoff(A, res);                                            // every edit, in order
const B = Kotkit.analyze(ho.text);                                              // facts recomputed from the edited paste
const inp = Kotkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");

Save the audit’s output.output as orders-reply.txt; for the orders audit below it reports 14 edit(s) applied, 0 did not apply. The handoff string it builds, decoded, with each why left out:

{
  "verdict": "blocked",
  "headline": "Thread.sleep in the retry loop and runBlocking in authHeader can freeze the UI thread, and a cancellation-swallowing catch is why polling keeps hitting the API after the screen closes.",
  "findings": [
    {
      "id": "KC-03",
      "severity": "high",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 42,
      "title": "Thread.sleep blocks the retry loop in fetchOrders",
      "why": ...,
      "edits": 2
    },
    {
      "id": "KC-02",
      "severity": "high",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 105,
      "title": "runBlocking in authHeader blocks the calling thread",
      "why": ...,
      "edits": 3
    },
    {
      "id": "KC-04",
      "severity": "medium",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 37,
      "title": "OkHttp execute() runs uncontrolled on the caller's thread",
      "why": ...,
      "edits": 2
    },
    {
      "id": "KC-01",
      "severity": "medium",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 48,
      "title": "refreshInBackground launches on GlobalScope",
      "why": ...,
      "edits": 3
    },
    {
      "id": "KC-06",
      "severity": "medium",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 94,
      "title": "Polling's catch swallows CancellationException",
      "why": ...,
      "edits": 2
    },
    {
      "id": "KC-12",
      "severity": "low",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 95,
      "title": "Error state is set with a read-modify-write instead of update { }",
      "why": ...,
      "edits": 2
    }
  ],
  "applied": 14,
  "failed": 0
}

The page sends that fix run with the audit’s note and the key coroutine-desk:fix:f7f6c1dc-3319:a1; for the shipped example it first looks for a saved reply to replay under orders:fix, the handoff run of the orders audit.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. In both lanes it must ground everything in code: a caller, a DI module, a dispatcher, a scope’s owner, a lifecycle, a test or a library’s behaviour that is not in the paste is never claimed to exist — it goes into questions; whether a function from another file suspends, blocks or is thread safe is unknown unless the paste shows it (“if OrderDao.replaceAll is a Room suspend DAO ...”, never “it is”). It must never write a secret value: a line that holds a literal credential is never copied into the reply (not in evidence, not in an edit, not in a file), and no credential, real hostname or endpoint is invented. It must never change what the code offers its callers: the same public classes, funs and properties with the same names, parameters, return types and types, and the same suspend-or-not nature of every fun that is not private, unless the note asks for it. It uses an API only when it is sure the API exists with that name in kotlinx.coroutines (or AndroidX lifecycle, when the paste uses it), and never adds a dependency the paste does not already use unless the note allows it. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never reported as a finding.

task: "audit"

task: "fix"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (a files array for fix, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like KC7 or kc 7 become KC-07; flags keeps only F<n> values; findings with no file, evidence and title are dropped; feature may also arrive as service or system, file as path, a change’s function as func and its change as what, and an open item as path; questions is capped at 10, plan at 30 and verify at 20. An audit reply carries 9 sections (verdict, headline, feature, areas, findings, flag_answers, plan, questions, summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids orders and tracker), built exactly as the page builds them: Kotkit.buildInput(lane, Kotkit.analyze(text), note, null), with the real model reply the page replays for each (/example-result.js). The pastes are shown whole; the request bodies cut the long code and facts strings with a visible ..., and the replies are pretty-printed excerpts, with ... where entries were left out. Every value shown is copied from the input or the reply. Neither paste holds a credential: the orders token comes from a TokenStore at run time, and the companies are invented.

Audit: an order sync that sleeps and calls OkHttp’s blocking execute() in a suspend fun, runs runBlocking on the main thread, launches on GlobalScope and swallows cancellation in its polling loop

Three files: build.gradle.kts (the Android application plugin, Kotlin 2.0.21, kotlinx-coroutines-android:1.8.1, lifecycle-viewmodel-ktx:2.8.6 and okhttp:4.12.0), OrderRepository.kt and OrderSyncViewModel.kt: an Android order sync. OrderRepository.fetchOrders is a suspend fun with no withContext of its own: a while (true) retry loop that calls OkHttp’s blocking client.newCall(request).execute(), catches IOException and waits with Thread.sleep(500L * attempt); refreshInBackground starts GlobalScope.launch to fetch and dao.replaceAll(orders). OrderSyncViewModel.startPolling launches a while (true) loop in viewModelScope that fetches, publishes a new SyncState and delay(30_000)s, inside a catch (e: Exception) that writes the error with _state.value = _state.value.copy(...); refreshNow calls refreshInBackground(authHeader()), and the private authHeader is runBlocking { "Bearer " + tokens.currentToken() }. The page’s free check raises 6 flags (2 high, 3 medium, 1 low) plus the KC-18 context flag; its own reading of the verdict is blocked. The engineer’s note: “Polling keeps hitting the orders API after the screen is closed, and the order list freezes the UI now and then. What do we fix first? Changing OrderRepository's constructor is fine - the ViewModel factory is its only caller.”

The code string, whole: 3,284 characters, 112 lines ending in a newline. Save it as orders-paste.txt and run node build-check.js orders-paste.txt > orders-check.json:

// file: build.gradle.kts
plugins {
    id("com.android.application")
    kotlin("android") version "2.0.21"
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
---
// file: app/src/main/java/com/example/orders/OrderRepository.kt
package com.example.orders

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException

data class Order(val id: String, val status: String)

class OrderRepository(
    private val client: OkHttpClient,
    private val baseUrl: String,
    private val dao: OrderDao,
) {
    suspend fun fetchOrders(auth: String): List<Order> {
        var attempt = 0
        while (true) {
            try {
                val request = Request.Builder()
                    .url("$baseUrl/v2/orders")
                    .header("Authorization", auth)
                    .build()
                val body = client.newCall(request).execute().use { it.body!!.string() }
                return parseOrders(body)
            } catch (e: IOException) {
                attempt++
                if (attempt >= 3) throw e
                Thread.sleep(500L * attempt)
            }
        }
    }

    fun refreshInBackground(auth: String) {
        GlobalScope.launch {
            val orders = fetchOrders(auth)
            dao.replaceAll(orders)
        }
    }

    private fun parseOrders(body: String): List<Order> =
        body.lines().filter { it.isNotBlank() }.map { line ->
            val (id, status) = line.split(",")
            Order(id, status)
        }
}

interface OrderDao {
    suspend fun replaceAll(orders: List<Order>)
    suspend fun count(): Int
}
---
// file: app/src/main/java/com/example/orders/OrderSyncViewModel.kt
package com.example.orders

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

data class SyncState(val orders: List<Order> = emptyList(), val error: String? = null)

class OrderSyncViewModel(
    private val repository: OrderRepository,
    private val tokens: TokenStore,
) : ViewModel() {
    private val _state = MutableStateFlow(SyncState())
    val state: StateFlow<SyncState> = _state.asStateFlow()

    fun startPolling() {
        viewModelScope.launch {
            while (true) {
                try {
                    val orders = repository.fetchOrders(authHeader())
                    _state.value = SyncState(orders = orders)
                    delay(30_000)
                } catch (e: Exception) {
                    _state.value = _state.value.copy(error = e.message)
                }
            }
        }
    }

    fun refreshNow() {
        repository.refreshInBackground(authHeader())
    }

    private fun authHeader(): String = runBlocking {
        "Bearer " + tokens.currentToken()
    }
}

interface TokenStore {
    suspend fun currentToken(): String
}

Its facts, decoded (the request carries this object as one compact JSON string):

{
  "build": {
    "kotlin": "2.0.21",
    "coroutines": "1.8.1",
    "coroutines_artifacts": [
      "android"
    ],
    "gradle_pasted": true,
    "android": true,
    "kotlin_files": 2,
    "functions": 9,
    "suspend_funs": 4,
    "builders": 3,
    "flows": 2
  },
  "files": [
    {
      "path": "build.gradle.kts",
      "kind": "gradle",
      "line": 2,
      "end_line": 11
    },
    {
      "path": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "kind": "kotlin",
      "line": 14,
      "end_line": 64
    },
    {
      "path": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "kind": "kotlin",
      "line": 67,
      "end_line": 113
    }
  ],
  "functions": [
    {
      "name": "OrderRepository.fetchOrders",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 29,
      "suspend": true,
      "private": false
    },
    {
      "name": "OrderRepository.refreshInBackground",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 47,
      "suspend": false,
      "private": false
    },
    {
      "name": "OrderRepository.parseOrders",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 54,
      "suspend": false,
      "private": true
    },
    {
      "name": "OrderDao.replaceAll",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 62,
      "suspend": true,
      "private": false
    },
    {
      "name": "OrderDao.count",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 63,
      "suspend": true,
      "private": false
    },
    {
      "name": "OrderSyncViewModel.startPolling",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 87,
      "suspend": false,
      "private": false
    },
    {
      "name": "OrderSyncViewModel.refreshNow",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 101,
      "suspend": false,
      "private": false
    },
    {
      "name": "OrderSyncViewModel.authHeader",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 105,
      "suspend": false,
      "private": true
    },
    {
      "name": "TokenStore.currentToken",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 111,
      "suspend": true,
      "private": false
    }
  ],
  "coroutines": [
    {
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 48,
      "func": "OrderRepository.refreshInBackground",
      "builder": "launch",
      "scope": "GlobalScope",
      "dispatcher": "default",
      "in_loop": false,
      "kept": null
    },
    {
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 88,
      "func": "OrderSyncViewModel.startPolling",
      "builder": "launch",
      "scope": "viewModelScope",
      "dispatcher": "main",
      "in_loop": false,
      "kept": null
    },
    {
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 105,
      "func": "OrderSyncViewModel.authHeader",
      "builder": "runBlocking",
      "scope": null,
      "dispatcher": null,
      "in_loop": false,
      "kept": null
    }
  ],
  "blocking_calls": [
    {
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 37,
      "func": "OrderRepository.fetchOrders",
      "call": "client.newCall(request).execute",
      "id": "KC-04",
      "dispatcher": "caller"
    },
    {
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 42,
      "func": "OrderRepository.fetchOrders",
      "call": "Thread.sleep",
      "id": "KC-03",
      "dispatcher": "caller"
    }
  ],
  "catches": [
    {
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 94,
      "func": "OrderSyncViewModel.startPolling",
      "type": "Exception",
      "suspends": [
        "fetchOrders",
        "delay"
      ],
      "rethrows": false,
      "in_loop": true
    }
  ],
  "state_flows": [
    {
      "name": "_state",
      "kind": "MutableStateFlow",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 84,
      "visibility": "private",
      "holds_mutable": false,
      "changed_in_place": 0,
      "read_modify_write": 1
    },
    {
      "name": "state",
      "kind": "StateFlow",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 85,
      "visibility": "public",
      "holds_mutable": false,
      "changed_in_place": 0,
      "read_modify_write": 0
    }
  ],
  "collects": [],
  "flags": [
    {
      "uid": "F1",
      "id": "KC-03",
      "sev": "high",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 42,
      "text": "suspend fun OrderRepository.fetchOrders calls Thread.sleep(): Thread.sleep parks the thread and ignores cancellation",
      "detail": "Thread.sleep blocks the thread the coroutine runs on - on the main dispatcher the UI freezes (an ANR after five seconds), on Dispatchers.Default or IO one of a small pool of threads stops serving every other coroutine - and it cannot be cancelled, so a cancelled scope still waits it out. delay(ms) suspends instead: the thread is free while it waits and cancellation ends the wait at once."
    },
    {
      "uid": "F2",
      "id": "KC-02",
      "sev": "high",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 105,
      "text": "runBlocking is called on the main thread in OrderSyncViewModel.authHeader (OrderSyncViewModel)",
      "detail": "Every method of a ViewModel, an Activity, a Fragment or a composable is called on the main thread: runBlocking stops that thread until the block finishes - the UI freezes, a slow network turns into an ANR, and a block that needs Dispatchers.Main to resume never does, which deadlocks. Launch it in viewModelScope or lifecycleScope and publish the result through state instead."
    },
    {
      "uid": "F3",
      "id": "KC-04",
      "sev": "medium",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 37,
      "text": "suspend fun OrderRepository.fetchOrders calls client.newCall(request).execute() on whatever dispatcher calls it - on Android usually the main thread: a blocking network, database or wait call runs on a coroutine's thread",
      "detail": "Blocking calls (OkHttp Call.execute(), URL.readText(), JDBC executeQuery, Future.get(), CountDownLatch.await(), BlockingQueue.take()) hold the thread until they return. Coroutine code that is not inside withContext(Dispatchers.IO) runs on whatever dispatcher the caller uses - on Android usually the main thread, which throws NetworkOnMainThreadException or freezes the UI - and nothing suspends, so cancellation cannot interrupt it. Wrap the call in withContext(Dispatchers.IO) { ... } (the suspend fun is then main-safe), or use a suspending API (OkHttp's await via an adapter, a suspending Retrofit fun, CompletableFuture.await() from kotlinx-coroutines-jdk8)."
    },
    {
      "uid": "F4",
      "id": "KC-01",
      "sev": "medium",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 48,
      "text": "GlobalScope.launch in OrderRepository.refreshInBackground starts a coroutine that nothing owns",
      "detail": "A GlobalScope coroutine lives as long as the process: closing the screen, stopping the service or a failing caller never cancels it, so it keeps working (and holding whatever it captured - a ViewModel, an Activity) after nobody wants the result, and a crash in it is reported nowhere near the code that started it. Launch it in the scope that owns the work - viewModelScope, lifecycleScope, an injected application scope - or make the function suspend and let the caller decide."
    },
    {
      "uid": "F5",
      "id": "KC-06",
      "sev": "medium",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 94,
      "text": "catch (e: Exception) in OrderSyncViewModel.startPolling wraps fetchOrders() and delay(), and also catches CancellationException, so the loop around it keeps running after the coroutine is cancelled",
      "detail": "Cancellation reaches a suspended coroutine as a CancellationException thrown from the suspension point. Exception includes it, and this catch neither rethrows it nor checks for it: the coroutine logs or retries and goes on as if nothing happened - after the screen closed, after withTimeout gave up, after a sibling failed - and structured concurrency waits for it. Rethrow it first (catch (e: CancellationException) { throw e } before this catch, or if (e is CancellationException) throw e), or call coroutineContext.ensureActive() in the catch."
    },
    {
      "uid": "F6",
      "id": "KC-12",
      "sev": "low",
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 95,
      "text": "_state.value is read, changed and written back in 1 place instead of _state.update { }",
      "detail": "value = value.copy(...) reads and writes in two steps: when two coroutines (or a coroutine and the main thread) do it at the same time, one of the two changes is lost. update { it.copy(...) } (kotlinx.coroutines.flow.update) retries with compare-and-set until the change lands on the latest value."
    },
    {
      "uid": "F7",
      "id": "KC-18",
      "sev": "info",
      "file": "app/src/main/java/com/example/orders/OrderRepository.kt",
      "line": 14,
      "text": "context: 2 Kotlin files, 4 suspend fun(s) of 9 fun(s), 3 coroutine builder(s) (2 launch, 0 async), 2 flow or channel properties, kotlinx.coroutines 1.8.1 [android]",
      "detail": "Also read: build.gradle.kts (gradle). Only what is pasted is checked: which dispatcher a caller outside the paste uses, whether a function from another file suspends or blocks, and how the scopes are cancelled outside the paste, is outside the check."
    }
  ],
  "verdict_candidate": "blocked",
  "notes": []
}

The request body (POST /estimate, then POST /run or /run-stream with Idempotency-Key: coroutine-desk:audit:b94f0c30-320c:a1 as the page sends it):

{
  "task": "audit",
  "code": "// file: build.gradle.kts\nplugins {\n    id(\"com.android.application\")\n    kotlin(\"android\") version \"2.0.21\"\n}\n\ndependencies {\n    implementation(\"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1\")\n    implementation(\"androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6\")\n    implementation(\"com.squareup.okhttp3:okhttp:4.12.0\")\n}\n---\n// file: app/src/main/java/com/example/orders/OrderRepository.kt\npackage com.example.orders\n...",
  "facts": "{\"build\":{\"kotlin\":\"2.0.21\",\"coroutines\":\"1.8.1\",\"coroutines_artifacts\":[\"android\"],\"gradle_pasted\":true,\"android\":true,\"kotlin_files\":2,\"functions\":9,\"suspend_funs\":4,\"builders\":3,\"flows\":2},\"files\":...",
  "note": "Polling keeps hitting the orders API after the screen is closed, and the order list freezes the UI now and then. What do we fix first? Changing OrderRepository's constructor is fine - the ViewModel factory is its only caller."
}

The reply (output.output), an excerpt: the verdict, the headline, the feature, each area’s status, and the finding that answers “polling keeps hitting the orders API after the screen is closed”, with its edits and its flag answer:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "Thread.sleep in the retry loop and runBlocking in authHeader can freeze the UI thread, and a cancellation-swallowing catch is why polling keeps hitting the API after the screen closes.",
  "feature": "OrderRepository fetches orders from a REST endpoint over OkHttp (retrying IOException up to three times) and can push a fire-and-forget refresh into a DAO; OrderSyncViewModel polls fetchOrders every 30 seconds inside viewModelScope, publishing the result through a StateFlow, and fetches a bearer token synchronously before each call.",
  "areas": [
    {
      "area": "structure",
      "status": "fragile"
    },
    {
      "area": "threads",
      "status": "broken"
    },
    {
      "area": "cancellation",
      "status": "fragile"
    },
    {
      "area": "flows",
      "status": "sound"
    }
  ],
  "findings": [
    ...,
    {
      "id": "KC-06",
      "flags": [
        "F5"
      ],
      "file": "app/src/main/java/com/example/orders/OrderSyncViewModel.kt",
      "line": 94,
      "severity": "medium",
      "area": "cancellation",
      "title": "Polling's catch swallows CancellationException",
      "evidence": "} catch (e: Exception) {\n_state.value = _state.value.copy(error = e.message)",
      "why": "This catch wraps repository.fetchOrders(authHeader()) and delay(30_000) and neither rethrows CancellationException nor checks isActive/ensureActive. When viewModelScope is cancelled (the ViewModel is cleared, e.g. the screen closes) the CancellationException thrown at that suspension point is caught here, _state is written, and the surrounding while(true) loop immediately calls fetchOrders() again - so the poller keeps hitting the orders API after the screen that started it is gone.",
      "edits": [
        {
          "before": "import androidx.lifecycle.viewModelScope\nimport kotlinx.coroutines.delay",
          "after": "import androidx.lifecycle.viewModelScope\nimport kotlinx.coroutines.CancellationException\nimport kotlinx.coroutines.delay"
        },
        {
          "before": "                } catch (e: Exception) {\n                    _state.value = _state.value.copy(error = e.message)\n                }",
          "after": "                } catch (e: CancellationException) {\n                    throw e\n                } catch (e: Exception) {\n                    _state.value = _state.value.copy(error = e.message)\n                }"
        }
      ]
    },
    ...
  ],
  "flag_answers": [
    ...,
    {
      "flag": "F5",
      "status": "confirmed",
      "reason": "catch (e: Exception) in startPolling neither rethrows CancellationException nor checks isActive, so cancellation is swallowed."
    },
    ...
  ],
  "plan": [
    ...,
    "Add a kotlinx-coroutines-test runTest that starts polling, advances virtual time past 30_000ms to confirm a second fetch happens, then cancels the job and asserts fetchOrders is not called again - this would have caught F5 letting the loop survive cancellation - plus a test that makes fetchOrders throw and asserts state.value.error is set."
  ],
  "questions": [
    ...
  ],
  "summary": ...
}

The reply has 6 findings, one per flag, and confirms all 6 flags; they carry 14 edits in all. In order: Thread.sleep(500L * attempt) becomes delay(500L * attempt), with the delay import (F1, KC-03); authHeader becomes a private suspend fun without runBlocking, and refreshNow calls it from viewModelScope.launch { } (F2, KC-02); the OkHttp call moves into withContext(Dispatchers.IO) { } (F3, KC-04); OrderRepository gets a private val externalScope: CoroutineScope constructor parameter, which the note allows, and refreshInBackground launches on it instead of GlobalScope (F4, KC-01); the finding above rethrows CancellationException before the general catch (F5, KC-06); and the error branch uses _state.update { it.copy(error = e.message) } (F6, KC-12). Every finding writes its import edit on top of the import lines the earlier findings left, which is why all 14 apply in order and the re-check below raises nothing.

Fix: a route recorder whose callbackFlow has no awaitClose and whose finish() spins on a volatile flag, with a launch(SupervisorJob()) left in place on purpose

One file, RouteRecorder.kt, and no build file. RouteRecorder.fixes is a callbackFlow { } that registers a LocationSource listener sending each fix with trySendBlocking(fix) and then ends without awaitClose, so the flow fails as soon as it is collected and the listener is never unregistered; record calls scope.launch(SupervisorJob()) to collect it and sets a @Volatile uploading flag around store.append(trackId, fix); and the suspend fun finish waits with an empty while (uploading) { } loop before store.close(trackId). The free check raises 3 flags (2 high, 1 medium, 0 low); its reading of the verdict is blocked. This example has no audit before it, so it carries no audit_handoff. The engineer’s note: “Recording crashes as soon as it starts and finish() pins a CPU core. Fix those two. Leave the launch(SupervisorJob()) in record() exactly as it is for now - the platform team moves the recorder onto its own supervised scope next sprint.”

The code string, whole: 1,381 characters, 51 lines ending in a newline. Save it as tracker-paste.txt and run node build-check.js tracker-paste.txt > tracker-check.json:

// file: app/src/main/java/com/example/tracks/RouteRecorder.kt
package com.example.tracks

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch

data class Fix(val lat: Double, val lon: Double, val accuracyM: Float)

interface LocationSource {
    fun register(listener: (Fix) -> Unit): Int
    fun unregister(handle: Int)
}

interface TrackStore {
    suspend fun append(trackId: String, fix: Fix)
    suspend fun close(trackId: String)
}

class RouteRecorder(
    private val source: LocationSource,
    private val store: TrackStore,
    private val scope: CoroutineScope,
) {
    @Volatile private var uploading = false

    fun fixes(): Flow<Fix> = callbackFlow {
        val handle = source.register { fix -> trySendBlocking(fix) }
    }

    fun record(trackId: String) {
        scope.launch(SupervisorJob()) {
            fixes().collect { fix ->
                if (fix.accuracyM < 50f) {
                    uploading = true
                    store.append(trackId, fix)
                    uploading = false
                }
            }
        }
    }

    suspend fun finish(trackId: String) {
        while (uploading) {
        }
        store.close(trackId)
    }
}

Its facts, decoded, cut to files, flags and verdict_candidate (the full object also carries the build block — no build file pasted, so kotlin and coroutines are null — the 7 functions, 3 of them suspend, the 2 coroutine builders (callbackFlow in RouteRecorder.fixes and scope.launch in RouteRecorder.record), the one collect of fixes(), which is not hot, and empty blocking_calls, catches and state_flows):

{
  "files": [
    {
      "path": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "kind": "kotlin",
      "line": 2,
      "end_line": 52
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "KC-10",
      "sev": "high",
      "file": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "line": 30,
      "text": "callbackFlow { } in RouteRecorder.fixes never calls awaitClose",
      "detail": "A callbackFlow's block must end in awaitClose { unregister(...) }: without it the block returns right after registering the listener, the flow fails with IllegalStateException (\"'awaitClose { yourCallbackOrListener.cancel() }' should be used in the end of callbackFlow block\"), and the listener is never removed - it keeps calling trySend on a closed channel and leaks whatever it captured. End the block with awaitClose { ... } that unregisters the callback."
    },
    {
      "uid": "F2",
      "id": "KC-07",
      "sev": "high",
      "file": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "line": 47,
      "text": "while (uploading) in RouteRecorder.finish spins without suspending or checking for cancellation",
      "detail": "The loop waits for a condition by spinning: it never suspends, so it burns its thread at 100% until the condition changes - and if the code that would change it needs the same thread (the main dispatcher, a single-thread context), it never does. Cancelling the scope does not stop it either. Wait with a suspending primitive (a StateFlow's first { it }, a Channel, a CompletableDeferred, delay between checks) and check ensureActive()."
    },
    {
      "uid": "F3",
      "id": "KC-09",
      "sev": "medium",
      "file": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "line": 35,
      "text": "launch(SupervisorJob()) in RouteRecorder.record cuts the coroutine off from its parent",
      "detail": "A Job passed to a builder replaces the parent's job in the new coroutine's context, so the new coroutine is no longer a child of the scope it was launched from: cancelling that scope (viewModelScope when the screen closes, a coroutineScope when a sibling fails) does not cancel it, and its failure does not reach the parent. For supervision, use supervisorScope { } or a scope built as CoroutineScope(SupervisorJob() + dispatcher); to stop cancellation of cleanup, use withContext(NonCancellable)."
    },
    {
      "uid": "F4",
      "id": "KC-18",
      "sev": "info",
      "file": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "line": 2,
      "text": "context: 1 Kotlin file, 3 suspend fun(s) of 7 fun(s), 2 coroutine builder(s) (1 launch, 0 async), 0 flow or channel properties, no build file pasted",
      "detail": "Only what is pasted is checked: which dispatcher a caller outside the paste uses, whether a function from another file suspends or blocks, and how the scopes are cancelled outside the paste, is outside the check."
    }
  ],
  "verdict_candidate": "blocked"
}

The request body (Idempotency-Key: coroutine-desk:fix:e99d934e-1968:a1):

{
  "task": "fix",
  "code": "// file: app/src/main/java/com/example/tracks/RouteRecorder.kt\npackage com.example.tracks\n\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.channels.trySendBlocking\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.callbackFlow\nimport kotlinx.coroutines.launch\n...",
  "facts": "{\"build\":{\"kotlin\":null,\"coroutines\":null,\"coroutines_artifacts\":[],\"gradle_pasted\":false,\"android\":false,\"kotlin_files\":1,\"functions\":7,\"suspend_funs\":3,\"builders\":2,\"flows\":0},\"files\":[{\"path\":\"app/...",
  "note": "Recording crashes as soon as it starts and finish() pins a CPU core. Fix those two. Leave the launch(SupervisorJob()) in record() exactly as it is for now - the platform team moves the recorder onto its own supervised scope next sprint."
}

The reply (output.output), an excerpt: the verdict, the headline, the returned file from the new Mutex on, the change that stops the spin, the last verification step and the open item:

{
  "task": "fix",
  "verdict": "partial",
  "headline": "Added awaitClose to fixes() and replaced the busy-spin in finish() with a Mutex wait; left launch(SupervisorJob()) in record() exactly as the note asked.",
  "files": [
    {
      "path": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "content": "package com.example.tracks\n...\n    private val uploadLock = Mutex()\n\n    fun fixes(): Flow<Fix> = callbackFlow {\n        val handle = source.register { fix -> trySendBlocking(fix) }\n        awaitClose { source.unregister(handle) }\n    }\n\n    fun record(trackId: String) {\n        scope.launch(SupervisorJob()) {\n            fixes().collect { fix ->\n                if (fix.accuracyM < 50f) {\n                    uploadLock.withLock {\n                        store.append(trackId, fix)\n                    }\n                }\n            }\n        }\n    }\n\n    suspend fun finish(trackId: String) {\n        uploadLock.withLock { }\n        store.close(trackId)\n    }\n}\n"
    }
  ],
  "changes": [
    ...,
    {
      "file": "app/src/main/java/com/example/tracks/RouteRecorder.kt",
      "function": "RouteRecorder.finish",
      "flags": [
        "F2"
      ],
      "change": "Replaced while (uploading) { } with uploadLock.withLock { } before store.close(trackId).",
      "why": "Suspends at a real cancellation point until any in-flight append releases the lock, instead of spinning the thread at 100% and never suspending or reacting to cancellation."
    }
  ],
  "verify": [
    ...,
    "A runTest with a fake TrackStore.append that suspends on a CompletableDeferred until released: call record(trackId), then launch finish(trackId) while append is still suspended, advanceUntilIdle(), and assert store.close is only reached after the deferred completes and append returns - this would have caught the busy-spin (F2), which otherwise burns real CPU instead of suspending."
  ],
  "open": [
    {
      "item": "launch(SupervisorJob()) in RouteRecorder.record still replaces the parent job, so cancelling scope does not cancel the recording coroutine and a failure inside it never reaches scope's owner.",
      "flags": [
        "F3"
      ],
      "reason": "The note asks to leave this exactly as it is for now, since the platform team is moving the recorder onto its own supervised scope next sprint."
    }
  ],
  "questions": [
    ...
  ],
  "summary": ...
}

The other three changes add the awaitClose, Mutex and withLock imports ((module), no flag), end the callbackFlow with awaitClose { source.unregister(handle) } (F1), and replace the @Volatile flag in RouteRecorder.record with a private Mutex held around store.append (no flag of its own; it is what finish now waits on). The verdict is partial because the note asked to leave launch(SupervisorJob()) as it is, so the medium F3 is named in open rather than fixed; every fun keeps its name and its suspend or not.

The page checks the reply against the code; the API does not

What comes back from the API is the model’s raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the code and the handoff it sent): it applies every edit or returned file to the paste and re-runs its own checks on the result. None of that happens in the API. A direct API caller gets the reply unchecked and must do the applying and re-checking itself.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /kotkit.js and /recon.js next to this script, pass the file you built the body from (build-check.js or handoff.js output), the reply text and the lane; it prints every statement, the count, and the paste with every edit (or file) put back:

// recheck.js -- run: node recheck.js orders-check.json orders-reply.txt audit > orders-edited.txt   (kotkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./kotkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, ""));      // the paste with every edit (or file) put back

For the orders audit reply it reports:

ok: The areas table rates structure, threads, cancellation and flows once each.
ok: Every one of the 6 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (blocked) fits the worst finding.
ok: Every one of the page's 6 flags is answered once.
ok: All 14 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every function of the paste is still there after the edits.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked

and for the tracker fix reply (node recheck.js tracker-check.json tracker-reply.txt fix):

ok: 1 file returned whole.
ok: With the fixed files in place the free check raises no high or medium flag beyond the ones left open (0 low).
info: F3 left open by the reply.
ok: Every one of the 7 functions is still there.
ok: Every one of the 4 changes names a returned file, a function in the fixed code and real page flags.
ok: The verdict (partial) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 6 statements checked
still raised: KC-09 medium app/src/main/java/com/example/tracks/RouteRecorder.kt:39 launch(SupervisorJob()) in RouteRecorder.record cuts the coroutine off from its parent

The page does not compile or run the code. After that, build it (./gradlew build, or the module’s test task), run the tests the reply’s plan or verify names under runTest (kotlinx-coroutines-test) with virtual time and a cancellation test that cancels the scope and asserts the work stops, and on Android watch a run with a profiler and StrictMode on for work that blocks the main thread, before you ship anything.

Step 1 — a tiny client helper

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; Java’s makeInput from step 4 goes in the class).

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

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

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   export SKILLSAFE_TOKEN=...        and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"coroutine-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — 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 4 before it lets you run.

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

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then code as the paste the page would send, facts as a compact JSON string, the note when it is not blank, and — for the fix lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the orders example from orders-check.json; step 6 reuses makeInput for the tracker (fix) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. 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 ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run.

The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size of code and facts, so read them from the call rather than hard-coding them.

# orders-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "Polling keeps hitting the orders API after the screen is closed, and the order list freezes the UI now and then. What do we fix first? Changing OrderRepository's constructor is fine - the ViewModel factory is its only caller." \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' orders-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

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

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. 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 — and include the lane, so an audit and a fix run of the same paste never share a key. The page uses coroutine-desk:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or fix, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is coroutine-desk:audit:b94f0c30-320c:a1 (orders audit) and coroutine-desk:fix:e99d934e-1968:a1 (tracker fix); the fix run of the orders audit handed on (the example key orders:fix, above) is coroutine-desk:fix:f7f6c1dc-3319:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

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

KEY="coroutine-desk:audit:$(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 '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

Step 6 — POST /run-stream (Server-Sent Events, fix)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the fix input of the tracker example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. 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 5.
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. The samples print the verdict and headline, each returned file’s path and size, each change (function, flags and change), each verification step, each open item and each question. To put the files into the paste and re-check them the way the page’s “Put every file into the paste” does, keep the reply text and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of tracker-check.json: makeInput then carries its audit_handoff.

# the fix worked example: tracker-check.json from build-check.js
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "Recording crashes as soon as it starts and finish() pins a CPU core. Fix those two. Leave the launch(SupervisorJob()) in record() exactly as it is for now - the platform team moves the recorder onto its own supervised scope next sprint." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  tracker-check.json)
FKEY="coroutine-desk:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")

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

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  files: [(.files // [])[] | {path, characters: (.content | length)}],
  changes: [(.changes // [])[] | {function, flags, change}], verify, open, questions}'

What the page does that you now do

Or skip all of this and use Coroutine Desk in the browser: the check, the flags, the coroutine-builder, blocking-call, catch, flow and collect inventories, putting an edit or the fixed files back into the paste, and the Markdown and CSV exports are free.