DEV Community

Cover image for I built a LaTeX editor where the AI fixes its own mistakes
Umar Pathan
Umar Pathan

Posted on

I built a LaTeX editor where the AI fixes its own mistakes

Here is a loop I have run more times than I want to admit.

Ask a model for an equation. Get LaTeX back. Paste it into Overleaf. It does not compile. Copy the error into the chat. Get an apology and a fix. Paste that back in. Repeat until it renders, or until I give up and write the thing myself.

Anyone doing serious math with an LLM knows this dance. Frontier models sit near 50% on LaTeX tasks in the TeXpert benchmark, and the cycle stays slow for a dumb reason: the renderer knows exactly what is broken, and the model never sees it. They live in different windows.

So when OpenAI's WebMCP Challenge came along I had a specific itch to scratch. What if the renderer sat inside the loop? The agent writes LaTeX straight into the document, the page renders it live, and when it breaks the agent reads the render error like any other tool result.

That became Lemma. It is live at https://lemma-webmcp.netlify.app .

What it is

Lemma is a local-first LaTeX pad for writing math with an agent. You get a CodeMirror source pane, a live KaTeX preview, split view, and an agent that reads and edits the same document through 16 WebMCP tools. Documents sit in IndexedDB inside the tab. No backend, no account, nothing you type leaves the browser.

Stack: Next.js 16 as a static export, React 19, TypeScript in strict mode, Tailwind 4, Zustand. The build took about a week, 57 commits.

The part I care about is the loop in the middle:

Live demo render error

you:    "fix the render errors in my notes"
agent:  get_render_errors  ->  Undefined control sequence: \alpa (block blk_a7f3, position 22)
agent:  update_block       ->  \alpha
page:   math re-renders, the block glows
Enter fullscreen mode Exit fullscreen mode

The seed document ships with a deliberate \alpa typo so anyone can watch an agent catch and fix it in one pass.

Errors as data

get_render_errors does not return a wall of text. It returns coordinates:

{
  "blockId": "blk_a7f3",
  "message": "Undefined control sequence: \\alpa",
  "suggestion": "\\alpha",
  "position": 22,
  "context": "&= \\alpa \\cdot 0 + 1",
  "fix": "Replace the offending command with \\alpha via update_block."
}
Enter fullscreen mode Exit fullscreen mode

A block ID, a character position, an edit-distance suggestion, and the next action spelled out. The error becomes data the agent can act on instead of a vibe it has to interpret. That one tool is the whole project.

Error fixed by agent

Blocks, not strings

The usual way an LLM edits a document is quote matching. Find the paragraph that says X, replace it with Y. That breaks the moment text shifts, and in a live editor text shifts constantly.

Lemma splits every document into blocks. Each equation, theorem, paragraph, table, and figure gets a stable ID. The agent edits blk_a7f3, never a string match.

Hand edits are the tricky part. You type in the raw source, the document re-parses on every keystroke, and IDs could drift, which would leave an agent comment anchored to the wrong paragraph. A reconciliation pass runs after each re-parse and keeps IDs pinned: exact block match first, then similarity scoring with a position penalty. It survives reordering, inserts, deletes, and typo edits, and there are unit tests pinning each case down.

The toolset

Fifteen imperative tools through document.modelContext.registerTool, plus one declarative form tool on the sidebar search.

Reads: list_documents, get_document, get_document_outline, search_documents, get_render_errors. Writes: create_document, insert_blocks, update_block, delete_block, undo_last_change, add_comment, reply_to_comment, resolve_comment, set_view, export_document.

Design rules I settled on after reading the spec and Chrome's docs more than once:

  • Errors are values, never throws. If execute rejects, the agent receives a generic UnknownError and your message dies on the way out. Every failure path returns { error, hint } with the next action in it.
  • Honest annotations. WebMCP has exactly two, readOnlyHint and untrustedContentHint. Reads carry the first. Anything returning user-written LaTeX or comments carries the second, because prompt injection travels through math too.
  • Budgets. 500 characters for a tool description, 150 for parameters, outputs trimmed to what the agent needs next.
  • Input caps with useful messages. 50 blocks per insert, 50k characters per block, 4k per comment. Every cap tells the caller what to do instead of just failing.

Two tools deserve a highlight. set_view lets the agent switch you to split view and scroll to the block it just edited, which demos beautifully. And undo_last_change is author-aware: the store records who made each change, the agent can revert its own edit, but if you typed something more recently it refuses and explains why. I did not want an agent quietly erasing my work while I was not looking.

Gotchas the spec taught me

The spec is short but it bites.

  1. registerTool needs origin isolation. No Origin-Agent-Cluster: ?1 header, no tools, just a SecurityError. Static hosting handles it. The sneaky one was local dev, because next dev does not read your netlify.toml, so I added dev-only headers to the Next config.
  2. Inputs and outputs are strings. executeTool returns a JSON string, and in the Chromium I tested against the input side also wanted JSON strings. Raw objects turned into "Failed to parse input arguments". I tolerate both shapes now.
  3. The ChatGPT browser implements a subset. It discovers imperative tools and skips declarative form tools. My search was originally a declarative form on the sidebar, which worked great in Chrome and was invisible to ChatGPT. So search_documents became a full imperative tool.
  4. Tab tools are tab tools. I fired up Codex to test and it told me my tools were not connected to its interface. I spent a while convinced I had broken something. I had not. A sandboxed CLI agent has no bridge to your browser tab. Run the agent inside the ChatGPT desktop app's built-in browser and everything lights up.

The engine

KaTeX does the math. Around it I built document-wide numbering: numbered equations get real tags, theorems count per kind across 17 environments, \ref and \eqref resolve live and renumber when blocks move. mhchem handles chemistry. Macros from the preamble apply document-wide. Tables parse the column spec, including \multicolumn and booktabs rules.

The part that ate the most time was a TikZ subset that compiles to inline SVG. Lines, rectangles, circles, ellipses, nodes with labels, fills, dash patterns, named colors. It is a subset and I say so on the tin. Anything unsupported renders an explicit chip in the preview and survives untouched in the .tex export, which the agent can see and route around. A fast honest pad beats a fake full-LaTeX clone.

A render cache keyed on numbering version plus block hashes keeps typing smooth, so editing one block re-renders exactly that block even in a 60-block document.

Keeping a human in the loop

Agent comments sit in the margin next to the rendered block, badged as agent-authored, never impersonating you. Agent edits glow briefly so you notice them. In Chrome you can watch every call live in DevTools under Application → WebMCP, with counters and full input and output logs.

For anyone without a WebMCP-capable browser there is a Tool Inspector built into the app. Same registered tools, same code path, wrapped in UI. You can drive the entire toolset yourself with JSON.

Try it

Easiest path is the ChatGPT desktop app's built-in browser. On Chrome, flip chrome://flags/#enable-webmcp-testing and watch the calls land in DevTools. Then ask your agent to fix the render errors in the demo document. The \alpa typo is waiting.

The model did not get smarter. The feedback loop just got shorter. That is the whole trick.

Top comments (0)