
This is the 4th in a series of articles discussing my work on a LibreOffice extension, now known as WriterAgent. Here’s a link to the first article for background: https://keithcu.com/wordpress/?p=5060
At Microsoft, I spent five years working on the text components RichEdit and Quill, and came to understand the “physics” of word processing: the file formats, data structures, and algorithms that provided fast access to text and properties, independent of the length of the file. Selecting one million characters to make them bold took about the same time as changing one character, because of the clever data structures (piece tables) and algorithms in these engines.

To be clear, changing more characters requires more repainting, but the code that actually applied the change to the document so it could be persisted to disk, and fetched while redrawing, ran in near-constant time. It did this for changes anywhere, in documents of any size, because of the piece table.
Text editing is an interesting problem space: Latin line layout alone is a hard problem, even before you consider the rules for non-Western languages: tabs, justification, hyphenation, kerning, ligatures, numbering, etc. There are many interesting little features you might never have noticed, like merging connected underlines:

On top of line layout, you add tables, embedded objects, columns, footnotes, indexes like a table of contents, and many other features required for real-world documents, and it becomes very complicated.
Word is a codebase where “byzantine” is an understatement, but RichEdit and Quill were in those perfect Goldilocks zones where you could over time learn most of the details of the code, since they weren’t buried under a mountain of legacy features and cruft.
When I decided to add a real-time AI grammar checker to WriterAgent, I knew what I was getting into, but I underestimated the trickery of LibreOffice’s UNO.
The Silent Killer Bug

LibreOffice has a linguistic subsystem that provides spelling and grammar checkers with a consistent UI. You register a proofreader component, and it calls you back, asking you to review text. You can return “no errors”, or a list of problems, explanations, and fixes.
LibreOffice will draw blue underlines in the correct place, and create the pop-up menu when the user right-clicks on the squiggles. The menu shows the explanation of the mistake, and let the user choose the replacement, and LibreOffice will apply it to the document.
That all sounds great, but it has a huge downside: it is entirely synchronous. However, I couldn’t even solve that problem because LibreOffice kept crashing!
When UNO is unhappy, it doesn’t throw a message box with an error; it shuts down the entire program. This usually only happens when there is a developer error, but when the program disappears it’s hard to figure out what happened. My code tries to catch all exceptions and log errors, so that I can figure out what happened later, but when the program disappeared, there was no chance to do so.
I spent several hours banging my head against the wall trying to figure out why the Tools – Options – Writing Aids dialog, where I had registered my grammar checker, would take down the whole LibreOffice process. Here’s a feature that almost works; I hope you’ve saved your changes!
Somewhere my Python XProofreader class was causing LibreOffice to detonate even though my grammar checker was not being called yet.After failing with even a simple one-page grammar checker that immediately agrees everything is correct, I decided to fire up ye olde GNU debugger.
It shows you the stack trace when the unhandled exception happens, which allows you to figure out the line of code that caused the problem. If you can narrow it down to one line, that usually clarifies things enough.
It turns out, LibreOffice instantiates these services using a C++ function called createInstanceWithArgumentsAndContext. Even if you aren’t passing use arguments, the office suite throws a bunch of initialization variables at your constructor. If your Python __init__ method doesn’t handle them, the code fails to map the call, the stack misaligns, and the program dies.

The fix? I changed the class’s __init__ method to accept *args (Python’s syntax for a variable length number of extra parameters), allowing LibreOffice to pass its hidden arguments, giving them a place to go besides corrupting the stack.
It was just 4 characters (plus the addition of Any which tells the type checkers what type to expect, in this case any type: string, integer, etc.) to act as a bucket for LibreOffice’s variables and suddenly, the crashes stopped. Once that UNO weirdness was out of the way, I could actually start on the grammar checker.
Async Queues and a Sentence Cache

The reason I avoided working on a grammar checker was the whole sync / async problem. I had built a multi-threaded extension, but the LibreOffice proofreading API is synchronous. That means the entire LibreOffice app waits—the code that handles keyboard events and everything else—while you decide whether there is a problem. You can spin up another thread to make the request in the background, but that doesn’t stop LibreOffice from waiting for an answer. Since I use OpenRouter and Together.ai for my LLMs, the “hold tight while I do a quick network request” was not going to work.
I like Mercury-2 which returns 250-500 tokens per second, so I can often get any answer in half a second. But when typing, even a half-second delay before anything shows up is annoying, and that’s the best-case scenario.

Also, I was happy with the add_comment tool as a way for the LLM to suggest corrections. You can ask WriterAgent to “review”, give “feedback” or “suggestions” on your document, and it will go through it in one pass like a professional copyeditor, and add comments. Then, you can go through those notes at your own pace, and delete each when you are satisfied.
I ’m not sure how many know of the comments feature in LibreOffice, it’s very cool but surely underutilized. The messages show up on the right-hand margin, in colored rectangles, almost like sticky notes that a professional might have written.
Because the LLM has full context rather than just a single sentence, it can provide more useful feedback. I told the AI to use add_comment for both positive and negative feedback, so the users enjoy reading the notes rather than always dreading bad news. The add_comment tool call is in the main document context to make it easy for the LLM to review at any time. The user just has to trigger it with the right keywords.
However, the LLMs occasionally grouped multiple similar issues in one comment, rather than creating separate comments at each location, which made it harder to find and fix the problems. I realized it’s nice to have a basic checker constantly running, verifying proper grammar, reminding you where a comma is needed or other sorts of nontrivial but essential details that should actually be fixed before you show it to your copyeditor. That way no one will wonder whether you graduated from middle school.
Eventually, I decided to tackle the problem. I used the venerable LightProof Python grammar checker which was a great starting point for efficiently handling the ProofReading API, but its rules were regular expressions which take microseconds to check, so I used its foundation but had to change the guts.
I tried two different designs to handle the sync-async issue: a fully async one where I would look up the results, cache them, and then give the answers later, and another design that returned error results right away, without completely halting the program, and which almost worked.
While it is true that in the proofreading callback, the entire LibreOffice process is waiting, you can call any function in LibreOffice, including processEventsToIdle(). That function tells LibreOffice to process keyboard and other events that might have happened, including repainting the screen.
It meant that from within my grammar checker callback, I could actually tell LibreOffice: “Do what you gotta do while I’m waiting on this network request.” You could type at full speed without seeing any delay as the screen repainted, even though the main thread and grammar checker were actually still waiting for an answer. It’s the power of recursion, being able to call LibreOffice back!
While it mostly worked, a few things broke, like being able to right-click on errors. None of the menus would appear while the LibreOffice proofing subsystem was still waiting. You could type, but the app was still mostly on hold. I had to move to async, which I knew would create more challenges.
So I changed the system to return “no errors” immediately, start the request in a background thread, save the results whenever they arrive, and if LibreOffice asks again with the exact same string, we’ll have a useful answer.
The first problem I had to solve was that while I was looking up one answer, multiple new requests would come in as the user typed each character. So in the background worker, `_GrammarWorkQueue`, I keep only the newest request for each paragraph, and it only fires after a 1-second pause of no new requests. There is no point in trying to check anything until the user has calmed down.
The next feature I needed was sentence caching, which is a minor topic in itself.

The challenge is that not only does each language have different punctuation marks, but also some languages like Thai don’t use standard punctuation. They don’t have spaces between words, only between sentences, so the rules for deciding what is a sentence cannot be the same for all languages.
I had done the easy part of auto-translating the user-visible strings into 34 languages, but now I needed some special rules in a few places to handle the quirks of those languages, like sentence determination. Fortunately, you can fetch the full list of Unicode punctuation marks, and store them in a little table.
I needed to break chunks up into sentences because I saw cases where an LLM was given multiple sentences with many errors and it would get confused, and sometimes show zero problems. Perhaps it was thinking: “No issues,who knows? Maybe that is some intentional new poetic lingo I’m not familiar with. I’m not paid enough to try to explain all the issues in that mess.” Feeding it just one sentence at a time makes it more focused, although you can adjust the value in settings, and try it out on larger batches, and see how it behaves on your model.
The async model works well enough because LO asks you to proof the paragraph every time. When a single sentence changes, only that new sentence is sent to be checked; the results for the rest are served from cache and reported as errors.
Because LibreOffice updates the UI on a pull model, there is a chance that it never asks about an error that was found. I will eventually add a way to keep track of errors LibreOffice hasn’t asked us about yet, a way to poke the system. For example, toggling the language of the affected sentence to a new one, and back. There must be some simple trick like this to nudge LO to ask us to re‑evaluate the text, so we can give the results we went to all of this trouble to obtain. For now, it seems to report useful problems in practice. (The build on GitHub has the ability to persist errors, so saving and re-opening will show them all the next time.)
Then there was the issue of the over-helpful AI. You type something simple like “This is a error.” The model knows it is wrong, it would flag the “a” as problematic, and suggest “is an error.” as the replacement. My initial, naive version of the code looked like a glitch in the matrix because you’d end up with: “This is is an error. error.” The system fixed one mistake, and created two new ones, like the Sorcerer’s Apprentice.
To fix this, I wrote code to strip out any duplicate words before or after the suggestion that match any words at the beginning or end of the replacement. The resulting architecture: debouncing, deduplication, prefix/postfix matching, and caching, keeps the UI snappy and usually useful, no matter the speed of the LLM, while preserving the original synchronous API.
Protecting Math from JSON

While I was frustrated with the grammar checker, I decided to investigate math import.
LibreOffice can generate beautiful equations, but it can be difficult for users to generate the required format in its editor. I decided to add a feature that lets the LLM create the formulas directly. You can describe what you want, either in plain text (E = mc^2) or using a description, and it can generate TeX math format, which LLMs know very well since it is so common on the internet for math. Once imported, these objects can be further edited by the user as beautifully formatted native Math objects.

The secret was a library called latex2mathml. LibreOffice understands the MathML format already and can convert it into its math objects, so with this bit of Python magic, I could take the TeX from the LLM, convert it to MathML, and let LibreOffice take it from there. It took only a couple of hours to get it working since the Python library and LibreOffice were doing most of the work.
I ran into a couple of issues; at first it would display “imes” instead of the multiplication operator. The issue was that streaming APIs return chunks of JSON. If the AI generates a LaTeX command like \times or \nabla, standard JSON parsers see the backslash, assume it’s a control character (like a tab \t or a new line \n), and mangle the math before it ever reaches the parsing code. I had to build a workaround for math blocks.
I don’t have edit working yet, converting back to TeX from MathML is a completely separate problem, but at least the LLM can insert formulas, and the user can change it, or delete it and tell the AI how to make a better one.
34 Languages and Auto-Translation

Having translated it to 8 languages, it was almost no work to add more. I had already built a batching, multi-threaded auto-translation system that reads the .pot template files and translates any missing strings, up to 10 strings at a time using 8 concurrent threads.
Because the infrastructure is automated, adding new locales is essentially painless. I decided to flip the switch and WriterAgent now supports 34 languages, including most European languages, plus Japanese, Korean, Chinese, Hindi, and other major Asian languages. For the translation, I use x-ai/grok-4.1-fast. It’s fast, intelligent, and inexpensive. Translating the extension into a new language costs a couple of pennies. Most of the strings are UI elements like “Send” or “Image Model,” so I don’t need a frontier model.
In fact, because it’s so cheap to run these API calls, I set up a review system that has another model (such as Qwen for Chinese) review every translation and report errors, with an English description of the issue and suggestions. The review script generates a JSON file of improvements, which you can further modify and then apply to the translation file. I’ve made many changes that will go into version 0.7.7.1.
Future Work
There’s plenty of future work. Each time I add a feature, I find two new ones I could work on. If you want to try it out, the repo is here: https://github.com/KeithCu/writeragent. Let’s make LibreOffice and the free desktop AI-native!





















