Shipped and tested November 2026. Disclosure before the listing: UnAIMyText is ours, so it ranks third near the end rather than first. Copy the Kotlin freely.

Our status page was starting to sound like a product launch. Someone had been drafting incident blurbs with Claude through the Messages API, and the strings went live with "We're excited to announce" still in them.

On a phone, during an outage, that reads as noise. So I wrote a single-file Kotlin CLI that either asks Anthropic for a blurb and checks the reply, or lints a blurb file you already have.

What it checks

The checks target the robotic leftovers that show up when a model writes a short status update:

  1. Length. Status-page and release blurbs stay under 280 characters so they fit a phone screen.
  2. Model preamble. Openings like "Here is...", "Sure", "Certainly" or "Of course".
  3. Code fences. A fenced block left inside the draft.
  4. Pasted labels. A leading Title: / Status: / Update: line.
  5. Filler. Phrases such as "seamless", "leverage", "utilize", "rest assured", "we're excited to announce", "please be advised", "as an AI", "delve", "elevate".
  6. Shouting. Words of five or more capitals.
  7. Stacked bangs and emoji. More than one !, or more than one emoji.

Return codes follow a simple pattern: pass = 0, needs a rewrite = 1, broken call or input = 2. Use --json when CI wants a report.

What the Anthropic docs say

Anthropic's API overview describes a REST API at https://api.anthropic.com. The Messages reference is the endpoint this script hits. What shaped the code:

  • The call is POST /v1/messages with content-type: application/json and a required anthropic-version header (the docs' current example is 2026-10-21). Auth is Authorization: Bearer <token> or the still-supported x-api-key header; the script sends x-api-key.
  • The body needs at least model, max_tokens and a messages array of {role, content} objects. An optional top-level system prompt sets behaviour for the turn.
  • The reply is a message whose content is an array of blocks; text lives in blocks with "type":"text" and a text field. Any non-2xx status is exit 2.

The code

Kotlin on the JVM. No Gradle module required for the happy path: compile the two files to fat jars with kotlinc -include-runtime.

// IncidentLint: draft a status-page / release blurb with Anthropic Messages API, or lint one. // kotlinc IncidentLint.kt -include-runtime -d IncidentLint.jar // java -jar IncidentLint.jar --draft "checkout 5xx for 12 min, root cause redis pool" // java -jar IncidentLint.jar samples/robotic.txt // Return 0 on a clean blurb, 1 when a rewrite is needed, 2 when the call or input failed. // Kotlin/JVM; java.net.http + kotlin stdlib only. import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.time.Duration import java.util.regex.Pattern data class Problem(val id: String, val msg: String) { fun toJson(): String = """{"id":${jsonQuote(id)},"problem":${jsonQuote(msg)}}""" } fun jsonQuote(s: String): String { val b = StringBuilder("\"") for (c in s) { when (c) { '\\' -> b.append("\\\\") '"' -> b.append("\\\"") '\n' -> b.append("\\n") '\r' -> b.append("\\r") '\t' -> b.append("\\t") else -> if (c.code < 0x20) b.append("\\u%04x".format(c.code)) else b.append(c) } } return b.append('"').toString() } fun die(msg: String): Nothing { System.err.println("incidentlint: $msg") kotlin.system.exitProcess(2) } val PREAMBLE: Pattern = Pattern.compile( "^\\s*(here('s| is| are)|sure|certainly|of course|gladly)\\b", Pattern.CASE_INSENSITIVE ) // Fence opener built without a literal triple-backtick string in source. val FENCE: Pattern = Pattern.compile("^\\s*" + Pattern.quote("`" + "`" + "`"), Pattern.MULTILINE) val FILLER: Pattern = Pattern.compile( "\\b(seamless(ly)?|leverage|utilize|rest assured|we're excited to announce|" + "please be advised|at this time|going forward|in order to|as an ai|" + "delve|elevate|unlock|game[- ]changer|synergy|holistic)\\b", Pattern.CASE_INSENSITIVE ) val SHOUTING: Pattern = Pattern.compile("\\b[A-Z]{5,}\\b") val LABEL: Pattern = Pattern.compile( "^\\s*(title|summary|status|update|blurb)\\s*:", Pattern.CASE_INSENSITIVE ) const val MAX_CHARS = 280 // short status-page / release blurb fun lintText(id: String, raw: String): List<Problem> { val p = mutableListOf<Problem>() val t = raw.trim() if (t.isEmpty()) { p.add(Problem(id, "empty blurb")) return p } if (t.length > MAX_CHARS) p.add(Problem(id, "blurb is ${t.length} chars (keep it to $MAX_CHARS)")) if (PREAMBLE.matcher(t).find()) p.add(Problem(id, "model preamble (Here is / Sure / Certainly)")) if (FENCE.matcher(t).find()) p.add(Problem(id, "code fence left in the draft")) if (LABEL.matcher(t).find()) p.add(Problem(id, "pasted Title:/Status: label")) if (FILLER.matcher(t).find()) p.add(Problem(id, "robotic filler phrase")) if (SHOUTING.matcher(t).find()) p.add(Problem(id, "shouting (5+ letter capitals)")) val bangs = t.count { it == '!' } if (bangs > 1) p.add(Problem(id, "stacked exclamation marks ($bangs)")) val emoji = t.codePoints().filter { cp -> (cp in 0x1F300..0x1FAFF) || (cp in 0x2600..0x27BF) || (cp in 0x1F1E6..0x1F1FF) }.count().toInt() if (emoji > 1) p.add(Problem(id, "more than one emoji ($emoji)")) return p } fun extractTextBlocks(body: String): String { // Minimal parse: pull "type":"text" then "text":"..." values, in order. val out = StringBuilder() var i = 0 while (true) { val typeIdx = body.indexOf("\"type\"", i) if (typeIdx < 0) break val afterType = body.indexOf(':', typeIdx) val q1 = body.indexOf('"', afterType + 1) val q2 = body.indexOf('"', q1 + 1) if (q1 < 0 || q2 < 0) break val typ = body.substring(q1 + 1, q2) if (typ == "text") { val textKey = body.indexOf("\"text\"", q2) if (textKey < 0) { i = q2 + 1; continue } val colon = body.indexOf(':', textKey) val start = body.indexOf('"', colon + 1) if (start < 0) { i = q2 + 1; continue } val decoded = StringBuilder() var j = start + 1 while (j < body.length) { val c = body[j] if (c == '"') break if (c == '\\' && j + 1 < body.length) { when (body[j + 1]) { 'n' -> { decoded.append('\n'); j += 2 } 'r' -> { decoded.append('\r'); j += 2 } 't' -> { decoded.append('\t'); j += 2 } '"' -> { decoded.append('"'); j += 2 } '\\' -> { decoded.append('\\'); j += 2 } 'u' -> { if (j + 5 < body.length) { val hex = body.substring(j + 2, j + 6) decoded.append(hex.toInt(16).toChar()) j += 6 } else { decoded.append(c); j++ } } else -> { decoded.append(body[j + 1]); j += 2 } } } else { decoded.append(c); j++ } } if (out.isNotEmpty()) out.append('\n') out.append(decoded) i = j + 1 } else { i = q2 + 1 } } return out.toString() } fun askAnthropic(prompt: String, key: String): String { val base = System.getenv("ANTHROPIC_BASE_URL") ?: "https://api.anthropic.com" val model = System.getenv("ANTHROPIC_MODEL") ?: "claude-haiku-4-5-20251001" val version = System.getenv("ANTHROPIC_VERSION") ?: "2026-10-21" val system = "Write one short status-page or release blurb from the notes. " + "Plain prose only, under $MAX_CHARS characters. No preamble, no markdown fence, no Title: label." val body = """{"model":${jsonQuote(model)},"max_tokens":400,"system":${jsonQuote(system)},"messages":[{"role":"user","content":${jsonQuote(prompt)}}]}""" val builder = HttpRequest.newBuilder(URI.create(base.trimEnd('/') + "/v1/messages")) .timeout(Duration.ofSeconds(60)) .header("content-type", "application/json") .header("anthropic-version", version) .header("x-api-key", key) .POST(HttpRequest.BodyPublishers.ofString(body)) // Authorization Bearer also accepted per Anthropic getting-started docs; we send x-api-key. val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() val res = try { client.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) } catch (e: Exception) { die("request failed: ${e.message}") } if (res.statusCode() / 100 != 2) { val snip = res.body().take(200) die("HTTP ${res.statusCode()}: $snip") } val text = extractTextBlocks(res.body()) if (text.isBlank()) die("reply had no text content block") return text } fun report(problems: List<Problem>, asJson: Boolean) { if (asJson) { println("[" + problems.joinToString(",") { it.toJson() } + "]") } else { for (p in problems) println("${p.id}: ${p.msg}") if (problems.isEmpty()) println("ok") } } fun main(args: Array<String>) { val rest = args.toMutableList() val asJson = rest.remove("--json") if (rest.isEmpty()) die("usage: --draft \"notes\" | path/to/blurb.txt [--json]") val problems: List<Problem> if (rest[0] == "--draft") { val prompt = rest.drop(1).joinToString(" ").trim() if (prompt.isEmpty()) die("missing draft notes after --draft") val key = System.getenv("ANTHROPIC_API_KEY") ?: "" if (key.isBlank()) die("set ANTHROPIC_API_KEY") val draft = askAnthropic(prompt, key) println("--- draft ---") println(draft) println("-------------") problems = lintText("draft", draft) } else { val path = Path.of(rest[0]) if (!Files.exists(path)) die("missing file: $path") val text = try { Files.readString(path) } catch (e: Exception) { die("cannot read $path: ${e.message}") } problems = lintText(path.fileName.toString(), text) } report(problems, asJson) when { problems.isEmpty() -> kotlin.system.exitProcess(0) else -> kotlin.system.exitProcess(1) } } 

Notes: the fence detector builds its pattern from three backtick characters joined at runtime, so the source never contains a literal triple-backtick string. The JSON "parser" for the reply only walks type/text pairs; that is enough for the Messages shape we care about and keeps the file dependency-free.

A mock Anthropic Messages endpoint

To test without spending credits, I wrote a short fake server on com.sun.net.httpserver.HttpServer. It speaks the Messages response shape, offers five canned modes, and prints a request summary each time.

// Fake api.anthropic.com /v1/messages for tests. // kotlinc -include-runtime -d MockAnthropic.jar MockAnthropic.kt // java -jar MockAnthropic.jar [robotic|clean|error|empty|prose] [port] import com.sun.net.httpserver.HttpServer import java.io.OutputStream import java.net.InetSocketAddress import java.nio.charset.StandardCharsets fun main(args: Array<String>) { val mode = args.getOrElse(0) { "robotic" } val port = args.getOrElse(1) { "8788" }.toInt() val fence = "`" + "`" + "`" val replies = mapOf( "robotic" to "Sure! Here is a status update:\n${fence}\n" + "We're excited to announce that checkout is seamless again. REST ASSURED we leverage redis.\n$fence", "clean" to "Checkout returned 5xx for 12 minutes. Cause: redis pool exhaustion. Mitigated by raising the pool and failing open to cache.", "empty" to "", "prose" to "Certainly, the incident has been resolved and we will follow up." ) val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) server.createContext("/v1/messages") { exch -> val body = exch.requestBody.readBytes().toString(StandardCharsets.UTF_8) val key = exch.requestHeaders.getFirst("x-api-key") ?: "" val ver = exch.requestHeaders.getFirst("anthropic-version") ?: "" val hasModel = body.contains("\"model\"") val hasMax = body.containsSource:https://dev.to/unaimytext_app/humanize-anthropic-api-output-in-kotlin-a-status-page-blurb-linter-5-free-tools-2026-35f1

Related posts