The question's text stands above its buttons: + attaching files, who answers as a chip (Effort now a submenu of it), the conversations kept, and the microphone while nothing is written, which gives way to the button sending. What goes along is one row above the text, opening a sheet with how far the assistant looks and the parts to send. A conversation's follow-up has the same + and microphone. Files are read on the device: an image goes as itself to an assistant that reads images, a PDF as its pages' text, a text file as its text, each cut at 20,000 characters and labelled by its name after the question. Voice is the device's own speech recognition (speech_to_text: Android, Apple, Windows; none on Linux), in the app's language, the microphone asked for once tapped. The privacy page says that Android's recogniser may send the sound to Google or the phone's maker. The field's Check goes, with its LanguageTool sheet (amending ADR 0024); the editors' underlining and Why? stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
8.6 KiB
ADR 0024: LanguageTool as a sidecar
- Status: decided 26 Oct 2026, by the developer (D1–D5 below); built on 26 Oct for v0.21.0, with ADR 0021's step 3.
- Amends: ADR 0017, point 10 (a private studyset is never "checked by the instance's LanguageTool"): what the person writes in one is checked once they agree for it (D5), and what they type into the field's Check always (D4). ADR 0021, D54 (Check in the field asks the assistant): LanguageTool checks first where it can (D3).
Context
Halkyon Learn is used to write in the language being learnt: flashcards, notes, answers in the field. A spelling and grammar checker helps there as much as anywhere, and LanguageTool is the open-source one (LGPL 2.1) that runs on one's own server: some 60 languages, Korean not among them, Greek and Chinese unmaintained, German and English the best kept. Its HTTP API checks a text in a language, or tells the language itself, and answers with what it found: where, why, and what could stand there instead.
An instance may already run services beside itself (deploy/sidecars/, decided 14 Oct:
Jellyfin), each a compose file switched on in deploy/.env. On 26 Oct, before the push
of ADR 0021's three steps, the developer asked "to also include the open-source version
of languagetool as a potential sidecar, similar to Jellyfin being an optional sidecar".
LanguageTool's server has no sign-in. Jellyfin has its own accounts, so the instance publishes it under a site of its own; LanguageTool published so would check anyone's text on the operator's machine. And unlike a person's Jellyfin, which the app reaches directly, a checker sees what people write while they write it.
Decisions (26 Oct 2026, the developer's)
Amended on 2 Nov 2026 by ADR 0021's D67 (the developer's): the field no longer has Check, so D1's Check, D3 and D4 no longer hold; the endpoint and the editors' underlining stay.
- D1 The sidecar, a server endpoint, the field's Check and underlining in the editors (the developer's choice, over the sidecar with Check alone, which was recommended as the smaller step, and over the sidecar with the endpoint alone): the checks reach every place people write.
- D2 The image is
meyay/languagetool6.8-13, pinned by its digest (recommended, overerikvl87/languagetool, a month behind, and over building our own from LanguageTool's tag): LanguageTool's own downloads end at 6.6, and this image builds every release from its tag, runs as any user ID on a read-only filesystem and has a health check. - D3 Check goes to LanguageTool first (recommended, amending ADR 0021's D54): where the instance checks the language, the field's Check opens a sheet with what LanguageTool found, a tap taking a suggestion into the field, and "Ask why" where one is offered; where it doesn't, Check asks the assistant as before. LanguageTool answers in a moment and costs the operator no tokens; the assistant explains, which LanguageTool can't.
- D4 What is typed into the field goes to LanguageTool in a private studyset too (recommended, over checking it nowhere there): the person typed it to have it checked, as they paste a text to have flashcards proposed (D56), and the text is theirs, not the studyset's.
- D5 The editors of a private studyset check only once agreed, asked once per studyset (recommended, as D9 and D17 ask for an assistant; over never checking there, as ADR 0017's point 10 had it, and over the device's setting alone deciding): a banner above the editor, not a dialog, while it has the focus, says that what is written there goes to the instance readable, a paragraph at a time, and is kept nowhere, with "Check here" and "Not here". The answer holds for the studyset and its topics, follows the person (their sealed settings) and is taken back in the studyset's privacy dialog, which asks again.
What else was built so (the agent's calls, each with its reason)
- Reached only through the server. The sidecar joins
edgewithout a site in Caddy and publishes no port; the server'sgrammar.check(requireLogin) forwards a text and its language (LANGUAGETOOL_URL, set by the sidecar's compose file). The server checks who asks and how often (60 checks a minute per person, counted in memory), refuses a text over 20,000 characters, and keeps and logs neither the text nor what was found; a failure is logged without LanguageTool's answer. The endpoint is Learn's, not halkyon_base's: only Learn writes text to check so far, and a module's endpoint is for what every Halkyon app needs. - No log in the container (
logging: none): LanguageTool's log names each check's length and language only, but a failing rule may quote its sentence. - Memory: Java's heap at most 1 GB (1.2 GB resident with fastText beside it and five languages loaded, measured), the container capped at 2 GB.
- fastText on: the model telling languages apart (126 MB, fetched at the first start
into
deploy/languagetool/fasttext) makes the recognised language of a note far more reliable than LanguageTool's own guess; it costs 135 MB of memory. - Languages: the instance asks its LanguageTool which it checks as it starts and an
hour after, and a minute after a look it didn't answer (added 26 Oct, after the first
deploy: a server started beside it looks before LanguageTool answers, and apps keep the
languages they were told), and
instance.info()names them (grammarLanguages, primary subtags), so the app offers checks only where they work. A tag maps to LanguageTool's code (languageToolCode):enanddetoen-USandde-DE, since the bare codes check no spelling; traditional Chinese to none, as LanguageTool only knows simplified; an empty one toauto, whose guess prefersen-US,de-DE,pt-PTandca-ES. - What is checked in which language: a card's front in the studyset's front language (a character card's front not at all: one character is no text), its back and its answers in the back's, its hints and explanation, notes, steps, a PDF page's note, descriptions and discussions in the language LanguageTool recognises, since they mix the studyset's two.
- How the editors check (
core/grammar/):GrammarCheckControllerchecks once the writer pauses for 1.5 s and as the editor loses the focus, never at each key; a paragraph at a time, so what is found maps to the node's offsets, and only paragraphs whose text changed (the rest kept by their text); one check at a time; formulas and code masked as spaces, so they are neither checked nor underlined. An edit drops what was found at once, so an underline never points at the wrong word; moving the caret doesn't. A language LanguageTool refuses ends the checking in that editor. Nothing is written into the document:GrammarOverlaydraws red wavy lines over the editor where the editor would draw a selection, and follows its scrolling and wrapping; a tap opens a menu of what is wrong and the suggestions, and the one chosen is an edit like any other. A check that found nothing shows a muted check in the corner. - Settings › Writing: "Check grammar while writing", a device setting, on unless
turned off (
checkGrammar), saying so where the instance checks nothing yet. It covers the editors; the field's Check is asked for each time. - Private studysets' answers: an agreement is kept as an assistant's is
(
privateAgreements, the keygrammar), so two devices' settings fold it the same way (only where both agreed); a refusal ingrammarDeclined(settings schema 14), folded where either refused, so an older row never sends what was refused since. - The privacy page says that what is checked, and written while checking is on, goes to the instance's LanguageTool, a paragraph at a time, in a private studyset only once agreed, and that neither keeps it.
Consequences
- An operator switches it on as Jellyfin: a user ID of its own (61081), its folder, the
compose file in
COMPOSE_FILE(deploy/sidecars/README.md). Without it nothing changes: the instance names no languages, the editors check nothing, and Check asks the assistant. - What people write while checking is on is sent to the instance as they write, readable to it, as their studysets that aren't private are anyway; in a private studyset only where they agreed. A modified instance could keep it: the agreement says where it goes, not that the operator can't read it.
- A paragraph is checked whenever its text changes after a pause; a person typing a long note sends some checks a minute. The limit of 60 a minute is above what writing needs and below what would let one person keep the checker busy.