GridPath

io.github.pixelsmasher13Published by GitHub account pixelsmasher13v0.5.0Updated Nov 3, 2026

Edit real .xlsx files with your agent: live formulas, and you review every change before it saves.

Overview

AI-generated overview

Local MCP server that lets an assistant read and edit real .xlsx workbooks with live formulas, staging every change for review before saving.

What you can do
  • Inspect a workbook's structure, headers, sections and named ranges, and find rows by label
  • Read evaluated values and formulas from a range, or filter, project and aggregate a sheet's rows by column values
  • Write cells, blocks, formulas and Excel-style copies, or run a short JavaScript script as one batch
  • Apply formatting, column widths, row heights, merges, notes, freeze panes, hidden rows and columns
  • Create, rename and delete sheets, insert or delete rows and columns, define named ranges
  • List or reject pending change batches and save the workbook, patching only the parts that changed
When to use it
Useful when an assistant should build or modify financial models and other spreadsheets on disk while keeping formulas live and letting a person approve each change. Fits agent workflows in Claude Code, Claude Desktop, Cursor, Codex or similar hosts that need to edit existing .xlsx files rather than generate new ones.
Requirements
Runs locally over stdio, typically via npx with Node.js (the Claude Desktop bundle includes Node). Each tool takes a file path; access is limited to the home directory by default unless --allow folders are given. A local review page is served on a port (random by default, or --port). No accounts, API keys or environment variables are declared.
Before you install
The assistant can modify .xlsx files on disk: save_workbook is the only tool that writes, and in-place saves can be gated with --review-required so the user saves from the review page. Deleting sheets, rows or columns is permanent and can turn cross-sheet references into #REF!. Pending batches are stored next to the workbook in a .gridpath folder. Review the changed cells before saving.

Installation

In SourceWeft

  1. Open GridPath in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

gridpath

[npm] [license] [node]

Real Excel files, in your web app and in your agent. Open the .xlsx your users already have, keep every formula live at full size, let AI edit it with review, and save back the same file.

In your web appIn your coding agent
Installnpm install gridpathclaude mcp add gridpath -- npx -y gridpath mcp
What you getAn embeddable spreadsheet: a canvas grid over a Rust/WebAssembly engine in a Web Worker, in-place .xlsx saves, and agent tools for your own modelAn MCP server for Claude Code, Claude Desktop, Cursor, Codex: the agent edits files on disk and you review every change before it is saved
DocsIn your web appIn your coding agent

Both run the same engine (a fork of IronCalc): real formulas evaluated in process, and saves that patch the original file so untouched parts come back byte-identical. Recalculated formulas and supported chart data caches carry current values; chart definitions remain native Excel XML.

In your web app

Quick start

sh
npm install gridpath react react-dom

React and React DOM are peer dependencies. Your app doesn't have to use React: createSpreadsheet mounts the spreadsheet into any element.

tsx
import { Spreadsheet } from "gridpath";
<div style={{ height: 600 }}>  <Spreadsheet    file={bytes} // an .xlsx as ArrayBuffer or Uint8Array; omit for a blank workbook    onReady={(sheet) => (sheetRef.current = sheet)}    onError={(err) => console.error(err.code, err.message, err.hint)}  /></div>;

Without React:

ts
import { createSpreadsheet } from "gridpath";
const sheet = await createSpreadsheet(document.getElementById("sheet")!, { file: await file.arrayBuffer() });sheet.setCell("Sheet1", "B2", "=SUM(A1:A10)");console.log(await sheet.getCell("Sheet1", "B2")); // { value: 55, formula: "=SUM(A1:A10)" }const xlsx = await sheet.save(); // Uint8Array

createSpreadsheet resolves once the workbook is open. If it can't open it rejects with a GridPathError and leaves the element empty.

Next.js

No configuration is needed. This was tested on Next.js 16 with React 19, with Turbopack and with webpack, in next dev and in production builds.

  • Render the spreadsheet from a client component ("use client"). The page around it can stay a server component.
  • gridpath/agent works in a route handler.

examples/nextjs-demo is a complete app. Each user has a list of workbooks saved to the app's own API: app/api/books/[[...path]]/route.ts holds the REST routes httpStore calls, with auth. Each workbook has its own URL. The example shows:

  • uploading and starting new workbooks;
  • a loading indicator;
  • what to do for each error code;
  • switching workbooks, which saves the one being left.

What users get without any code

  • A toolbar for formatting:

    • undo and redo;
    • font and size;
    • bold, italic and underline;
    • font and fill colour;
    • alignment and wrap;
    • currency, percent, comma, and more or fewer decimals;
    • number formats;
    • inserting and deleting rows and columns;
    • sort, filter, freeze panes and clear.

    Keyboard shortcuts (⌘B, ⌘Z, ⌘S…) work in the grid. toolbar={false} hides the toolbar.

  • A save status that says where things are saved: "Saved in this browser", "Saved to your account", "Saving…", "Unsaved changes", or "Couldn't save · Retry".

    • Edits are saved two seconds after they pause. ⌘S or the Save button saves at once.
  • Reopening where they left off: after a reload, the workbook comes back and so does the cell the user was on.

  • A File menu with Open…, New and Recent workbooks. fileMenu={false} removes those entries when your app has its own list.

  • Version history: restore an earlier version, or download it. Restoring keeps the state it replaces as a version too.

  • Download as .xlsx, a separate button from Save.

  • An "AI changes" panel when an agent works through sheet.ai. Each change shows what it did ("D2: empty → =C2/B2-1") and where, with Accept and Reject buttons. Clicking a change shows its cells.

    • reviewPanel={false} turns the panel off if your app has its own review UI.
  • Plain explanations in place of the grid when a file can't be opened, for example "an old-format .xls workbook or a password-protected .xlsx".

  • A count of formulas showing Excel's saved value in the toolbar, with a list of them.

  • Its own question before anything is lost: opening another workbook over changes that can't be saved asks "Discard unsaved changes?".

Workbooks and documents

A spreadsheet shows one workbook at a time. You identify a workbook by its documentId, which defaults to fileName. Together with a store, the ID is how a workbook is loaded:

tsx
<Spreadsheet store={store} documentId={book.id} fileName={book.name} />

The spreadsheet opens the stored state for that ID. If you also pass a file, the stored state wins only when it was saved from that same file; otherwise file opens. With nothing stored and no file, the workbook starts blank.

Switching workbooks can be done in two ways:

  1. Remount per document. Give the component key={book.id}, as the Next.js example does with a page per workbook.
  2. Call open() or openStored() on the same spreadsheet. Both follow the same rules:
When you open another workbook…
AI changes are waiting for reviewIt rejects with PendingChangesError. Accept or reject them first.
There are unsaved edits and a store or onSaveThey are saved first.
The save fails or nothing is set up to save toIt rejects with UnsavedChangesError. Pass { discardUnsaved: true } to drop the edits. A remote store's failed save is not counted as a failure here: those edits are kept in this browser.
The new file can't be openedIt rejects with unsupported-file or malformed-file, and the previous workbook stays open. Plainly wrong files (empty, old .xls, a PDF or image) are refused before anything changes.

The File menu follows the same rules and asks the user before it discards anything.

Cleaning up.

  • Unmount the component, or call destroy() (createSpreadsheet only). If there are edits that aren't saved yet, they are saved first. This covers a client-side navigation, which fires no beforeunload. Then the engine's worker stops. From then on, every call on the old sheet object fails with not-ready.
  • Closing the page while edits are unsaved prompts the browser's "Leave site?" warning.

CSV and text files

file, open() and the File menu also take CSV and other delimited text. It opens as a new workbook, which saves as .xlsx: sales.csv becomes sales.xlsx, with one sheet named sales, and the toolbar says so.

  • Delimiters: comma, tab, semicolon or pipe, whichever the first lines agree on. Excel's sep=; first line is honoured. In semicolon files a comma is the decimal point (1.234,5 is 1234.5).
  • Encodings: UTF-8 (with or without a byte-order mark), UTF-16 with a mark (Excel's "Unicode Text"), otherwise Windows-1252.
  • Values are read as if typed into the cell: numbers, currency, percentages, ISO and US dates, TRUE/FALSE, and = formulas, which calculate. A formula that doesn't parse stays text.
  • Kept as text, where Excel would lose them: digits with leading zeros (ZIP codes, IDs) and digit strings longer than 15 (card and account numbers).
  • Columns are widened to fit their text, up to 60 characters.

Text that isn't a table (.json, .xml, .html, .md…) is refused with unsupported-file. A 17 MB CSV (2 million cells) opens in 7 to 10 seconds in Chromium on a recent Mac.

Saving

Saving happens in three ways:

  • Autosave: two seconds after edits pause. Change the delay with autosave (milliseconds), or turn it off with false.
  • ⌘S and the Save button.
  • sheet.saveNow() from your code.

Each save writes the workbook to the store and calls onSave. Watch it with onStatusChange or getStatus():

stateThe toolbar says
"saved""Saved in this browser" / "Saved to your account"savedAt, and where: the store's label.
"unsaved""Unsaved changes"An autosave is coming.
"saving""Saving…"
"review""Review 2 AI changes to save"pending. Saving waits for the user to accept or reject the AI's changes.
"error""Couldn't save · Retry", or "Saved in this browser only · Retry"code, message and hint. keptLocally is set when the edits are safe in this browser, and retryAt when autosave will try again.
"idle"(nothing)A fresh workbook, or no saving set up.

save() and saveBlob() return the bytes without touching the store, for an upload of your own.

Saving to your backend

By default, everything is kept in the user's browser (IndexedDB) by browserStore(). Saves within five minutes of each other share one version. The newest 20 versions are kept as they are, then one per day for 30 days. Tune this with browserStore({ checkpointMinutes, keepRecent, keepDays }).

To save to the signed-in user's account, point httpStore at your API:

ts
import { httpStore } from "gridpath";
const store = httpStore({  url: "/api/books",  // Called before every request, so a refreshed token is picked up. Leave it  // out with cookie sessions.  headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),});
<Spreadsheet store={store} documentId={book.id} />;

The routes it calls, all relative to url. Ids are URL-encoded:

RequestResponse
PUT /{id}?name=…&source=…[&checkpoint=1], with the .xlsx as the bodyAny 2xx. source fingerprints the file the workbook was opened from. checkpoint asks for a version of its own (before and after a restore).
GET /{id}The .xlsx, with headers: X-Workbook-Name (percent-encoded), X-Workbook-Saved-At (an ISO time), X-Workbook-Source. A 404 when there's none.
GET /[{ id, name, savedAt, size }], newest first (the Recent list).
GET /{id}/versions[{ id, savedAt, size }], newest first. A 404 when you keep no history.
GET /{id}/versions/{versionId}That version's .xlsx.
DELETE /{id}Any 2xx.

When the backend can't be reached or answers with an error, the user's edits are not lost:

  1. The status reads "Saved in this browser only · Retry", with keptLocally: true.
  2. Autosave retries after 2, 5, 15 and 30 seconds, then every minute.
  3. A copy of the edits waits in this browser.
  4. If the page is closed first, the copy is reopened next time, newer than the backend's, and saved there. The status line says "Restored changes that weren't saved to your account".

If the backend can't be reached when a workbook opens, the spreadsheet shows the error with Try again. It does not open a blank workbook that autosave would then write over the user's saved one.

Anything else can be a store. httpStore is one implementation of this interface:

ts
interface WorkbookStore {  label?: string;   // the status line's "Saved …" wording: "to your account"  local?: boolean;  // true when it is in this browser (no extra drafts kept)  save(w: { id: string; name: string; bytes: Uint8Array; source?: string; checkpoint?: boolean }): Promise<void>;  load(id: string): Promise<{ id; name; bytes; savedAt: string; source?: string } | null>;  list(): Promise<Array<{ id; name; savedAt: string; size: number }>>;  versions(id: string): Promise<Array<{ id: string; savedAt: string; size: number }>>;  loadVersion(id: string, versionId: string): Promise<Uint8Array | null>;  remove?(id: string): Promise<void>;}

There are two simpler options:

  • onSave: receive the bytes on every save and upload them yourself. A rejected promise counts as a failed save and is retried.
  • store={false}: no saving at all; users keep Download, and your code keeps save().

Errors

Everything the spreadsheet reports is a GridPathError. Each one has:

  • code, for your code to act on;
  • message, which says what happened;
  • hint, a sentence you can show that says what to do.

Errors reach you in four ways:

  • Methods reject with them. For example, open(), openStored(), save() and getCell().
  • onError receives engine failures and workbooks that couldn't be opened: the first one, or one opened from the File menu.
  • onStatusChange reports a failed save as { state: "error", code, message, hint }. Failed saves don't go to onError, because autosave retries them.
  • The spreadsheet itself shows the problem in place of the grid, so users are never left with an empty box.
ts
try {  await sheet.open(bytes, { name: file.name });} catch (e) {  if (!(e instanceof GridPathError)) throw e;  if (e.code === "unsaved-changes" && confirm(`${e.message}. Discard them?`)) await sheet.open(bytes, { name: file.name, discardUnsaved: true });  else showToast(`${e.message}. ${e.hint}`);}
codeWhenWhat to do
unsupported-fileNot a spreadsheet: an empty file, an old-format .xls, a password-protected workbook, a PDF, image or JSON file.Ask for an .xlsx or CSV (the hint says how to get one).
malformed-fileAn .xlsx the engine couldn't read (damaged or truncated), or a CSV that couldn't be read as a table.Open and re-save it in Excel.
engine-failedThe engine's worker or .wasm didn't load, or the engine stopped later. If it stopped, a banner says calculation stopped and offers Download.Serve the package's engine.worker.js and .wasm (see Bundlers); reload the page.
storage-failedThe store failed. With httpStore, the error's cause is an HttpStoreError with the response status. A URL that returns a web page instead of data says so.On 401/403, sign the user in again. Otherwise autosave retries.
save-failedThe session's changes couldn't be written into the file.The edits are still on screen; undo the last change and save again.
calculation-timeoutgetCell() (after 30 s), whenCalculated(ms) or save() (after 60 s) waited for a recalculation that was still running.Try again once it finishes.
pending-changessave() or open() while AI changes await review (PendingChangesError, with pending).Accept or reject them (sheet.ai.accept() / reject()), or save({ includePending: true }).
unsaved-changesopen() would drop edits that couldn't be saved (UnsavedChangesError).Ask the user, then retry with { discardUnsaved: true }.
not-foundopenStored(id) found nothing.
not-readyA method was called before onReady, while another workbook is opening, or after unmount.Wait for onReady (or the promise from open()).

Formulas GridPath can't calculate

Some formulas don't calculate in GridPath:

  • an Excel add-in function (_xll.…);
  • a function GridPath doesn't support yet;
  • a link to another workbook.

These cells keep the value Excel saved in the file, and the formulas that read them calculate from that value. The user sees a count in the toolbar and a list that explains each cell. Your code can read the same thing:

ts
await sheet.getCell("Model", "C7");// { value: 1250, formula: "=_xll.FDS(…)", fromFile: true,//   note: "Showing Excel's saved value because GridPath couldn't calculate this formula: it calls FDS, an Excel add-in function." }
await sheet.getCalcIssues(); // [{ sheet, ref, row, col, formula, value, calculated, note }]

Options

Option
fileThe .xlsx bytes (ArrayBuffer or Uint8Array). Without it, the workbook starts blank or reopens the stored one. Read when the component mounts; to open others, use open().
sheetsSheet names for a blank workbook (default ["Sheet1"]).
fileNameThe workbook's file name, for downloads and the Recent list (default "Untitled.xlsx").
documentIdWhich stored workbook this is (default: fileName). See Workbooks and documents.
storeWhere saves, recent workbooks and versions go. The default is browserStore(). You can pass httpStore({ url, headers }), your own WorkbookStore, or false.
autosavetrue (the default: two seconds after edits pause), a delay in milliseconds, or false.
onSave({ id, name, bytes }) => …, called on every save. A rejected promise counts as a failed save.
onStatusChangeCalled when the save status changes.
onReady(sheet) => …, called once the first workbook is open (React; createSpreadsheet resolves instead).
onError(error: GridPathError) => …, for engine failures and workbooks that couldn't be opened.
onDocumentChange({ id, name }) => …, called when another workbook was opened: from the File menu, Recent, a restore, open() or openStored().
onEditCalled after each user edit in the grid.
onProgressLoading stages ("Loading workbook…", "Building grid…"), for your loading indicator.
toolbarfalse hides the toolbar.
fileMenufalse removes Open, New and Recent from the File menu.
reviewPanelfalse turns off the "AI changes" panel. Build your own from sheet.ai.list(), accept() and reject().
engine.workerfalse runs the engine on the page instead of in a Web Worker.
engine.workerUrl, engine.wasmUrlWhere to load the worker script and the .wasm from, when your bundler doesn't serve them next to the package.

Methods

MethodContract
getSheetNames()Sheet names in order.
getCell(sheet, "A1"){ value, display?, formula, fromFile?, note? }, or null for an empty cell. Waits for pending writes to recalculate (up to 30 s, then it rejects with calculation-timeout).
getCalcIssues(limit?)The formulas showing Excel's saved value (see above).
setCell(sheet, "A1", value)Returns at once. Writes are queued and recalculated in order, so a getCell after it sees the result. Strings starting with = are formulas.
setCells(sheet, [{ row, col, value }])Many cells in one step (0-based).
whenCalculated(ms = 30000)Resolves once pending writes are recalculated; rejects with calculation-timeout after ms.
select(sheet, "A1"), focus()Move the selection; give the grid keyboard focus.
undo(), redo()Undo or redo the last change, the user's or an agent's.
save(options?), saveBlob(options?)The workbook as .xlsx bytes (or a Blob), with the store untouched. Waits for recalculation (up to 60 s). Rejects with PendingChangesError while AI changes await review, unless you pass { includePending: true }.
saveNow()Save to the store and onSave now, as autosave and ⌘S do. Resolves with the new status and never rejects: a failure becomes { state: "error", … }.
hasUnsavedChanges()true while there are edits the store doesn't have yet, AI changes awaiting review included. Useful for a route-leave prompt.
download(fileName?)Download the .xlsx.
getStatus(), getDocument()The save status; the open workbook's { id, name }.
open(bytes, { name?, id?, discardUnsaved? })Open another workbook here, following these rules. Resolves once it is open.
openStored(id, { discardUnsaved? })Open a workbook from the store by ID; rejects with not-found if there is none. The same rules apply.
destroy()Save what's unsaved, unmount, and stop the engine (createSpreadsheet only; in React, unmount the component).
aiThe agent tools; see below.

AI: your model, your key

sheet.ai is GridPath's agent tool set (the same tools and result shapes as the MCP server) acting on the grid the user is looking at. Give the tools to your model, run its tool calls with sheet.ai.run(), and let the user review: every write lands at once, highlighted, as a pending batch they accept or reject in the "AI changes" panel (or your own UI).

GridPath never sees a key. The model call happens on your server; the tool calls run in the browser, where the workbook is.

Server (any Node route; this one is the demo's server/agent.ts). The system prompt and tools are fixed here; taking them from the request would make the route a general proxy for your key. Add your own auth and rate limits.

ts
import Anthropic from "@anthropic-ai/sdk";import { AGENT_INSTRUCTIONS, agentTools } from "gridpath/agent";
const client = new Anthropic(); // ANTHROPIC_API_KEYconst tools = agentTools() as Anthropic.Beta.BetaTool[];
export async function agentTurn(messages: Anthropic.Beta.BetaMessageParam[]) {  const response = await client.beta.messages.create({    model: "claude-opus-5-5",    max_tokens: 16000,    output_config: { effort: "high" },    system: AGENT_INSTRUCTIONS,    tools,    cache_control: { type: "ephemeral" }, // system + tools are identical every call    betas: ["server-side-fallback-2026-07-01"],    fallbacks: "default", // a declined request re-runs on a fallback model    messages,  });  return { content: response.content, stop_reason: response.stop_reason };}

Browser (the demo's src/agentLoop.ts, shortened):

ts
const messages = [{ role: "user", content: [  { type: "text", text: `The open workbook:\n${await sheet.ai.context()}` },  { type: "text", text: prompt },] }];for (;;) {  const out = await (await fetch("/api/agent", { method: "POST", body: JSON.stringify({ messages }) })).json();  messages.push({ role: "assistant", content: out.content }); // unchanged, thinking blocks included  const calls = out.content.filter((b) => b.type === "tool_use");  if (out.stop_reason !== "tool_use" || calls.length === 0) break;  const results = [];  for (const c of calls) {    const r = await sheet.ai.run(c.name, c.input);    results.push({ type: "tool_result", tool_use_id: c.id, content: JSON.stringify(r), ...(r?.ok === false ? { is_error: true } : {}) });  }  messages.push({ role: "user", content: results }); // all results in one message}

Already have an agent framework? Use sheet.ai.tools() (Anthropic format) or sheet.ai.tools("openai") and route tool calls to sheet.ai.run().

sheet.ai
tools(format?)Tool definitions: "anthropic" (default) or "openai". Also exported from gridpath/agent as agentTools().
instructions()How to use them, for the system prompt (AGENT_INSTRUCTIONS).
context()The workbook overview for the first turn.
run(name, input)Run one tool call; resolves with JSON for the model. Never throws.
list(), onChange(fn)The batches, pending or accepted.
accept(id?)Keep one batch, or every pending one.
reject(id?)Undo a batch and every batch after it, newest first, so rows and columns moved by later batches stay right. Resolves with the ids undone.

Included: describe_workbook, find_rows, query_rows, read_range, set_cell, set_range, copy_range, set_format, clear_range, run_script (sandboxed in a worker), row/column/sheet structure, freeze, hide, merge, notes, sizes, define_name. Not included: tools that need the desktop app or the network (fetch_web, edgar_lookup, reference workbooks).

save() refuses while changes are pending: it throws a PendingChangesError (with a pending count) until the user accepts or rejects them, so nothing unreviewed reaches the file by accident. save({ includePending: true }) saves them anyway.

How saving works

Every save replays the session's changes (cells, formats, sizes, rows and columns, sheets, filters) onto the bytes the workbook was opened from. Parts the session did not touch are copied through unchanged. Formulas carry their results: every new formula, and every formula the session's edits recalculated, is saved with its current result as the cached value (the formula text is left as it was), so a reader that never calculates (pandas, openpyxl with data_only=True, file previews) sees current numbers. Formulas the session did not affect keep the values Excel saved. The file is also marked for recalculation on open, which Excel honours.

Charts

The browser renders imported standard line charts at their worksheet anchors, using a separately loaded Apache ECharts SVG renderer. Source values come from the calculation engine, including cells outside the viewport; edits and undo update the chart after recalculation. await sheet.getCharts() returns the display descriptors and current values. sheet.getLastEditTimings() reports the most recent edit's calculation, serialization and transfer/apply timings.

This first version supports direct one-dimensional worksheet references up to 10,000 points per series, basic titles, legends, RGB line colors and numeric axes. It approximates Excel's visual styling. Other chart types, named/external data references, stacked/smoothed lines, date/log/secondary axes, and chart creation or editing controls are not supported. Unsupported charts show a placeholder and retain their native definitions on save.

Browser and headless saves refresh affected direct-reference numeric/string chart caches from the saved worksheet values. Source references, styling and unrelated chart XML stay intact. Unsupported references retain their original caches; Excel's recalculation flag remains set.

Compared with other embeddable spreadsheets

Against the spreadsheet components a web app would otherwise embed, on real models in Chromium (workbooks, method and full results):

GridPathSpreadJS 19.2Handsontable 18.1 + HyperFormula 3.5 + ExcelJS 4.4
1.4 MB analyst model: open, fully recalculated0.6 s, 0.8 s1.5 s, 3.6 s1.9 s
… agrees with Excel's results95.5%94.0%56.9%
… lost on savenothing (75 of 76 parts byte-identical)custom XML, printer settingscustom XML, printer settings, comments, 3,335 of 3,418 names
Data-intensive model over 10 MB (2.3M cells): open, fully recalculated4.2 s, 10.9 stab crashes (lazy mode: 6.5 s, 3.7 min)10.8 min
… edit → dependent updated2.7 s— (lazy mode crashes on the first edit)64 s

Bundlers

The package loads its worker and engine with new URL("…", import.meta.url), which Next.js, Vite, webpack 5 and Rollup bundle on their own.

  • Next.js: no configuration (see Next.js).

  • Vite: production builds work as is. For the dev server, exclude the package from dependency pre-bundling:

    ts
    // vite.config.tsexport default defineConfig({ optimizeDeps: { exclude: ["gridpath"] } });
  • webpack 5 (outside Next.js): no configuration; new URL workers and assets are built in.

  • Anything else: copy engine.worker.js, its chunks and gridpath_engine_bg.wasm from dist/ to your static files and pass engine: { workerUrl, wasmUrl }.

Size in the browser: about 120 KB of JavaScript and a 1.3 MB engine, gzipped (0.9 MB with brotli). The engine downloads once and is cached by the browser.

Build from source

sh
crates/engine-wasm/build.sh   # needs rustup target wasm32-unknown-unknown and wasm-bindgen-clicd packages/gridpath && npm install && npm pack

examples/embed-demo installs the packed tarball the way an app would.

In your coding agent (MCP)

Let your agent edit Excel files. Review before anything is saved.

An MCP server for Claude Code, Claude Desktop, Cursor, Codex or any MCP host that edits the .xlsx / .xlsm files you already have, with a review step:

  • Review before save. Every write returns a review_url: a local page with the workbook, changed cells highlighted, before/after per cell, Accept / Reject per batch or per cell, Save or Save as… (a copy). With --review-required, only that page can write the file; the agent just hands you the link.
  • Leaves your Excel file intact. No re-export through a library, no conversion into another format. Saves patch the original bytes: only the parts an edit touched are rewritten. Untouched parts, including pivots, VBA macros, add-in data and custom XML, come back byte-identical. Referenced chart caches update with recalculated worksheet values.
  • Real formulas, in process. A calculation engine (a fork of IronCalc, compiled to WebAssembly) runs inside the server. The agent reads evaluated values and writes formulas that work when the file opens in Excel; inserts and deletes shift references.
  • Runs anywhere Node runs. No Python, no openpyxl, no LibreOffice, no Excel install. macOS, Windows, Linux.
  • Sandboxed scripts. run_script runs the agent's JavaScript against the workbook in a worker with a hard timeout and lands the result as one reviewable batch.

[GridPath review page: workbook with a changed cell highlighted and a panel of pending change batches]

Install

Claude Desktop (no terminal)

Download gridpath-0.5.2.mcpb and open it. Claude Desktop shows an install dialog where you pick the folders Claude may edit and whether saving requires review. Node is bundled; nothing else to install. Claude Desktop shows its standard "not verified" warning for any extension installed from a file; the source is public and nothing is written until you accept and save on the review page.

Claude Code
sh
claude mcp add gridpath -- npx -y gridpath mcp --review-required
Cursor

Add to Cursor (one click), or add to mcp.json:

json
{ "mcpServers": { "gridpath": { "command": "npx", "args": ["-y", "gridpath", "mcp", "--review-required"] } } }
Codex (~/.codex/config.toml)
toml
[mcp_servers.gridpath]command = "npx"args = ["-y", "gridpath", "mcp", "--review-required"]
Gemini CLI, Windsurf, VS Code, anything else

Same command/args as the Cursor JSON in that host's MCP settings. The server speaks stdio.

Options
FlagMeaning
--allow <dir> [<dir>...]Limit the server to these folders. Default: your home directory.
--review-requiredThe agent cannot save. save_workbook returns the review link; you accept and save there. Also GRIDPATH_REVIEW_REQUIRED=true.
--port <n>Port for the local review server (default: random).
gridpath review <file>Reopen the review page for a workbook's pending batches later.

Pending changes live next to the workbook (.gridpath/<file>.batches.json) until saved or rejected, so nothing is lost if the agent's session ends.

Tools

Every tool takes path. Cells and ranges are A1 notation.

ToolWhat it does
describe_workbook (sheet?)Structural map: sheets, used ranges, header rows, sections, named ranges. Call first.
find_rows (query, sheet?, max_results?)Find rows by label across sheets, with an evaluated sample.
read_range (sheet, range)Evaluated values, formulas and display text for a range (≤ 500 cells).
set_cell (sheet, cell, value | formula)Write one cell.
set_range (sheet, top_left, values[][])Write a block; strings starting with = are formulas.
copy_range (sheet, source, dest_sheet, dest, mode)Excel-style copy with reference shifting; all / values / formats.
run_script (script)Sandboxed JS with sheet(name).get/set/values/setValues/format/clear; one batch.
set_format (sheet, range | operations[], format)Number formats, bold/italic, colors, alignment, wrap.
clear_range (sheet, range)Clear values and formulas, keep formatting.
insert_rows / delete_rows / insert_columns / delete_columnsStructural edits; references, CF and validation ranges follow.
create_sheet / rename_sheet / delete_sheetSheet ops.
set_column_width / set_row_height / freeze_panes / unfreeze_panes / hide_rows / show_rows / hide_columns / show_columnsLayout.
merge_cells / unmerge_cells / set_note / delete_note / define_nameMerges, notes, named ranges.
list_batchesPending batches and the review link.
reject_batch (batch_id)Drop a pending batch; the rest replay onto a fresh model.
save_workbook (as?)Patch pending changes into the file, or with as write a patched copy and leave the original untouched. The only tool that writes. In-place saves are gated by --review-required.

Writes never touch the file. They accumulate as pending batches and every write returns a readback of what landed (values, formulas, a row_map of labels) plus the review_url.

What the agent said

Claude, after editing a 1,400-row sell-side model (2,500+ named ranges, hidden sheets) through GridPath in Cursor, asked how it compared with its built-in approach:

It recalculates live. After I changed row 58, I could read back the new growth rates right away (28.0%, 28.0%, 27.9%) and confirm the change worked. My usual approach, a Python library called openpyxl, doesn't calculate formulas. I'd have to run the file through LibreOffice to get values, and that can differ from Excel on some functions.

It finds things fast. Its row search and workbook map located "Product revenue" and the Assumptions structure in a few calls. Otherwise I'd be scanning a 1,400-row sheet cell by cell.

It leaves the rest of the file alone. It rewrites only the cells that changed. When openpyxl opens and re-saves a heavy model like this one, it can quietly drop or damage charts, data validation, pivot caches or some formatting. That's the biggest risk with my built-in approach.

How it compares

gridpathFile-library MCPs (openpyxl etc.)Live-Excel MCPs (COM)Browser spreadsheet SDKs
Edits the file you haveyes, patched in placerewrites the whole fileyes, via Excelconverts to its own format
Formulas evaluatedyes, in processnoyes, needs Excel openyes, in a hosted app
Review before writeyesnonoin the vendor's UI
Needs installedNodePythonWindows + Excela web app + account
Macros, pivots, add-in data survivebyte-identicaloften droppedyesnot guaranteed

Benchmarks and methodology, including runs where a plain coding agent does as well as we do, are in the gridpath-evals repo.

Supported files

.xlsx and .xlsm. Verified on real analyst models: a 1.4 MB, 12-sheet, 55,000-formula workbook (76 package parts, 75 byte-identical after an edit) and a 13-sheet .xlsm bank model whose vbaProject.bin came back byte-identical.

How it works

The engine parses the .xlsx into an in-process model; edits mutate it and read back evaluated results, recalculating only what an edit affects. Saving diffs the session's changes against the file as loaded and patches the original bytes at the zip-part level: untouched parts are copied through, nothing regenerates the package. In the browser the engine runs in a Web Worker and the grid draws only the visible window, so large models stay light.

Headless (Node)

gridpath/headless is the engine without the grid: open a workbook, apply the same tool calls, read results, and save, from a server or a script.

ts
import { WorkbookSession } from "gridpath/headless";

Status

v0.5. Not yet supported:

  • In the grid: chart types other than line charts, creating or editing charts, data-validation dropdowns, real-time collaboration.
  • For agents: the fetch_web and edgar_lookup sourcing tools, and edits to charts or pivots.

GridPath is also a desktop app with the same engine and review and its own agent built in.

License

Apache-2.0 (LICENSE, NOTICE); third-party software in the package is listed in THIRD_PARTY_NOTICES.md. Releases before 0.2.0 were FSL-1.1-Apache-2.0. "GridPath" is a trademark of Heelix Technologies Inc.

Source: packages/gridpath/README.md at commit 18ebc71

Tools

33

set_cell

set cell

Set the value or formula of a single cell. `cell` is an Excel A1-style address (case-insensitive, e.g. "A1", "B17", "AA42"). For formulas, pass them in `formula` starting with '=' (e.g. "=SUM(A1:A10)") — do NOT put formulas in `value`.

Parameterscellrequiredpathrequiredsheetrequiredvalueformula

set_range

set range

Set a rectangular block of values starting at `top_left` (A1 address). `values` is a 2D array — outer rows, inner cells. Strings starting with '=' inside `values` are treated as formulas; formula references must use A1 notation pointing at the FINAL placement of cells (e.g. if `top_left` is "A9" and the value at row index 2 / col 0 should reference the data row just above your block at A8, write "=A8" — not "=A1" or "=A11"). **Off-by-one guard: if formulas will reference rows created by THIS call, split the work — write labels + hardcoded values first, read the `row_map` in this call's result, then write formulas in a second set_range against those verified row numbers.** Composing formulas in the same call that lays out their target rows is the top source of off-by-one references. Use this for bulk writes; prefer it over many set_cell calls. **`null` and empty string `""` inside `values` are treated as PRESERVE — the cell is left untouched, NOT cleared.** This means you can ship `[["Gross Profit", "", "", ""]]` to set only the label in column A without wiping the data in B–D. To actually clear cells, use `clear_range`. **Writing to a sheet name that doesn't exist CREATES that sheet automatically** (near-miss spellings of an existing sheet are rejected as probable typos) — so organize multi-section builds across purposefully named sheets (e.g. Data / Assumptions / Model / Notes) instead of piling everything onto one long sheet; a new tab costs nothing.

Parameterspathrequiredsheetrequiredvaluesrequiredtop_leftrequired

copy_range

copy range

Copy a rectangular range to another location with Excel copy/paste semantics, executed against the LIVE grid: relative formula references shift by the paste offset exactly like Excel ($ anchors are respected — `$B$5` stays put, `B5` shifts), and cell formatting (number format, font, fill, alignment) travels with the cells. STRONGLY PREFER this over reading a range and re-typing shifted formulas yourself — it cannot make off-by-one mistakes and costs a fraction of the tokens. Ideal for: extending a projection by one more period (copy the last full year column block to the next column), duplicating a section as a template, or seeding a new sheet from an existing block. Blank source cells leave the destination untouched (they do NOT clear it). Result reports the evaluated destination values so you can verify. Limited to 5000 cells per call.

Parametersdestrequiredmodepathrequiredsheetrequiredsourcerequireddest_sheet

run_script

run script

Execute a short JavaScript program against the workbook and land ALL its writes as one reviewable batch. STRONGLY PREFER this over long set_range payloads or many tool turns whenever the edit is loop-shaped: per-row conditional transforms ("for every row where margin < 0 …"), repeating formula patterns across many rows/columns, address arithmetic, or edits that depend on reading existing cells first. One run_script call replaces dozens of tool turns and cannot drift out of alignment mid-payload, because it computes addresses programmatically. API (plain synchronous JavaScript, strict mode — no async/await, no imports, no network, no DOM): sheets() -> array of sheet names sheet(name) -> handle for one sheet (name must match exactly; the name arg may be omitted ONLY if the workbook has a single sheet). Handle methods: .get("B7") -> {value, formula} — evaluated value as of BEFORE this script, overlaid with your own literal writes .values("A2:C50") -> 2D array of values (rows × cols) .set("B7", x) — write a value; strings starting with '=' are formulas; null clears the cell's content .setValues("A9", [[...],[...]]) — bulk write with set_range semantics: null / "" preserve the existing cell .format("A1:F1", {bold: true, number_format: "0.0%", background_color: "#1F4E79", font_color: "#FFFFFF"}) — same keys as set_format .clear("B2:D10") — empty values + formulas, formatting preserved .usedRange() -> "A1:Q48" or null log(...) — up to 100 entries of up to 4,000 characters each (64 KB in total), returned to you in the result as `script_logs`. A cut entry ends with `… [+N chars]`. Logs are for debugging and small extracts; for bulk reads use query_rows / read_range, not a script that logs rows. Rules: formulas you write are NOT evaluated during the script — .get on a cell you just wrote a formula into returns {value: null, formula}; the automatic readback after the script (cells + row_map) carries the evaluated results, so verify there. Fo

Parameterspathrequiredscriptrequired

set_format

set format

Apply formatting to one OR MANY cell ranges in a single call. STRONGLY PREFER the bulk `operations` form when you have more than one format to apply — it's one tool call instead of N, dramatically faster. Each operation's `format` is a partial object; only specified properties are applied, others are left as-is. Use this for: bold headers, currency / percent / number formatting on data columns, italic notes, alignment, font color/size/family, **text wrap via `wrap_text`** (long methodology notes / descriptions in a fixed-width column — wrap beats restructuring the layout around overflow), and **cell fill via `background_color`** (e.g. dark-blue header bars, yellow assumption highlights — see the financial-model color conventions). v1 limitation: cell borders are not yet supported. Two equivalent shapes: • Single range: { sheet, range, format } • Bulk: { sheet, operations: [ { range, format }, ... ] }

Parameterspathrequiredrangesheetrequiredformatoperations

set_column_width

set column width

Set the pixel width of one or more columns. **Always prefer the bulk `operations` form when you need multiple widths** (e.g. label column A wide, data columns narrower) — one call instead of N. The flat single-width form is kept for trivial cases. Default Excel column is ~64px; a wide column for labels is typically 180–220.

Parameterspathrequiredsheetrequiredwidthcolumnsoperations

set_row_height

set row height

Set the pixel height of one or more rows. **Always prefer the bulk `operations` form when you need multiple heights** (e.g. title row tall, body rows short) — one call instead of N. The flat single-height form is kept for trivial cases. Default row is ~24.

Parameterspathrequiredrowssheetrequiredheightoperations

merge_cells

merge cells

Merge a rectangular range into a single visible cell (e.g. for section headers). `range` is an A1 range like "A1:F1".

Parameterspathrequiredrangerequiredsheetrequired

unmerge_cells

unmerge cells

Unmerge a previously merged range.

Parameterspathrequiredrangerequiredsheetrequired

set_note

set note

Attach a note (Excel-style cell comment) to a single cell — a small indicator that shows the given text on hover. Use this for methodology callouts, source citations, or caveats that would clutter the cell's own value. Overwrites any existing note on that cell.

Parameterscellrequiredpathrequiredtextrequiredsheetrequired

delete_note

delete note

Remove a cell's note, if it has one. No-op if the cell has no note.

Parameterscellrequiredpathrequiredsheetrequired

create_sheet

create sheet

Create a new sheet (worksheet tab) in the workbook. Use a clear human-readable name like "Assumptions", "Q4 2024", "Inputs". The new sheet starts empty — write cells with set_cell / set_range afterwards. NOTE: writes to a not-yet-existing sheet name auto-create it, so an explicit create_sheet is only needed when you want to set a tab_color or create an empty tab ahead of time.

Parametersnamerequiredpathrequiredtab_color

delete_sheet

delete sheet

Remove a sheet from the workbook. Permanent — every cell on this sheet is lost. Cross-sheet references in other sheets will become #REF!.

Parametersnamerequiredpathrequired

rename_sheet

rename sheet

Rename a sheet. Cross-sheet formula references in other sheets will be updated automatically by the workbook engine.

Parameterspathrequirednew_namerequiredold_namerequired

clear_range

clear range

Empty the values AND formulas of cells in `range`. Use this when you want to delete content (not just overwrite with new values). `range` is an A1 range like "A1", "A1:F1", or "B2:D10". Cell formatting is preserved.

Parameterspathrequiredrangerequiredsheetrequired

insert_rows

insert rows

Insert `count` empty rows starting at (and shifting down from) row `before` (1-indexed Excel row number). Existing content at row `before` and below shifts down. Use this to make space in the middle of a model. Charts and conditional-formatting references in unrelated areas of the workbook may not auto-adjust to the new layout.

Parameterspathrequiredcountsheetrequiredbeforerequired

delete_rows

delete rows

Delete `count` rows starting at row `start` (1-indexed). Content below shifts up. Permanent — the removed rows are gone.

Parameterspathrequiredcountsheetrequiredstartrequired

insert_columns

insert columns

Insert `count` empty columns starting at (and shifting right from) column `before` (column letter like "C"). Existing content shifts right.

Parameterspathrequiredcountsheetrequiredbeforerequired

delete_columns

delete columns

Delete `count` columns starting at column `start` (column letter like "C"). Content to the right shifts left. Permanent.

Parameterspathrequiredcountsheetrequiredstartrequired

freeze_panes

freeze panes

Freeze the top `rows` rows and left `cols` columns of a sheet so they stay visible while scrolling. Classic financial-model setup is `freeze_rows: 1, freeze_cols: 1` (lock the header row and label column). Pass 0 to disable that axis.

Parameterspathrequiredsheetrequiredfreeze_colsrequiredfreeze_rowsrequired

unfreeze_panes

unfreeze panes

Remove any freeze panes from a sheet.

Parameterspathrequiredsheetrequired

hide_rows

hide rows

Hide one or more rows. `rows` is a comma-separated list of 1-indexed row numbers (e.g. "3" or "3,5,12"). Hidden rows still hold data but don't display in the workbook.

Parameterspathrequiredrowsrequiredsheetrequired

show_rows

show rows

Unhide one or more rows. Same `rows` format as hide_rows.

Parameterspathrequiredrowsrequiredsheetrequired

hide_columns

hide columns

Hide one or more columns. `columns` is a comma-separated list of column letters (e.g. "D" or "D,F,H").

Parameterspathrequiredsheetrequiredcolumnsrequired

show_columns

show columns

Unhide one or more columns. Same `columns` format as hide_columns.

Parameterspathrequiredsheetrequiredcolumnsrequired

read_range

read range

Read evaluated cell values from a specific A1 range WITHOUT modifying anything. Use this to verify your own work, confirm where the assumption block actually landed before composing dependent formulas, or pull current values you need to reference. Returns each non-empty cell's value AND formula (if any) in `A1 = value` form. Costs one tool turn but is the cheapest way to prevent the formula-pointing-at-wrong-row class of bug on long sheets. Range limited to 500 cells per call. To LOCATE a row/section/header by NAME, don't probe with this — use `find_rows` or `describe_workbook` first, then read the exact range.

Parameterspathrequiredrangerequiredsheetrequired

describe_workbook

describe workbook

Get a structural map of the ACTIVE workbook without reading cell data. Per sheet: the used range, the detected header row with its period/column headers mapped to column letters (e.g. {"BU": "FY2026E"}), and a table of contents of sections — contiguous row bands with their titles, like {"rows": "5-42", "title": "Revenue build"}. Also lists workbook defined names. Served from an in-memory index, so it's near-instant and returns in ONE call what would take many read_range probes. Call it once to orient on any sheet whose preview is truncated. Optional `sheet` filters to a single sheet.

Parameterspathrequiredsheet

find_rows

find rows

Search the ACTIVE workbook's row labels and column headers by name and get exact positions back — e.g. query "total revenue" returns each matching row's sheet, 1-indexed row number, containing section, and one evaluated sample cell ("BT209 = =BT194 → 39001.1") read live from the grid. Matching is case-insensitive substring plus word-prefix ("tot rev" matches "Total revenue"). Near-instant. Use this — NOT exploratory read_range probes — whenever you need to LOCATE a row or header ("where is Subscriber ARPU?", "which column is FY2027E?"); then read_range the exact range you need. Searches only the active workbook, not reference workbooks.

Parameterspathrequiredqueryrequiredsheetmax_results

query_rows

query rows

Filter, project and aggregate the rows of ONE sheet of the ACTIVE workbook by column values — served by the calculation engine in milliseconds on any sheet size. This is the tool for "which rows have Acme in the Owner column", "stores with revenue >= 100 opened after 2026-01-01", or "total revenue by region": one call, instead of scanning with read_range or writing a script. Columns are column letters ("DB") or header texts from the sheet's header row ("Revenue ($m)", case-insensitive, unique substrings accepted); the header row is auto-detected (override with `header_row`). `where` lists predicates that are ANDed: `op` is one of eq, ne, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte, between (value = [low, high]), in / not_in (value = list), empty, not_empty. Text compares case-insensitively and trimmed, numbers numerically, dates as ISO strings ("2026-01-01") against date-formatted cells. Returns the matching rows — 1-indexed `row` plus the requested `columns` keyed by column letter (date-formatted numbers come back as ISO dates) — up to `limit` (default 50, max 500), with `matched` = the total count and `truncated`. With `group_by`, returns one entry per distinct value with `count` and each requested `aggregate` (sum / count / min / max / avg) instead of rows. Read-only, evaluated values (set `include_formulas` for formulas too). Row numbers in the reply are exact — use them directly in formulas or read_range.

Parameterspathrequiredlimitsheetrequiredwherecolumnsend_rowgroup_byorder_byaggregatestart_rowheader_rowinclude_formulas

define_name

define name

Create (or replace) a workbook-scoped named range so formulas can reference a cell/range by a readable name instead of a raw address — e.g. name `GrowthRate` for the assumption cell, then write `=Revenue*(1+GrowthRate)`. The name persists in the saved .xlsx. `name` must be a valid Excel name (letters, digits, underscores; can't look like a cell reference such as "A1"). `ref` must be sheet-qualified, e.g. "Model!B22" or "Model!B5:B12" (absolute `$` signs optional — they're added automatically). Calling again with the same `name` replaces its target.

Parametersrefrequirednamerequiredpathrequiredsheet

list_batches

list batches

List this workbook's pending (unsaved) change batches with their mutation counts, and the review link.

Parameterspathrequired

reject_batch

reject batch

Drop one pending batch. The remaining batches are replayed onto a fresh model, so later edits that depended on it may change.

Parameterspathrequiredbatch_idrequired

save_workbook

save workbook

Write every pending batch into the .xlsx on disk through the surgical patcher: only the parts an edit touched are rewritten, everything else stays byte-identical. This is the ONLY tool that modifies the file. When the server runs with --review-required, this returns the review link instead and the user saves from there.

Parametersaspathrequired

Version history

3
  1. v0.5.0LatestOct 29, 2026
  2. v0.2.0Oct 19, 2026
  3. v0.1.9Oct 11, 2026

Score

90% of the applicable checks met

How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.

  • Reached in the last 30 days25 pts
  • Auth declared10 pts

    Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.

  • Tools listedRequired15 pts
  • DescriptionRequired15 pts
  • Logo10 pts
  • Documentation15 pts
  • License10 pts