A LanguageTool-compatible grammar-checking HTTP server backed by harper-ls — offline, privacy-first, sub-second.
Any LanguageTool client (LTeX in VS Code/Neovim/Emacs, browser extensions,
LibreOffice) can point at http://localhost:8875 and get offline checking
with no Java, no 16GB n-gram downloads, no cloud.
LanguageTool's own server is Java, heavy (~2-4GB RAM) and slow to start.
harper is a native Rust grammar checker that is millisecond-fast and tiny.
This server exposes harper's engine through the exact /v2/check API shape
that existing LanguageTool clients already speak.
go build -o bin/grammar-server ./cmd/server
./bin/grammar-server --port 8875
# needs harper-ls on PATH: sudo pacman -S harper (Arch)Test:
curl -s -X POST http://localhost:8875/v2/check -H 'Content-Type: application/json' \
-d '{"text":"this has a misspeled wurd","language":"en-US"}'POST /v2/check — LanguageTool request/response. Form-encoded POSTs and GET with
query parameters work too; LT clients use all three:
{"text": "…", "language": "en-US", "enabledRules": [], "disabledRules": [],
"enabledCategories": [], "disabledCategories": [], "enabledOnly": false,
"level": "default", "motherTongue": "de-DE", "preferredVariants": ["en-US"]}offset/lengthare UTF-16 code units (matches LanguageTool/LTeX, whose implementation is Java), so a JS client slicestextwith them directly and an emoji counts as two. Verified against text with an emoji before the error.languageselects the harper dialect (en-US→ American,en-GB→ British, …). A language this server cannot check is refused with LanguageTool's own400.enabledRules/disabledRulestake LanguageTool rule ids (MORFOLOGIK_RULE_EN_US) or harper's native names (SpellCheck,BoringWords, …), and they reach the engine:enabledRulescan switch on rules harper ships off, which filtering results could never do.enabledCategories/disabledCategoriestakeGRAMMAR/TYPOS/STYLE/…, andenabledOnlyruns nothing but what was asked for.enabledOnlynaming nothing this engine has (an id harper does not ship, a category this server never emits) does not answer "no issues" —warnings. incompleteResultscomes backtrueinstead, the field LanguageTool clients read for "this result is not the whole story". A request that named something real keepsfalse, because there an empty result honestly means clean.level=pickyadds the style tier (see below). Everything else is the correctness tier, so an editor client is never shown hints it did not ask for.preferredVariantsis LanguageTool's spelling-variant preference, and it is the dialect: the first entry we can check wins (["en-GB"]→ British), the rest are ignored, as are variants for languages we do not check. It accepts an array or a comma-separated string, like every other list parameter.motherTongueis accepted and ignored — it is part of the client contract, not a behaviour this server has.GET /statusreports the dialect the engine is currently configured for, the address it was launched on (listen), so a client can tell loopback from the network without asking, and how many words it has been told to stop reporting (ignored).POST /v2/ignore— words to stop reporting:{"word": "…"}adds one,{"word": "…", "forget": true}takes it back, and the answer carries the count and the file that was written. Writes are accepted from this machine only, because the list belongs to the engine: a word added over the LAN would change what everyone else sees. The list is a plain file ($XDG_CONFIG_HOME/grammar-server/ignored-words, one word per line,#comments) so it can be edited and undone by hand. This is deliberately not a dictionary: harper still flags the word and every other editor still shows it — seereferences/harper-dictionaries.mdin the skill package for why harper's own dictionaries cannot be used here. Filtering happens in/v2/check, the one place every client's matches come through, so it covers clients written later too.replacements[]come from harper-ls code actions.- Every key LanguageTool sends is present, including the ones a client reads without
checking:
shortMessage(empty when the rule has no short form),sentenceRangesandextendedSentenceRanges. The last mirrors the range list and names the language each sentence was checked as — this server checks one language and does not guess, so the rate is always 1.0. Two LanguageTool fields are deliberately absent:ignoreForIncompleteSentenceandcontextForSureMatch, which LanguageTool varies per rule and this server has no source for; an invented constant would be worse than an omitted optional field, which clients read as falsy. - A text longer than the engine can hold in one call is checked in sentence-aligned chunks (~12 KB), so offsets stay correct into the hundreds of kilobytes. Cuts fall after a sentence end, or at the last space when the text has none (bullet lists, tables, comma run-ons) — never inside a word, which the engine would report as two misspellings.
/v2/stats counts sentences the way a human would: a period after an abbreviation,
an initial, a decimal, a time, a URL or a file name does not start a new sentence
(Dr., R. K., 1.5, 10.30 a.m., example.com, report.txt), and a
punctuation-only fragment is not a sentence. Those counts feed mean/longest sentence
length and the Flesch/Fog scores above them.
POST /v2/fix-sentence takes {text, offset} (the offset in UTF-16 code units, as
everywhere else) and answers {fixed, offset, length}: harper's first suggestion for
each finding in the sentence that contains the offset, and the range of that sentence
so a client replaces exactly the text the server fixed. The sentence boundary is the
same one /v2/stats counts with (lt.SentenceRanges), so "Dr. Smith wrote it." is one
sentence, an initial or a surname is never a fragment, and a spelling guess on a name
(Rao → Rad) is dropped rather than applied. A sentence with nothing to fix comes
back unchanged.
Other endpoints: GET / (an index of the endpoints below — this server is
API-only now; the UI is a separate static page, grammar-ui,
which you point at this origin), GET /v2/stats (delivery metrics),
GET /v2/languages (entries carry name, code and longCode — clients read the
last one), POST /v2/rewrite (optional, needs a local Ollama).
POST /v2/rewrite accepts {"text": …, "tone": …, "intent": …} and answers with
candidates, model, provider and elapsedMs. Add "stream": true and it answers
application/x-ndjson instead: {"delta": "…"} as the model writes, then that same body
as the last line, or {"message": "…"} if it failed after the status line had already
gone out. A client should tell those apart by which key is present rather than by
position, because a backend that cannot stream — every backend but Ollama today — ignores
the flag and answers in one body, which arrives as a single line carrying candidates.
That is what makes it safe to ask for a stream unconditionally, and it is why clients
disconnect to cancel: the request's context reaches the backend, so the model stops
generating. On this machine the first line arrives at ~0.45s against ~1.8s for the
finished sentence.
cmd/server/main.go entrypoint (flags: --port, --host, --dialect, --harper)
internal/lsp/client.go minimal JSON-RPC/LSP client over stdio (Content-Length framing)
internal/engine/harper.go owns one persistent harper-ls process; didOpen → publishDiagnostics
→ codeAction suggestions; unique doc URI per check (no cross-talk);
warm-up lint at startup (dictionary load); full linter map from
`harper-cli config` (unlisted rules = disabled for harper-ls)
internal/api/handler.go /v2/check handler + LanguageTool JSON mapping
Design notes:
- One persistent harper-ls — per-request spawns would be ~500ms; LSP lint is ~3-10ms.
- Rule list read once —
harper-cli configprints hundreds of rules from a 150 MB process (~0.7s). It is read once per engine and the map is reused, which is the difference between a rule toggle costing 0.7s and costing nothing. - A quiet engine is a dead engine — harper-ls occasionally stops answering while the process stays alive. A check that gets no diagnostics reconnects and retries once, so one bad request is slow rather than every later request being a 500.
- Documents are closed after each check — harper-ls re-lints every open document on a configuration change, so leaving them open made toggles slower and fatter.
- Style hints are opt-in —
level=pickyorenabledCategories=[STYLE]. Three deterministic, offline passes:WORDINESS(33 wordy phrases → the concise form) andPREFERRED_TERM(10 non-preferred forms → the house-style one: e-mail → email, whilst → while) are ours,PASSIVE_VOICE_SIMPLEis LanguageTool's own id, so clients render all three unchanged. Both of ours suggest a replacement; the passive hint deliberately carries none: guessing the actor ships wrong fixes. - Localhost by default —
--host 0.0.0.0to expose it deliberately. - Unique URI per check — harper-ls publishes diagnostics tagged by document URI; reusing one URI lets concurrent checks cross-match stale publishes (was a real bug).
- A check is the linter plus one codeAction per match — harper-ls answers
codeActiononly for the range it is handed (one whole-document request returns zero actions), so suggestions cost one round trip per match: ~20 ms of linter work plus ~5 ms per match (measured: ten matches = 52-87 ms, two thirds of it codeAction). Sending those requests eight-way in parallel measures the same as sequential, so they are not latency-bound and the loop stays a loop. - Checks are serialized by design — one harper-ls behind one mutex, so throughput
is ~65 checks/s here whatever the concurrency, and latency is what queues. Measured
with a 66-word document at
level=picky(2 findings): p50 15 ms / p95 16 ms with one request in flight, p95 91 ms at 4 in flight, p50 467 ms / p95 527 ms with 96 requests at 32 in flight — no failures, no timeouts. Cost tracks findings, not size alone: a 10-match document measured 52-87 ms. Past ~4 in flight latency queues rather than degrading, so one editor session (LTeX+ keeps about one check per debounce) is well inside it; running more than one engine is the upgrade path if this ever serves several clients at once. Re-measure on a cool machine: this one throttles hard when hot (a 66-word check measured 4x slower at 94 °C than at 55 °C). - UTF-16 offsets — LSP positions are UTF-16 code units; LanguageTool clients (LTeX)
expect UTF-16 too.
lineCharToU16Offsetconverts line/char → flat UTF-16 offset. - FlatConfig trap — harper-ls treats linter rules not listed in config as disabled,
so the full rule map from
harper-cli configis always sent.
The engine listens dual-stack (*:8875) and avahi advertises this machine over mDNS, so another
machine — or a phone — can check text through the API:
| client | base URL |
|---|---|
| this machine | http://localhost:8875 |
| Linux, Windows 10+, macOS on the network | http://cachyos.local:8875 — stable, survives a new DHCP lease |
| Android, older Windows | http://192.168.29.123:8875 — Android does not resolve .local; check the address with ip -4 addr show wlan0 |
Dual-stack matters for the name: mDNS publishes an AAAA record, and an IPv6-preferring client that finds only an IPv4 listener gets "connection refused" from a name that works by address.
There is no page to open. The client is a desktop app in the
grammar-ui repo: one window with the checking, the
rewriting and the rewrite backend in it, built for Windows and Linux. On Linux it also installs
the helpers that hang off the desktop — check the text selected in any application
(grammar-lookup), put the fix in a notification when the engine flags what you just wrote
(grammar-watch), silence the checker for a while (grammar-pause). Those run on the machine
they read from, which is where the accessibility bus is; a phone can reach this API and check its
writing, but the window is a desktop app.
Installing the engine on Windows or macOS is not built yet: make bundle-windows-amd64 and
make bundle-darwin-arm64 cross-compile the server, but they need that platform's harper pair
(HARPER_DIR=...).
What that costs: the text you paste travels unencrypted over the network, there is no
authentication, and every endpoint on the engine is reachable — including /v2/rewrite,
which spends CPU on ollama. Checks are serialized, so a busy client slows everyone's. Put
--host 127.0.0.1 back in deployments/systemd/grammar-server.service and make install to go
back to loopback-only. grammar-doctor reports which of the two the running engine is in, so
the exposure is readable without opening the unit file.
There was an AppImage here that started the engine, served the browser UI from inside the bundle
and opened it. That UI is gone and the target went with it: it could only copy files that no
longer exist. git log -- Makefile has it if a portable engine bundle is wanted back.
The window is what ships as one now. npm run tauri build in grammar-ui produces the installers
for both platforms, and CI runs it on every push (the bundle job, ubuntu and windows). On a
machine with very new glibc the local AppImage step can fail inside linuxdeploy, whose bundled
strip cannot read the .relr.dyn section those libraries carry; NO_STRIP=1 gets past it by
hand, and the CI runner is clean — which is why the artifact comes from there.
The grammar-server binary finds harper-ls/cli in its own directory first,
then falls back to PATH. For portable use, bundle them together:
make bundle-linux-amd64
# → dist/linux-amd64/grammar-server + harper-ls + harper-cli
# Cross-compile for other targets:
make bundle-windows-amd64 HARPER_DIR=./harper-win64
make bundle-darwin-arm64 HARPER_DIR=./harper-macos
# Run (any OS):
unzip grammar-server-linux-amd64.zip
cd grammar-server-linux-amd64
./grammar-server --port 8875On Linux, make bundle-local assembles a runnable directory, and make package
turns the current checkout into the archive that ships on the
releases page:
grammar-server-0.4.1-linux-amd64.tar.gz (30 MB)
├── grammar-server harper-ls harper-cli ← no install, no PATH, no network
├── deployments/systemd/grammar-server.service
├── ui/ ← the UI's three files + its unit
├── README.md LICENSE LICENSE-harper
Extract and run; nothing else has to exist on the machine. Verified by running the
extracted archive with an empty HOME and no PATH: the server found the harper
pair beside itself, answered /v2/check with the style tier, and its /status
dialect followed a preferredVariants request from American to British. The UI in
the same archive serves from ui/ (python3 -m http.server --directory ui), or
install both halves with the two make install targets.
make install
systemctl --user enable --now grammar-serverThat builds the binary, puts it in ~/.local/bin, installs the user unit (which
references only %h/.local/bin/grammar-server, never this source tree) and reloads
systemd. make uninstall reverses it. Without systemd, the same build is:
make # ./grammar-server
./grammar-server --port 8875 --dialect Americanharper-ls is looked up on PATH, then beside the binary, then --harper /path/to/harper-ls.
| Client | How |
|---|---|
| VS Code (LTeX) | ltex.languageToolHttpServerUri: http://localhost:8875 |
| Neovim (ltex-ls / null-ls) | point ltex-ls at the server |
| Firefox/Chrome LT extension | settings → custom server URL |
| LibreOffice | its own grammar checker → this engine, no extension needed (below) |
| grammar-ui | the desktop card that watches what you type and talks to this engine |
LTeX and the LT extensions send the correctness tier only, so they will not show
the style hints — those need level: "picky" (or enabledCategories: ["STYLE"]) in
the request, which is what grammar-ui and the curl examples below do.
Verified against a real client library, not only against curl:
uv run --with language_tool_python python examples/lt-client-smoke.py
ok typo is reported: ['teh', 'wrote']
ok correct() applies: She goes to the office.
ok picky reaches the style tier: ['PREFERRED_TERM', 'WORDINESS']
ok disabledRules is honoured: []
ok enabledOnly is honoured: ['MORFOLOGIK_RULE_EN_US']It exits non-zero if the server is not there or any of that stops holding — curl only proves the shapes you thought of, a client proves the ones you did not.
LibreOffice 26.8 ships LanguageTool support, so its own grammar checking can run against this engine: no extension, no account, no Java.
- Tools ▸ Options ▸ Languages and Locales ▸ LanguageTool Server
- tick Enable LanguageTool
- set Base URL to
http://127.0.0.1:8875 - revert: untick the box. Nothing else is changed.
It lives per user profile in registrymodifications.xcu at
/org.openoffice.Office.Linguistic/GrammarChecking/LanguageTool → BaseURL (with IsEnabled). Both
names are taken from LibreOffice's own configuration schema, and confirmed through its configuration API:
setting them over com.sun.star.configuration resolves and reads back.
examples/lo-grammar-server-check.sh # engine up? setting present? how to watch a requestProven, and not proven. The engine's half is proven above, against a real client library. LibreOffice's half is not: with the setting in place, opening a document produced no request at the engine, and the GUI instance launched to test typing never put a window or a document on the accessibility bus on this machine — so the typing trigger was never reached. Watch it happen instead of taking this file's word for it:
journalctl --user -u grammar-server -f | grep v2/check # then type in WriterA line appearing as you type is the proof. Nothing appearing leaves two candidates untested here: the per-document "check as you type" setting, and which checker is active under Writing Aids.
- English only (harper's dialects: US/UK/CA/AU/IN).
- Rule IDs are mapped to LanguageTool's where a counterpart exists
(
SpellCheck→MORFOLOGIK_RULE_EN_US,SentenceCapitalization→UPPERCASE_SENTENCE_START, …); rules without a LanguageTool counterpart keep harper's ID.enabledRules/disabledRulesaccept either spelling. rule.urlsandcontextForSureMatchare not populated.
MIT licensed (see LICENSE). harper and harper-ls are Apache-2.0: they are not
vendored in this repository, but the release archive does carry them — with
LICENSE-harper beside them, as Apache-2.0 requires.
cmd/server/ entrypoint
internal/lsp/ LSP client
internal/engine/ harper-ls engine
internal/api/ HTTP + LanguageTool mapping
deployments/systemd/ user unit
Makefile cross-platform build
dist/ per-platform portable bundles (gitignored)
bin/ local build (gitignored)