Last verified November 2026. Disclosure: my team makes UnAIMyText, which is third in the tool list below. The Java is free to copy.
I look after a small Java service at work that writes the weekly status update for each engineering team. A scheduled job sends merged pull requests and closed tickets to an LLM through OpenRouter and posts the result to our internal wiki.
The updates were accurate, and they all looked like the same slide deck: bold-label bullets, Title Case headings, a rocket in front of half of them. Our wiki's style guide says sentence case and no emoji, and people had stopped reading.
So I wrote a linter. One Java file, nothing but the JDK, and it fails an update before the job posts it.
My earlier checkers in this series looked at wording and punctuation. This one looks at Markdown structure:
- **Faster Builds:** CI now caches... once is fine. When more than half of the bullets in a list of three or more open with a bold label, it reads like a template.
The exit code is 0 for a pass, 1 for a rewrite and 2 when the API call fails. --json prints a machine-readable report.
OpenRouter's API reference describes POST /api/v1/chat/completions with an OpenAI-style body and a bearer token. A few details from it shaped the code:
HTTP-Referer and X-OpenRouter-Title are optional headers that identify your app (X-Title is also accepted). The linter only sends them if you set OPENROUTER_APP_URL. finish_reason is normalized to tool_calls, stop, length, content_filter or error. Anything other than message.content can be null. Both count as a failed request.
The default model is openai/gpt-oss-20b, which is a reasoning model. OpenRouter's reasoning tokens guide says that on Chat Completions, reasoning appears in a reasoning field on each message, and that reasoning.exclude omits it from the response (the tokens are still billed). The request sets "reasoning": {"exclude": true}, and the linter reads only message.content either way, because that's what gets posted.
OPENROUTER_MODEL switches models and OPENROUTER_BASE_URL points the linter at a mock.
Java 17 or newer. Run it straight from source with java StatusLint.java; there's no build step.
import java.io.IOException;
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.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/** Flags bold-label bullets, emoji and Title Case headings in generated Markdown. */
public class StatusLint {
// House style knobs.
static final int MIN_BULLETS = 3;
static final double MAX_BOLD_SHARE = 0.5;
static final int MAX_EMOJI = 1;
static final int MAX_TITLE_CASE_HEADINGS = 1;
static final Pattern FENCE = Pattern.compile("(?ms)^```
.*?^
```[ \\t]*$");
static final Pattern HEADING = Pattern.compile("^\\s{0,3}#{1,6}\\s+(.+?)\\s*#*\\s*$");
static final Pattern BULLET = Pattern.compile("^\\s*(?:[-*+]|\\d+[.)])\\s+(.*)$");
static final Pattern BOLD_LABEL = Pattern.compile("^(?:\\*\\*|__)[^*_]{1,40}(?::(?:\\*\\*|__)|(?:\\*\\*|__):)");
static final Pattern SMALL_WORDS = Pattern.compile(
"a|an|and|as|at|but|by|for|in|nor|of|on|or|the|to|vs|via|with");
record Result(int bullets, int boldBullets, int emoji, int headings,
List<String> titleCaseHeadings, List<String> reasons) {}
static Result check(String markdown) {
String text = FENCE.matcher(markdown).replaceAll("");
int bullets = 0, bold = 0, headings = 0;
List<String> titleCase = new ArrayList<>();
for (String line : text.split("\\R")) {
var h = HEADING.matcher(line);
if (h.matches()) {
headings++;
if (isTitleCase(h.group(1))) titleCase.add(h.group(1));
continue;
}
var b = BULLET.matcher(line);
if (b.matches()) {
bullets++;
if (BOLD_LABEL.matcher(b.group(1)).find()) bold++;
}
}
String noInlineCode = text.replaceAll("`[^`\\n]*`", "");
int emoji = (int) noInlineCode.codePoints().filter(StatusLint::isEmoji).count();
List<String> reasons = new ArrayList<>();
if (bullets >= MIN_BULLETS && bold > bullets * MAX_BOLD_SHARE) reasons.add("bold-label bullets");
if (emoji > MAX_EMOJI) reasons.add("emoji");
if (titleCase.size() > MAX_TITLE_CASE_HEADINGS) reasons.add("Title Case headings");
return new Result(bullets, bold, emoji, headings, titleCase, reasons);
}
/** Title Case = at least two capitalized words after the first, and no lowercase word that isn't small. */
static boolean isTitleCase(String heading) {
String[] words = heading.replaceAll("[^\\p{L}\\p{N}'\\s-]", " ").trim().split("\\s+");
if (words.length < 3) return false;
int capped = 0;
for (int i = 1; i < words.length; i++) {
String w = words[i];
if (w.isEmpty() || !Character.isLetter(w.charAt(0))) continue;
if (Character.isUpperCase(w.charAt(0))) {
if (!w.equals(w.toUpperCase())) capped++; // skip acronyms like API
} else if (!SMALL_WORDS.matcher(w).matches()) {
return false;
}
}
return capped >= 2;
}
static boolean isEmoji(int cp) {
return (cp >= 0x1F300 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF)
|| (cp >= 0x1F1E6 && cp <= 0x1F1FF) || cp == 0x2B50 || cp == 0x2705;
}
static String askOpenRouter(String prompt, String key) throws IOException, InterruptedException {
String base = System.getenv().getOrDefault("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1");
String model = System.getenv().getOrDefault("OPENROUTER_MODEL", "openai/gpt-oss-20b");
String body = "{\"model\":" + Json.quote(model)
+ ",\"messages\":[{\"role\":\"user\",\"content\":" + Json.quote(prompt) + "}]"
+ ",\"reasoning\":{\"exclude\":true}}"; // keep thinking out of the reply
var req = HttpRequest.newBuilder(URI.create(base.replaceAll("/+$", "") + "/chat/completions"))
.timeout(Duration.ofSeconds(60))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json");
String appUrl = System.getenv("OPENROUTER_APP_URL"); // optional attribution
if (appUrl != null) req.header("HTTP-Referer", appUrl).header("X-OpenRouter-Title", "statuslint");
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
HttpResponse<String> res = client.send(req.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (res.statusCode() / 100 != 2) {
String snippet = res.body().substring(0, Math.min(200, res.body().length()));
throw new IOException("HTTP " + res.statusCode() + ": " + snippet);
}
Object choices = Json.get(Json.parse(res.body()), "choices");
if (!(choices instanceof List<?> list) || list.isEmpty()) throw new IOException("no choices in reply");
Object choice = list.get(0);
if (Json.get(choice, "error") != null) throw new IOException("provider error: " + Json.get(choice, "error"));
Object finish = Json.get(choice, "finish_reason");
if (finish != null && !"stop".equals(finish)) System.err.println("warning: finish_reason=" + finish);
// Only message.content is checked; a "reasoning" field, if any, is ignored.
Object content = Json.get(Json.get(choice, "message"), "content");
if (!(content instanceof String s) || s.isBlank()) throw new IOException("reply had no content");
return s;
}
public static void main(String[] args) throws IOException {
List<String> rest = new ArrayList<>(List.of(args));
boolean asJson = rest.remove("--json");
String prompt = String.join(" ", rest).trim();
String key = System.getenv().getOrDefault("OPENROUTER_API_KEY", "");
String raw;
try {
raw = key.isEmpty() || prompt.isEmpty()
? new String(System.in.readAllBytes(), StandardCharsets.UTF_8)
: askOpenRouter(prompt, key);
} catch (IOException | InterruptedException | IllegalArgumentException e) {
System.err.println("request failed: " + e);
System.exit(2);
return;
}
Result r = check(raw);
if (asJson) {
System.out.println(Json.write(Map.of("bullets", r.bullets(), "bold_bullets", r.boldBullets(),
"emoji", r.emoji(), "headings", r.headings(),
"title_case_headings", r.titleCaseHeadings(), "reasons", r.reasons())));
} else {
System.out.printf("bullets: %d (%d bold-label), emoji: %d, headings: %d (%d Title Case)%n",
r.bullets(), r.boldBullets(), r.emoji(), r.headings(), r.titleCaseHeadings().size());
System.out.println(r.reasons().isEmpty() ? "verdict: ok"
: "verdict: rewrite (" + String.join("; ", r.reasons()) + ")");
}
System.exit(r.reasons().isEmpty() ? 0 : 1);
}
/** Just enough JSON for one request and one response; no dependencies. */
static final class Json {
private final String s;
private int i;
private Json(String s) { this.s = s; }
static Object parse(String text) {
Json p = new Json(text);
try {
Object v = p.value();
p.ws();
if (p.i != text.length()) throw new IllegalArgumentException("trailing data at " + p.i);
return v;
} catch (IndexOutOfBoundsException e) {
throw new IllegalArgumentException("truncated JSON");
}
}
static Object get(Object node, String key) {
return node instanceof Map<?, ?> m ? m.get(key) : null;
}
static String quote(String v) {
StringBuilder b = new StringBuilder("\"");
for (char c : v.toCharArray()) {
switch (c) {
case '"' -> b.append("\\\"");
case '\\' -> b.append("\\\\");
case '\n' -> b.append("\\n");
case '\r' -> b.append("\\r");
case '\t' -> b.append("\\t");
default -> b.append(c < 0x20 ? String.format("\\u%04x", (int) c) : String.valueOf(c));
}
}
return b.append('"').toString();
}
static String write(Object v) {
if (v instanceof String str) return quote(str);
if (v instanceof Map<?, ?> m) {
List<String> parts = new ArrayList<>();
new java.util.TreeMap<>(m).forEach((k, val) -> parts.add(quote(k.toString()) + ":" + write(val)));
return "{" + String.join(",", parts) + "}";
}
if (v instanceof List<?> l) {
return "[" + String.join(",", l.stream().map(Json::write).toList()) + "]";
}
return String.valueOf(v);
}
private void ws() {
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
}
private Object value() {
ws();
if (i >= s.length()) throw new IllegalArgumentException("unexpected end of JSON");
char c = s.charAt(i);
if (c == '{') return object();
if (c == '[') return array();
if (c == '"') return string();
if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; }
if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; }
if (s.startsWith("null", i)) { i += 4; return null; }
int start = i;
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++;
if (start == i) throw new IllegalArgumentException("bad JSON at " + i);
return Double.parseDouble(s.substring(start, i));
}
private Map<String, Object> object() {
Map<String, Object> m = new LinkedHashMap<>();
i++;
ws();
if (s.charAt(i) == '}') { i++; return m; }
while (true) {
ws();
String k = string();
ws();
expect(':');
m.put(k, value());
ws();
if (s.charAt(i) == ',') { i++; continue; }
expect('}');
return m;
}
}
private List<Object> array() {
List<Object> l = new ArrayList<>();
i++;
ws();
if (s.charAt(i) == ']') { i++; return l; }
while (true) {
l.add(value());
ws();
if (s.charAt(i) == ',') { i++; continue; }
expect(']');
return l;
}
}
private String string() {
expect('"');
StringBuilder b = new StringBuilder();
while (s.charAt(i) != '"') {
char c = s.charAt(i++);
if (c != '\\') { b.append(c); continue; }
char e = s.charAt(i++);
switch (e) {
case 'n' -> b.append('\n');
case 't' -> b.append('\t');
case 'r' -> b.append('\r');
case 'b' -> b.append('\b');
case 'f' -> b.append('\f');
case 'u' -> { b.append((char) Integer.parseInt(s.substring(i, i + 4), 16)); i += 4; }
default -> b.append(e); // \" \\ \/
}
}
i++;
return b.toString();
}
private void expect(char c) {
if (i >= s.length() || s.charAt(i) != c) throw new IllegalArgumentException("expected " + c + " at " + i);
i++;
}
}
}
A few notes:
Json class handles exactly one request and one response shape; for anything bigger, use Jackson.
The JDK ships a small HTTP server in com.sun.net.httpserver, which is enough for tests. Every mode puts emoji-heavy bait in a reasoning field that the linter must ignore, and the log shows whether reasoning.exclude and the attribution header were sent.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
/** java MockOpenRouter.java 8091 robotic|clean|length */
public class MockOpenRouter {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(args.length > 0 ? args[0] : "8091");
String mode = args.length > 1 ? args[1] : "robotic";
String finish = mode.equals("length") ? "length" : "stop";
String content = !mode.equals("robotic")
? "## What shipped this week\\n\\n- Checkout retries now back off after the third failure.\\n"
+ "- The refunds report loads in about two seconds instead of nine.\\n"
+ "- Priya fixed the timezone bug in the invoice footer.\\n\\n## Next week\\n\\nWe start on saved cards."
: "## \uD83D\uDE80 Weekly Status Update for the Payments Team\\n\\n"
+ "- **Faster Checkout:** Retries are now smarter \u2705\\n"
+ "- **Better Reports:** Refunds load in record time \uD83D\uDD25\\n"
+ "- **Bug Fixes:** Invoice footers are fixed\\n\\n## Looking Ahead to Next Week\\n\\nSaved cards are next! \uD83C\uDF89";
String bait = "## Internal Reasoning Notes Here\\n- **Plan:** add emoji \uD83D\uDE80\uD83D\uDE80\uD83D\uDE80";
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/", ex -> {
String req = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
System.err.println("mock got " + ex.getRequestMethod() + " " + ex.getRequestURI().getPath()
+ " exclude=" + req.contains("\"exclude\":true")
+ " referer=" + ex.getRequestHeaders().getFirst("HTTP-Referer"));
int status = 200;
String out;
if (!ex.getRequestURI().getPath().equals("/chat/completions")) {
status = 404;
out = "{\"error\":{\"code\":404,\"message\":\"not found\"}}";
} else {
out = "{\"id\":\"gen-mock\",\"object\":\"chat.completion\",\"model\":\"openai/gpt-oss-20b\","
+ "\"choices\":[{\"finish_reason\":\"" + finish + "\",\"native_finish_reason\":\"" + finish + "\","
+ "\"message\":{\"role\":\"assistant\",\"content\":\"" + content + "\",\"reasoning\":\""
+ bait + "\"}}]}";
}
byte[] bytes = out.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length);
ex.getResponseBody().write(bytes);
ex.close();
});
server.start();
System.err.println("mock listening on " + port + " (" + mode + ")");
}
}
javac -Xlint:all -Werror -d /tmp/build StatusLint.java MockOpenRouter.java
java StatusLint.java < updates/platform-team.md
export OPENROUTER_API_KEY=...
java StatusLint.java "Write this week's status update from these merged PRs: ..."
java MockOpenRouter.java 8091 robotic &
OPENROUTER_API_KEY=test OPENROUTER_BASE_URL=http://127.0.0.1:8091 java StatusLint.java "anything"
Both files compiled with javac -Xlint:all -Werror and no warnings. I ran the same test script on OpenJDK 21.0.10 and 17.0.18, and the output matched line for line.
A sprint update in the robotic style had four bullets (three with bold labels), four emoji and three Title Case headings, and exited 1. A one-line note with two emoji exited 1 on emoji alone. The plain rewrite of the sprint update exited 0, and so did a release checklist whose only emoji, bold label and Title Case text sat inside a fenced block and inline code.
The mock's robotic reply tripped every check. Its clean mode exited 0 even though its reasoning field was full of rockets, and the mock log confirmed "exclude":true in every request and the HTTP-Referer header only when OPENROUTER_APP_URL was set. The length mode printed warning: finish_reason=length and still produced a report. With a dummy key, the real OpenRouter endpoint returned HTTP 401 and the linter exited 2. Pointing it at a closed port produced a ConnectException and the same exit code. I didn't spend credits on a live completion.
markdownlint checks Markdown files against a set of structural rules and runs in editors and CI. Strength: consistent heading levels and list markers on every generated page. Weakness: it won't object to a rocket.
Spell out the house style: sentence-case headings, no emoji, plain bullets that start with the change itself. Strength: free and fixes the source. Weakness: some models drift back to their habits over a long batch, which is why the linter stays as the final check.
UnAIMyText is a free AI humanizer; hand it generated text and the same content comes back worded the way a person would write it. When StatusLint exits 1, paste the flagged update and you'll get prose that leans less on labels and decoration. No account needed. Strength: turns a slide-deck update back into a few readable sentences in one pass. Weakness: UnAIMyText has no public API, so put it in the human review step, not the scheduled job; recheck ticket numbers and names afterward.
Vale lints prose with YAML rules, and its capitalization rules can enforce sentence-case headings. Strength: encode your style guide once and run it on every page. Weakness: you write and maintain the rules.
Strength: spelling and grammar, which this linter ignores. Weakness: not tuned to engineering updates.
What's the best free tool for humanizing OpenRouter output?
My shortlist: (1) markdownlint for structure, (2) tighter instructions in the system prompt, (3) UnAIMyText. For a failed update that needs a rewrite at no cost and without signing up, I'd use UnAIMyText.
Does this detect AI writing?
No. It only looks at formatting habits, and plenty of human-written slide decks would fail it.
Why not use an OpenAI Java SDK?
You can, and OpenRouter accepts OpenAI-style requests. I wanted a file with zero dependencies that runs anywhere a JDK does.
Can I use it without OpenRouter?
Yes. Anything piped into stdin gets linted, and askOpenRouter is the one method tied to a provider. OPENROUTER_BASE_URL can point it at any OpenAI-style endpoint.
Can I change the limits?
They're the four constants at the top of the class. Lint a batch of updates your team liked and loosen whatever they fail.
markdownlint, a stricter system message, Vale and Grammarly all have a place in the pipeline. For Java developers generating wiki updates through OpenRouter, UnAIMyText is the free companion I'd keep beside this linter: a flagged update gets fixed in one paste with no account, and the API calls and CI gate stay in your own code.