DEV Community

AJ
AJ

Posted on

Teaching a spreadsheet to write its own formulas

I built an AI feature into Cells, a spreadsheet app I have been building with Next.js 16, React 19, Convex, and HyperFormula. This is a writeup of what the feature solves, the technical choices behind it, what broke on first contact with a real user (me), and what I would do differently.

What the app solves, and why this feature

Cells is a real-time spreadsheet: per-user workbooks behind Better Auth, sparse cell storage in Convex, formulas evaluated client-side by HyperFormula, autosave with undo/redo. It already worked for people who know spreadsheets. The problem is everyone else.

Formula syntax is the single biggest barrier to spreadsheet use. Users know exactly what they want, "average of sales where the region is West", but not that this is =AVERAGEIF(A2:A100,"West",B2:B100). Every other feature in the app (formatting, copy/paste, undo) helps people who already know the tool. Nothing helped the ones who do not.

So the feature is natural language to formula. Select a cell, click a sparkle button next to the formula bar, describe the formula in plain English. The model returns a formula, the app validates it, shows a computed preview, and inserts it only when you confirm. A second mode, Ask, answers plain-text questions about the visible data. I chose this over flashier options (AI fill, chat sidebars) because the output is a single string that the formula engine can verify before it ever touches the sheet. Smallest surface, most checkable output.

Provider, model, and why

The provider is Mistral AI, mistral-large-latest through the chat completions API, temperature 0.1, max_tokens 256 for formulas and 400 for answers. I default to the strongest available model rather than downgrading for cost. Formula generation looks trivial but is not: the model has to read a text rendering of the sheet, infer the data extent, pick the right function, and respect a dialect that is close to Excel but not Excel. Small models pad ranges and hallucinate functions more often, and every bad formula costs a user round trip.

The call lives in a Convex action, not a Next.js route and never the client. The API key sits in Convex deployment env and no key material ever reaches the browser. The action authorizes the caller against sheet ownership before anything else, and caps the payload server-side (prompt 500 chars, sheet context 8,000 chars) so the endpoint cannot be used as a token firehose.

Did I stream? No, on purpose

Streaming makes sense when the user reads output as it arrives. Neither mode qualifies. A formula is useless until it is complete: you cannot validate half a formula, you cannot preview it, and watching =AVERAGEI grow into =AVERAGEIF helps nobody. The whole response is under 256 tokens and arrives in a couple of seconds; the popover shows a loading state and then the finished, validated result. Ask-mode answers are capped at roughly 120 words, short enough that streaming would save under a second of perceived wait while complicating parsing, error handling, and the Convex action contract. If Ask ever grows into long-form analysis, streaming becomes worth it. For single short strings, it is machinery without benefit.

How responses are structured

I did not trust the model to freestyle. Three layers shape every response.

First, the system prompt constrains output hard: exactly one formula, single line, leading =, no markdown, no prose. It carries an allowlist of preferred functions (SUMIF, COUNTIFS, VLOOKUP, INDEX/MATCH...), a denylist of Excel-only ones (LET, LAMBDA, TEXTJOIN...), a rule to use tight ranges covering only the data shown, a rule to never reference the selected cell, and an escape hatch: if the request cannot be a single formula, output ERROR: <reason>. That escape hatch is what turns "What is this sheet for?" into a clean refusal instead of garbage in a cell.

Second, a strict parser normalizes the raw completion: strip code fences and quotes, prepend a missing = on a bare SUM(...), reject multi-line output and prose, pass ERROR: reasons through to the UI. It is a pure function with its own test suite, shared verbatim between production and the eval suite so the evals measure exactly what production sends.

Third, the client validates the formula against the live HyperFormula engine before offering Insert, and the action itself returns typed errors (INVALID_ARGUMENT, NOT_CONFIGURED, UPSTREAM, BAD_OUTPUT) so the UI can say something specific instead of "something went wrong".

The sheet context sent to the model is also structured text, built client-side: the selected cell, the used range of the data, and one line per non-empty cell in a window around the selection, with formulas rendered as B5: =SUM(B2:B4) -> 360. Client-side because the Zustand store is fresher than the database while edits sit in the autosave debounce.

The build, and what did not work at first

The build went: pure modules (prompt, parser, context builder) with unit tests, then engine validation, then the Convex action with a mocked-fetch test suite, then the popover UI, then a paid eval suite hitting the real API with fixture-sheet cases graded by value equivalence, not string matching, so SUMIF versus SUMIFS variants both pass.

Then I used it, and the very first real prompt broke it. "Sum of Column 1" with three values in A2:A4 produced =SUM(A2:A1000). The preview said 101, correct. I clicked Insert and the cell said #CYCLE!. Two bugs in one: the model padded the range to the whole grid because nothing told it the data extent, and the range included A5, the cell the formula was inserted into. The validation preview missed it because it evaluated the formula unanchored, floating over the sheet instead of sitting in A5. The preview and reality could disagree, which defeats the point of a preview.

The fix was to make the bad path unreachable rather than less likely: validation now places the formula in the actual target cell, reads the value, and restores the previous content, so the preview equals the post-insert result by construction and #CYCLE! is rejected before Insert exists. The prompt gained the tight-range and no-self-reference rules, the context gained a Used range: A1:B5 line, and the eval gained a case with the target cell directly below the data column, the exact shape that failed.

Testing had its own surprises. My #NAME? test assumed HyperFormula lacks XLOOKUP; it turned out HyperFormula 3.3 implements it and returns #N/A. A thirty-second probe script against the installed library replaced an assumption from training-data-era knowledge with a fact, and LET became the test case. A Convex test failed with "Sheet not found" instead of "Forbidden" because each convexTest() call creates an isolated database; the non-owner was querying an empty world. And running the full suite surfaced a pre-existing store test that had been quietly wrong since before this feature.

Ask mode itself came from a failure. I typed "What is this sheet for?" into the formula box and got the ERROR guardrail. The refusal was correct and the product was incomplete. Rather than auto-routing requests in the model (a second latent-space guess), the popover got an explicit Formula | Ask toggle. User intent stays explicit, both paths stay deterministic and testable.

What I would do differently

Anchored validation from the start. I designed the preview as "evaluate the formula somewhere safe" when the only question that matters is "what happens in this exact cell". Any check that simulates instead of measuring the real target will eventually disagree with reality.

I would also verify library capabilities before writing tests against them. The XLOOKUP assumption cost a failing run that a probe script would have prevented in under a minute. And I would design the popover for two modes from day one; retrofitting the toggle was cheap, but the first version's aria-labels, state machine, and copy all assumed formulas-only and each needed touching.

One honest reflection

The model was never the hard part. Mistral produced reasonable formulas from the first call. Almost all of the engineering, and all of the bugs, lived in the scaffolding around it: what context to send, how to constrain and parse the output, how to prove a suggestion is safe before it touches user data. The first shipped bug was not the model being wrong, it was me trusting a preview that measured something other than reality. If I keep one lesson, it is that an AI feature is trustworthy exactly to the degree that its deterministic shell can catch the model being wrong, and that shell is where the real work is.

Top comments (0)