# Humanize OpenRouter Output in Java: A Zero-Dependency Linter for Bold-Label Bullets, Emoji and Title Case + 5 Free Tools (2026)

> Source: <https://dev.to/unaimytext_app/humanize-openrouter-output-in-java-a-zero-dependency-linter-for-bold-label-bullets-emoji-and-1b71>
> Published: 2026-11-01 14:37:55+00:00

*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](https://openrouter.ai/docs/api_reference/overview) 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](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) 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.

``` python
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.

``` python
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

# lint an exported update
java StatusLint.java < updates/platform-team.md

# generate with OpenRouter, then lint
export OPENROUTER_API_KEY=...
java StatusLint.java "Write this week's status update from these merged PRs: ..."

# point at the mock
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](https://unaimytext.com/features) 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.**
