A spreadsheet plugin for MulmoChat - a multi-modal voice chat application with OpenAI's GPT-4 Realtime API.
This plugin provides a full-featured spreadsheet component with:
- Excel-like cell editing with formula support
- Mathematical functions (SUM, AVERAGE, MIN, MAX, etc.)
- Text functions (CONCATENATE, LEFT, RIGHT, MID, etc.)
- Date functions (TODAY, NOW, DATE, YEAR, MONTH, DAY, etc.)
- Logical functions (IF, AND, OR, NOT, etc.)
- Lookup functions (VLOOKUP, HLOOKUP, INDEX, MATCH)
- Financial functions (PMT, PV, FV, NPV, IRR)
- Multi-sheet support
- Excel file export (.xlsx)
- Saving to the host's artifacts area, with cell edits written back
- UI in 8 languages, following the host's locale
- Cell formatting and styling
Note: This package uses SheetJS for Excel file export. SheetJS is not installed from npm, but directly from the official SheetJS CDN:
https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgzSee SheetJS Installation Guide for details.
yarn add @gui-chat-plugin/spreadsheet gui-chat-protocol
gui-chat-protocolis a peer dependency — install it alongside the plugin; the host application provides the runtime and this plugin only declares the compatible range.
// In src/tools/index.ts
import SpreadsheetPlugin from "@gui-chat-plugin/spreadsheet/vue";
const pluginList = [
// ... other plugins
SpreadsheetPlugin,
];
// In src/main.ts
import "@gui-chat-plugin/spreadsheet/style.css";import { pluginCore, TOOL_NAME } from "@gui-chat-plugin/spreadsheet";The plugin works in two modes, chosen by what the host passes to execute:
- With
files.artifacts(the gui-chat-protocol runtime capability): the workbook is written toartifacts/spreadsheets/YYYY/MM/<id>.jsonanddata.sheetsholds that path. The View reads and saves it throughuseRuntime().dispatch, so cell edits land in the same file and the model can read them back from it. - Without it:
data.sheetsholds the workbook itself, and edits are reported throughupdateResultonly.
The View sends two dispatch calls:
kind |
other args | result |
|---|---|---|
loadSpreadsheet |
path |
{ data: { sheets, version } } |
saveSpreadsheet |
path, sheets, baseVersion? |
{ data: { sheets: path, version } }, or { data: { sheets, version, conflict: true } } |
path must be a .json file under artifacts/spreadsheets/.
version is the SHA-256 of the file's text. The View saves with the baseVersion of the sheets it shows, and the save is written only while the file still has that version. When an agent or another View wrote the file in between, nothing is written: the answer carries the file's current sheets and version with conflict: true, and the View asks the user to keep that version or save their edit over it. A save without baseVersion is written unconditionally, as before. Saves of one file through the plugin are serialized, so two Views cannot both pass the check; a write that bypasses the plugin can still land between a save's check and its write, because files.artifacts has no compare-and-write.
A host that routes the View's dispatch to the plugin's own execute (as MulmoTerminal does) needs nothing more: pluginCore.execute tells the two calls apart by kind. A host with its own dispatch route can call executeSpreadsheetDispatch(files.artifacts, args) after checking isSpreadsheetDispatchArgs(args).
The View must be rendered inside the host's PLUGIN_RUNTIME_KEY provider to load or save a saved workbook; it also takes the UI language from that runtime (en, ja, de, es, fr, ko, pt-BR, zh, falling back to English).
3.0.0 changes what a host receives, so it is a major release:
SpreadsheetToolData.sheetsisSpreadsheetSheet[] | string. Withfiles.artifactsthe tool returns the saved file's path instead of the workbook, so code that readsdata.sheetsas an array has to handle the path (or load it with theloadSpreadsheetdispatch).pluginCore.executealso acceptsSpreadsheetDispatchArgs.- The
gui-chat-protocolpeer is^2.3.0, and the package no longer bundles it: the host's copy is used. - Cells may hold a boolean
v, and stored workbooks may contain bare values,nullgaps and{}cells (StorableCell).
| Export | Description |
|---|---|
@gui-chat-plugin/spreadsheet |
Core (framework-agnostic) |
@gui-chat-plugin/spreadsheet/vue |
Vue implementation with UI components |
@gui-chat-plugin/spreadsheet/style.css |
Tailwind CSS styles |
ABS, CEILING, EXP, FLOOR, INT, LN, LOG, LOG10, MOD, PI, POWER, ROUND, ROUNDDOWN, ROUNDUP, SIGN, SQRT, TRUNC
CONCAT, CONCATENATE, EXACT, FIND, LEFT, LEN, LOWER, MID, PROPER, REPLACE, RIGHT, SEARCH, SUBSTITUTE, TEXT, TRIM, UPPER, VALUE
DATE, DATEDIF, DAY, DAYS, EDATE, EOMONTH, HOUR, MINUTE, MONTH, NETWORKDAYS, NOW, SECOND, TIME, TODAY, WEEKDAY, WORKDAY, YEAR
A formula without a format code shows its number as a date only when the number is a date by origin: the formula starts with DATE, TODAY, EDATE, EOMONTH or WORKDAY, or its first reference is a date (a cell with a date format, typed as a date, or itself such a formula, on any sheet). A total that lands on 52,345 stays a number. Give a formula a format code to choose its display yourself.
A date format code is read as Excel reads it, for a cell's display and for TEXT alike: letters in any case (yyyy-mm-dd or YYYY-MM-DD), and m or mm as minutes when the part before it is an hour or the part after it a second (h:mm, h"時"mm"分"), the month otherwise.
Dates are serial numbers on the real calendar, as in LibreOffice and Google Sheets. Excel counts a 1900-02-29 that never existed, so a serial before 61 (1900-03-01) is one day earlier here than in Excel; from 61 on they are the same.
AND, FALSE, IF, IFERROR, IFNA, IFS, NOT, OR, TRUE
COLUMN, COLUMNS, HLOOKUP, INDEX, MATCH, ROW, ROWS, VLOOKUP, XLOOKUP
A range is written A1:C10, Data!A1:C10 or 'My Sheet'!A1:C10. A whole column (A:A, B:D) or a whole row (1:1) covers its sheet as far as the data reaches, which gives the same answers, since everything past the data is blank. ROW() and COLUMN() give the number of the cell the formula is in; given a range, they answer for its first cell.
AVERAGE, AVERAGEIF, AVERAGEIFS, CONFIDENCE.NORM, CONFIDENCE.T, CORREL, COUNT, COUNTA, COUNTIF, COUNTIFS, COVARIANCE.P, COVARIANCE.S, FORECAST, FORECAST.LINEAR, GEOMEAN, HARMEAN, INTERCEPT, KURT, LARGE, MAX, MAXIFS, MEDIAN, MIN, MINIFS, MODE, NORM.DIST, NORM.INV, NORM.S.DIST, NORM.S.INV, PEARSON, PERCENTILE, PERCENTILE.INC, PERCENTRANK, PERCENTRANK.INC, QUARTILE, QUARTILE.INC, RANK, RANK.EQ, RSQ, SKEW, SLOPE, SMALL, STANDARDIZE, STDEV, STDEV.P, STDEV.S, STDEVP, STEYX, SUM, SUMIF, SUMIFS, T.DIST, T.DIST.2T, T.DIST.RT, T.INV, T.INV.2T, T.TEST, TREND, TTEST, VAR, VAR.P, VAR.S, VARP
FV, IPMT, IRR, NPER, NPV, PMT, PPMT, PV, RATE
A workbook can define functions of its own, for an analysis no built-in function does. Each sheet may carry an optional functions list; a function defined on any sheet is callable from every sheet of that workbook:
{
"name": "Data",
"functions": [
{
"name": "MOVING.AVG",
"params": ["values", "n"],
"body": "const xs = values.flat().filter((v) => typeof v === 'number'); const tail = xs.slice(-n); return tail.reduce((a, b) => a + b, 0) / tail.length;"
}
],
"data": [[{ "v": 100 }, { "v": "=MOVING.AVG(A1:A30, 7)" }]]
}- A function body is JavaScript, run in QuickJS (WebAssembly) with no DOM, network, file system or timers, at most 50 ms per call, 2 s per calculation and 16 MB of memory. Each function has a global scope of its own.
- A range argument arrives as rows of cells (an array of arrays, a blank cell as
null); a single cell and any other argument as its value. A whole column or row (A:A,2:2) arrives as the range it covers in its sheet's data. An argument that is an error is passed on without calling the function. - Return a number, text or
true/false. A time-out or a non-finite number shows#NUM!; a thrown error or any other return value shows#VALUE!. - A name is letters and digits, parts joined by dots, and cannot be a built-in function's.
presentSpreadsheetsays in its reply which definitions could not be used. presentSpreadsheet's reply also lists formula cells that calculate to an error (a few, and how many in all), so an agent can fix a workbook before handing it over: the saved file holds formulas, not results.- Each workbook's functions get a QuickJS instance of their own, so a failure in one (an abort while freeing a runtime that ran out of time) cannot affect another.
- QuickJS loads only for a workbook that defines functions. The View and
readCalculatedRangeload it themselves; a host callingcalculatedRangedirectly passes the set fromloadCustomFunctions.
# Install dependencies
yarn install
# Start dev server (http://localhost:5173/)
yarn dev
# Build
yarn build
# Type check
yarn typecheck
# Lint
yarn lintTry these prompts to test the plugin:
- "Create a budget spreadsheet with income and expenses columns"
- "Make a multiplication table from 1 to 10"
- "Create a spreadsheet to track monthly sales with SUM totals"
MIT
- Protocol spec: gui-chat-protocol
- Reference implementations using this protocol: mulmoclaude · MulmoChat