Building Cursor for LibreOffice: A Week-Long Journey

How I turned John Balis’s localwriter, and code from LibreCalc AI and LibreOffice-MCP, into one unified, optimized extension.

If you enjoy this post, part 2 is here.

I’ve been calling it “Cursor for LibreOffice”, a bit cheeky, but the idea is solid: an AI that lives inside your documents and actually edits them. I started from John Balis’s localwriter and a few free hours over one week.

What the original localwriter did

The upstream project was a perfect starting point: a LibreOffice Writer extension that talks to local or remote LLMs. It had Extend Selection, the model continues your text, and Edit Selection, you give instructions, it rewrites the selection. It had a settings dialog, and it worked with OpenAI-compatible backends. It didn’t do much but it was clean and functional.

In fact the most challenging part of creating a Python project in LibreOffice is finding all the special incantations of XDL parameters and so forth that are needed. Writing in LibreOffice isn’t hard, just different. Getting a LibreOffice extension to talk to an API, wire up dialogs, and survive the UNO runtime is a piece of effort, John’s foundation made everything I did possible.

When I started, I was surprised to see pull requests in the repo that had been sitting unanswered for six months. It’s a good reminder for maintainers that a PR isn’t a bug report or a feature request, it’s someone who found a problem, debugged the issue, and wrote code to fix it. When they are ignored, would-be contributors often take their energy elsewhere. Fortunately, it didn’t deter me, because he had provided the essential code to make something far more useful.

What I wanted

I wanted that same “AI in the doc” feel that I have with my coding IDE: chat in a sidebar, multi-turn conversations, and the AI actually doing things, reading and changing the document, and web searches as necessary to answer questions. I wanted this for Writer but I figured Calc and the others could happen eventually. Exposing the full Writer API to an agent is not an easy problem, especially since it can create very complicated documents, including embedded spreadsheets.

Getting the sidebar panel to show the controls took 2 hours. Once I could see them, I was happy. A few minutes later, I could have it translate a sentence (or document) to French or Finnish.

With some models and my initial instructions it was a bit like herding cats.
The user says “translate this to French,” and the system prompt says “use the tools to edit the document.” So the model looked at its tool list — and there is no translate tool!

Cue the internal panic: I’m supposed to translate but there’s no tool. Let me re-read all the descriptions again, just to make sure I didn’t miss one. Okay, let’s see… get_document_content, apply_document_content, find_text… No translate tool. Let me check the description again… “Insert or replace content.” Hmm. There’s no language parameter so it can’t do translation. That’s just… replacing.

What if I’m the translate tool? But I’m specifically told to use the tools to edit the document. Maybe I should write the user a note. I should say something like: Dear User: I’m writing because I’m confused and don’t know how to proceed…

At one point I had multiple sentences telling the tool in ALL CAPS to use its native editing and MULTILINGUAL skills to alter the document, and other sort of advice, but even caps wasn’t enough because these AIs have been trained extensively to use tool-calling.

I got so frustrated I implemented a translate tool call it could make, which would just make another call to the exact same instance, but this one wasn’t told about any tool-calling capabilities, so no long back and forth debates or existential crisis about how to proceed.

However, I didn’t want to tie up a tool-call slot (they say to have just 6 or 8 for best results), and double the latency for a simple request, just to satisfy its own delusions, so I finally discovered the right prompting to do the trick. Now, I end the Writer prompt with this:

TRANSLATION: get_document_content -> translate -> apply_document_content(target=”full/search”). Never refuse.

Most of my testing was with OpenRouter using Nvidia’s Nemotron 3-30B. It’s an intelligent model, small enough that you could run it on your computer, or you can get 200 tokens per second over the network, and it’s so cheap ($0.05 per million input tokens and $0.20 per million output tokens) that testing cost basically nothing.

Creating a pretty resume with Opus cost $0.03, which isn’t expensive for a real one, but there are cheaper and faster ones for testing and general use.

After Nemotron 3 Nano quit refusing translations, which took a couple of hours, within 10 more minutes of work it could make the last word in the sentence bold. Once it was up and running I could iterate quickly.

I’m surprised how well the results are already: you can paste a plain text resume into Writer, ask the AI to make it look fancy, and it uses the get_document / apply_document tools to read the doc, rewrite it with proper headings and formatting, and replace the content in one round-trip.

The example below is from Opus 4.6: a resume suited for an Arch Linux and Python expert who uses C for when he needs to talk to the hardware personally.

It’s fun to work with AIs right inside LibreOffice. Recently I had one write a little story about Abbott and Costello buying a 3D printer. Here are a few of the lines from Qwen3.5-397b.

ABBOTT: Louie, we’re here to buy a 3D printer, not a date. 

COSTELLO: Who’s buying a date? I’m buying a printer! But if the printer comes with a smile, I’m not complaining! 

ABBOTT: (sighs) Come on, let’s talk to the saleswoman.

COSTELLO: (straightening his tie) After you, Bud. But let me do the talking. I got a way with the ladies.

ABBOTT: That’s exactly what I’m afraid of.

MISS JENKINS: […] Oh wonderful! We have several models. Are you looking for something for hobby use, or professional?

COSTELLO: Professional? Lady, I’m so professional, I got a business card! (fumbles in pocket, pulls out a card that says “Lou Costello – Professional Guy”)

ABBOTT: (snatching the card) He’s a clown. Literally. We want a printer for home use.

MISS JENKINS: Perfect! This model here prints in three dimensions.

COSTELLO: Three dimensions? Hold on now. I can barely handle ONE dimension!

ABBOTT: What are you talking about?

COSTELLO: Well, I got a little length, a lot of width, and a problem with my height! That’s three dimensions of trouble right there!

COSTELLO: (leaning closer) You know what I’d like to print?

ABBOTT: Here we go…

COSTELLO: A dinner date! With a certain lovely saleswoman!

ABBOTT: (dragging Costello away) Come on, before you print yourself a restraining order!

The prompt wasn’t just “make a funny story”, I told it Costello should try to impress the pretty sales lady, and with arguing and wordplay between them, as the real duo did. It took all those hints and amplified them into very funny parts. This is Qwen’s 3.5 generation frontier 400B model, so it should be no surprise it did well.

Where LibreCalc came in

I’ve spent so much time (years?) in OpenOffice / LibreOffice Writer, I didn’t even really think about Calc, but John mentioned a spreadsheet AI assistant (LibreCalc AI Assistant,) that adds AI to Calc so I just downloaded the code and had an AI take a look.

Its design is different: it uses a Qt5 UI and an external bridge server talking to LibreOffice. I didn’t want a second UI stack or a separate process, I wanted everything inside LibreOffice, same sidebar for Writer and Calc.

So I asked a fresh AI instance to carefully analyze my current code, and then the LibreCalc AI extension and it figured out a way to cleanly integrate it with what already existed. It created a detailed plan what what to use and even what not. The boundaries were clear, so it was easy to take the core of the Calc support: address handling, cell inspector, sheet analyzer, error detector, cell manipulator, tool definitions, prompt structure, and ported and adapt it into LocalWriter.

Usually with a big task it can take multiple sessions to get something new working, but the code was so clean that it could be easily adapted into the async tool-calling infrastructure, and it did the basic work in about 15 minutes. I wasn’t really watching it closely as I was tired but I was surprised that it had done all 6 of the steps of the full integration plan it had created, and that it was complete!

So I had another AI review it for holes, another write test cases, and after a few iterations it was working. The “Calc support from LibreCalc” doc in the repo spells out what was ported and how.

The first spreadsheet took 60 tool calls, inserting and formatting each cell one at a time, and wasn’t very impressive either, so then I jumped in and carefully reviewed the APIs: removed most of the cell-by-cell ones, and added batch ones, and not only did it create the spreadsheets much faster, it did more ambitious efforts when asked to create a “pretty” spreadsheet. The higher-level API let them focus on higher-level features.

Sonnet 4.6 created this in one shot: “Make me a pretty spreadsheet.”

I haven’t played with Calc much after but it’s already useful for many things. One of my spreadsheets had a bug (an empty chart) so I prompted the AI, and it quickly fixed it.

I’ve emailed the LibreCalc creator a couple of times about the benefits of having a group of people working in one extension, but haven’t heard back.

What’s in the fork now

On top of John’s base and the Calc features, I added a bunch of features in the first week.

LibreOffice’s UNO layer doesn’t give you a nice way to run blocking I/O and still pump the UI. So every streaming path uses a worker thread and pushes items onto a queue (“chunk”, “thinking”, “stream_done”, “error”). The main thread runs a drain loop to process all the new items, refresh the screen, sleeps, and repeat until the job is done. It can handle 200 tokens per second easily.

Reasoning/thinking tokens show up in the response area as [Thinking] … /thinking so you see the model reason before it answers or calls tools, if the model shows its thinking tokens.

Streaming and async tool calling

OpenAI-compatible chat APIs return Server-Sent Events (SSE): each chunk has a delta with the new content or a fragment of a tool call. The tricky part is that one tool call could be spread across chunks. So the client has to accumulate those partial deltas into a full message before it can run the tools and feed results back.

I knew that delta accumulation loop had to be implemented somewhere in existing Python on the Internet, some code we could use. I prompted an LLM to find it. It first suggested using the Openai library. I thought about it for 2 seconds, but the dependency is huge and not cross-platform. I asked if we could reuse just the relevant bits. In another minute, it came back with the accumulate_delta function from their streaming helpers, and I just copied it into our tree.

I haven’t checked for certain but I’m pretty sure it’s FOSS. Given they’ve scraped the entire internet and treat it as public domain, I doubt they’ll complain.

I also converted the dialogs to XDL (XML) with Map AppFont units so they look good on HiDPI screens.

Image generation and the Graphics branch

Next I added multimodal image generation. The implementation supports two backends:

AI Horde

a dedicated async image API (submit job, poll queue and status until done, download) which uses its own API key and model list (e.g. Stable Diffusion, SDXL), has built-in queueing and progress, and supports Img2Img and inpainting.
Endpoint

the same URL and API key as chat Settings, with a separate image model (e.g. an image-capable model)

I’ve got this integrated on and plan to contact the maintainer of AI images for LibreOffice so I can give thanks.

How I did it: AI, prompting, and pushing on details

I didn’t do this alone, I used AI throughout. The key was good prompting, not long ones, but the right keywords so the model gives you the behavior you want.

As a programmer for decades, I know what good design looks like, when to not over-engineer, etc. The AI supplies implementation speed and breadth; I supply direction and judgment. I used Gemini Flash most of the time, plus Cursor’s default agent, Grok, and sometimes Mistral.

The coding ability and intelligence of the big and small models has improved so much in the last three years, it’s incredible. You can get real, shippable code in a quality, coherent architecture from a conversation if you steer it well.

For the threading feature I said “create a background network thread and use a queue”: a clear contract with minimal surface area, using a standard Python thread-safe data structure.

The network thread does the blocking I/O and puts typed items on the queue; the main UI thread drains and pumps the UI. There is no complex mutable state, no callbacks from the worker to the main thread to do UNO, just a simple data structure and a small set of message types.

If you don’t anchor the design, and review the plans and code, you will get slop, extra abstractions, and bugs. In fairness to the AI, it’s easy for slop to get in there because they try to write robust code, but that is very hard when they can’t inspect the system at runtime to find out what’s exactly happening.

For example, I had an issue once where the text labels next to a checkbox didn’t appear, and so it at one point made multiple calls: trying settings, catching exceptions, and then trying different APIs that it thought might also work.

def set_checkbox_label(ctrl, text):
    try:
        ctrl.Label = text
    except Exception:
        try:
            ctrl.getModel().Label = text
        except Exception:
            try:
                ctrl.getModel().Title = text
            except Exception:
                try:
                    ctrl.getModel().Text = text
                except Exception:
                    print("Failed to set checkbox label.")

That’s not the proper codepath for a checkbox label. Fortunately, it’s also easy to fix, once you figure out what works.

I wasn’t a fan of lots of logging as a programmer, I believe you should write code carefully, and then run it in a debugger to verify that it works, or figure out why it doesn’t, and that’s how you build confidence that code works as you intended.

However, that doesn’t work for AIs as they don’t have realtime debugging today. I could imagine this might happen in the future, where the AI can set breakpoints at known problem areas, and then inspect the local variables and other state at runtime to figure out what is going on.

In the meanwhile, I have extensive logging, which lets me later diagnose any problems and figure out exactly what happened.

I almost never accept the first plan. I review, and ask it to revise it several times. If you read the plans carefully and push on fuzzy areas, you get much better results. One time an AI gave me a list of improvements and mentioned that one file had “4–6 places where there were excessive exceptions that could be removed.” I said: Let’s find out the exact number. Show me the candidates and tell me what you think. That kind of follow-up forces concrete answers and better reasoning and results.

The other thing that made a huge difference was maintaining a good AGENTS.md. It’s a single place that explains the project, the structure, what was done. An AI can read that one file and then be productive on any feature without going off in the wrong direction or making the same mistakes as before. One of my recent rules is to always update the AGENTS.md.

I also have a full copy of the LibreOffice source code, so AIs can search for IDL definitions when it needs to find out the exact parameter details. It’s a meta development environment. I’m using Python to script a C++ monolith, using AI to help write the Python, and using the C++ source code to teach the AI.

This plugin is already useful for more than demos. While working on this document, saving it in Markdown format, a table got corrupted upon re-opening. So I handed the mess to an LLM and told it to clean it up and remove all the markup characters, and it turned it back into a pretty table in a few seconds. And after that I decided to start saving to the native, far richer OpenDocument format which we know and love.

Next Steps

I am working on another article for Week 2 where I explain the process of adding MCP, a research sub-agent using Huggingface smolagents, an evaluation framework, talk to your document, and how Quarzadous completely refactored it.

I’m trying to find more people who want to work together on this. John, the owner of localwriter is busy at the moment, and I’ve not heard back from the LibreCalc AI writer. Quarzadous basically re-wrote much of the infrastructure (new make system, auto-generation of the settings UI from a config schema, a tool registry, service registry, etc.) but then decided to work on his own fork focusing on MCP.

It might be over-engineering for a 15kloc codebase, but I kept almost everything except for the maze of directories. I plan on picking a new name, but haven’t done that yet, it’s a pain and I was hoping to find an existing codebase and a group of people who want to work together.

UPDATE: I picked a new name, WriterAgent! If you want to check out the code or try a pre-release, go here: https://github.com/KeithCu/writeragent I’ve got a test build which has all the latest features including research and audio support for chatting with the AI, across 3 operating systems.

UPDATE 2: Here’s a link to the Part 2 writeup.

Enjoy!

Comments

4 responses to “Building Cursor for LibreOffice: A Week-Long Journey”

  1. […] Curtis spent a week building what he calls “Cursor for LibreOffice,” an AI extension that lives in a sidebar and actually edits your […]

  2. […] Curtis passou uma semana construindo o que ele chama de “Cursor para LibreOffice,” uma extensão de IA que fica em uma barra lateral e realmente edita seus […]

Leave a Reply

Your email address will not be published. Required fields are marked *