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.
The package runs on an ES2023 runtime: Node 22 or later, Chrome 110, Safari 16, Firefox 115 or later. It uses ES2023's array methods (
toSorted,toReversed) as they are, with no polyfill.
// 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).
A host whose own guidance lists the functions a formula may call can check that list against the engine: registeredFunctionsByCategory() (from ./core) returns each category's function names, sorted, as below, and isRegisteredFunction(name) tells whether a name is one of them, in any case.
ABS, CEILING, EXP, FLOOR, INT, LN, LOG, LOG10, MDETERM, MINVERSE, MMULT, MOD, MUNIT, PI, POWER, RAND, RANDARRAY, RANDBETWEEN, ROUND, ROUNDDOWN, ROUNDUP, SEQUENCE, SIGN, SQRT, SUMPRODUCT, 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, BYCOL, BYROW, FALSE, IF, IFERROR, IFNA, IFS, LAMBDA, LET, MAP, NOT, OR, REDUCE, TRUE
CHOOSECOLS, CHOOSEROWS, COLUMN, COLUMNS, DROP, FILTER, GROUPBY, HLOOKUP, HSTACK, INDEX, MATCH, PIVOTBY, ROW, ROWS, SORT, SORTBY, TAKE, TRANSPOSE, UNIQUE, VLOOKUP, VSTACK, XLOOKUP, XMATCH
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.
ANOVA.TEST, AUTOCORREL, AVEDEV, AVERAGE, AVERAGEA, AVERAGEIF, AVERAGEIFS, BETA.DIST, BETA.INV, BETAINV, BINOM.DIST, BINOM.INV, BINOMDIST, CHIDIST, CHIINV, CHISQ.DIST, CHISQ.DIST.RT, CHISQ.INV, CHISQ.INV.RT, CHISQ.TEST, CHITEST, CONFIDENCE.NORM, CONFIDENCE.T, CORREL, COUNT, COUNTA, COUNTBLANK, COUNTIF, COUNTIFS, COVARIANCE.P, COVARIANCE.S, CRITBINOM, DECOMPOSE, DEVSQ, EMA, EXPON.DIST, EXPONDIST, F.DIST, F.DIST.RT, F.INV, F.INV.RT, F.TEST, FDIST, FINV, FISHER, FISHERINV, FORECAST, FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.LINEAR, FREQUENCY, FTEST, GAMMA.DIST, GAMMA.INV, GAMMADIST, GAMMAINV, GAMMALN, GAMMALN.PRECISE, GAUSS, GEOMEAN, GROWTH, HARMEAN, HYPGEOM.DIST, INTERCEPT, KENDALL, KS.TEST, KURT, LARGE, LINEST, LOGEST, LOGINV, LOGNORM.DIST, LOGNORM.INV, MANNWHITNEY.TEST, MAX, MAXA, MAXIFS, MEDIAN, MIN, MINA, MINIFS, MODE, MODE.SNGL, NEGBINOM.DIST, NORM.DIST, NORM.INV, NORM.S.DIST, NORM.S.INV, PEARSON, PERCENTILE, PERCENTILE.EXC, PERCENTILE.INC, PERCENTRANK, PERCENTRANK.EXC, PERCENTRANK.INC, PHI, POISSON, POISSON.DIST, PROB, QUARTILE, QUARTILE.EXC, QUARTILE.INC, RANK, RANK.AVG, RANK.EQ, RSQ, SHAPIRO.TEST, SKEW, SKEW.P, SLOPE, SMALL, SPEARMAN, 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, TRIMMEAN, TTEST, VAR, VAR.P, VAR.S, VARP, WEIBULL, WEIBULL.DIST, WILCOXON.TEST, Z.TEST, ZTEST
ANOVA.TEST(range1, range2, …), MANNWHITNEY.TEST(range1, range2), WILCOXON.TEST(range) or WILCOXON.TEST(range1, range2) (paired), KS.TEST(range1, range2) and SHAPIRO.TEST(range) are not Excel functions: each returns a two-sided p value, choosing an exact or a normal null distribution as scipy.stats does by default.
FV, IPMT, IRR, NPER, NPV, PMT, PPMT, PV, RATE
A formula that is a range and nothing else, such as =A1:C5 or =Data!A1:B10, answers the whole range. Its values spill from the formula's cell to the right and down, as dynamic arrays do in Excel 365. Each cell reads as =A1 would read it, so a blank cell spills as 0.
- Blocked spills. If a cell the result needs already holds something (a value, a formula, or part of another spilled result), the formula shows
#SPILL!and nothing spills. - Reading spilled cells. Other formulas read spilled cells like any others:
=B3,=SUM(B:B)and=COUNTA(B1:B9)all see them. - Formatting. A spilled cell takes its own cell's number format if it has one, and the formula cell's format otherwise.
- Saving. Only the formula is saved. The spilled values are rebuilt on every calculation.
A function can answer a table too: XLOOKUP (a whole row or column; see below), GROUPBY, PIVOTBY, DECOMPOSE, EMA, RANDARRAY, FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, TRANSPOSE, CHOOSECOLS, CHOOSEROWS, HSTACK, VSTACK, TAKE, DROP, MMULT, MINVERSE, MUNIT, FREQUENCY, LINEST, LOGEST, TREND and GROWTH do, and they take each other's tables (=SORT(UNIQUE(A2:A100))). An answer with no cells is #CALC!. A range argument accepts such a call, as in =SUM(…) over it.
LINEST and LOGEST fit one or more x variables by least squares (LOGEST fits ln y, so every y must be above 0). With known_y a column, each column of known_x is a variable; with known_y a row, each row is; known_x of known_y's own shape is one variable. They answer the coefficients from the last x to the first, then the constant; with stats TRUE, four more rows follow: the standard errors, r² and the standard error of y, F and the degrees of freedom, and the regression and residual sums of squares, with #N/A where a row has no more values. An x collinear with the ones before it (the constant first) is left out, with a coefficient and standard error of 0 and one more degree of freedom, as Excel documents. A statistic that divides by 0 (no degrees of freedom, a perfect fit's F) is #NUM!. TREND and GROWTH answer the fitted line or curve at each new x, at known_x when new_x is left out. A polynomial is a fit over powers of x: =LINEST(B2:B20, A2:A20^{1,2,3}) fits a cubic, and =TREND(B2:B20, A2:A20^{1,2,3}, 8^{1,2,3}) answers it at 8.
GROUPBY(row_fields, values, function, [field_headers], [total_depth], [sort_order], [filter_array]) answers a row per group of row_fields (text keys compared without regard to case), each value column aggregated by function: a function's name such as SUM, AVERAGE or COUNT, or a LAMBDA of one parameter, given the group's values as a column. PIVOTBY(row_fields, col_fields, values, function, [field_headers], [row_total_depth], [row_sort_order], [col_total_depth], [col_sort_order], [filter_array]) lays the col_fields keys across the top and the row_fields keys down the side; a cell no row has both keys for is blank.
- field_headers: 0 none, 1 there and hidden, 2 not there and made up (
Row Field 1,Value 1), 3 there and shown; left out, there when the first value is text and the second a number, and then shown. PIVOTBY reads headers the same way but shows none, in any mode. - Totals: a
Totalrow (and for PIVOTBY aTotalcolumn) by default; 0 for none, negative to put it first. GROUPBY's total_depth n also gives subtotals for the first 1 … n − 1 key columns (as many as there are), each after its rows; it defaults to the number of key columns. PIVOTBY's depths are 0, 1 or -1; subtotals there are#VALUE!. - sort_order: columns of the answer, 1-based, negative for descending, one or an array; left out, each key column ascending. PIVOTBY's row_sort_order and col_sort_order number the key columns and then the values, which sorts by each row's or column's total.
- filter_array: TRUE (or a number other than 0) for each data row to keep.
- Not supported: GROUPBY's field_relationship, PIVOTBY's relative_to, more than one PIVOTBY values column and PIVOTBY subtotals; each is
#VALUE!.
When XLOOKUP's return range has several rows and several columns, the answer is the whole matching row (the lookup ran down a column) or the whole matching column (it ran along a row), and it spills: =XLOOKUP("b", A2:A9, B2:D9) gives the three cells of B, C and D on the row where A holds "b". A return range of one row or one column gives one cell, as before.
An array constant is written in braces: {1,2,3} is a row, {1;2;3} a column and {1,2;3,4} two rows of two. Its elements are numbers, text in double quotes, TRUE, FALSE or an error such as #N/A; anything else (a reference, a call) is #ERROR!.
An expression with a range, an array constant or a table-answering call among its operands is calculated element by element and answers a table: =A2:A9*B2:B9, =A2:A9>5, =A2:A9&"!", =SORT(A2:A9)*2. Each element reads its cell as a reference to that one cell would, so a blank cell is 0 beside * and empty text beside &. Operands of different sizes line up as in Excel: a single row or column is repeated, and past the end of a shorter operand the element is #N/A (={1,2,3}+{1,2} is {2,4,#N/A}). An element that fails, such as a division by zero, is that error, and the other elements still answer. A range read inside a call belongs to the call: =SUM(A2:A9)*2 is one number. A range argument reads such an expression's cells: =SUM(A2:A9*B2:B9), =SUMPRODUCT((A2:A9>5)*B2:B9).
A function that takes one value does not yet calculate over an array (=ABS(A2:A9), =IF(A2:A9>5,1,0)); given a table of more than one cell, it answers #VALUE!.
LET gives names to values for one formula: =LET(total, SUM(A2:A9), total / COUNT(A2:A9)). A value may use the names before it, and a name bound to a range stays that range, so =LET(x, A2:A9, SUM(x * 2)) reads its cells as a reference does. A name is letters, digits, _ and ., starting with a letter or _; one that looks like a cell (A1), a logical value, or R or C is #NAME?.
LAMBDA(param1, …, calculation) is a calculation over named parameters. It is called where it is written (=LAMBDA(x, x * 2)(3) is 6), through a name (=LET(f, LAMBDA(x, x * 2), f(3))), or by:
MAP(array1, [array2, …], lambda): the LAMBDA at each element, the arrays lined up as inA1:A3+B1:B3, answering a table;BYROW(array, lambda)andBYCOL(array, lambda): the LAMBDA at each row (a column of answers) or each column (a row of answers); a row given from a range is that row's range, so=BYROW(A2:C9, LAMBDA(row, MAX(row)))works asMAX(A2:C2)does;REDUCE([initial], array, lambda(accumulator, value)): the LAMBDA over every element in reading order, carrying its answer forward. An initial value left out is 0.
A LAMBDA given the wrong number of arguments is #VALUE!, and one written but never called is #CALC!. Where one value is wanted (each element of MAP, each line of BYROW and BYCOL), an answer that is a table is #CALC!.
RAND(), RANDBETWEEN(bottom, top) and RANDARRAY([rows], [columns], [min], [max], [whole_number]) draw from a stream seeded by where the call is: the sheet, the cell, and how many random calls that cell's formula has made before it, in the order they are evaluated. A workbook therefore calculates to the same numbers each time, and the agent and the page showing the sheet see the same table. Excel draws new numbers at every recalculation instead. RANDBETWEEN includes both bounds, rounding bottom up and top down, and is #NUM! when no whole number lies between them. RANDARRAY truncates its sizes as SEQUENCE does (0 is #CALC!, below 0 #VALUE!), and is #VALUE! when min is not below max, or when whole numbers are asked for between bounds that are not whole.
AUTOCORREL(values, lag), EMA(values, alpha) and DECOMPOSE(values, period, [model]) are not Excel functions; they compute what statsmodels and pandas do. A series is one row or one column of numbers in order, read as TREND and LINEST read their cells (text that is a number counts). A blank, or text that is not a number, is #VALUE! rather than skipped, since skipping would shift every point after it.
AUTOCORRELis the autocorrelation at one lag, as statsmodels'acfcomputes it.EMAis the exponential moving average (pandasewm(alpha=…, adjust=False)), one value per point; alpha must be in (0, 1].DECOMPOSEis the classical decomposition (statsmodelsseasonal_decompose): for each point, its trend (a centred moving average over one period,#N/Awhere the window does not fit), seasonal and residual parts, additive by default or"multiplicative". The lag and the period must be whole numbers (a fraction is#NUM!); the period must be at least 2, with two full periods of values.
FORECAST.ETS(target_date, values, timeline, [seasonality], [data_completion], [aggregation]), FORECAST.ETS.CONFINT(target_date, values, timeline, [confidence_level], [seasonality], [data_completion], [aggregation]) and FORECAST.ETS.SEASONALITY(values, timeline, [data_completion], [aggregation]) take Excel's arguments, but Excel does not publish how it fits its model, so their values can differ from Excel's. They fit additive Holt-Winters (a level, a trend and an additive season, as statsmodels' ExponentialSmoothing(trend="add", seasonal="add") smooths them), choosing the smoothing parameters that minimise the squared one-step errors.
- The timeline. The timeline can be in any order. Values at one time are combined by the aggregation: 0 or 1
AVERAGE(the default), 2COUNT, 3COUNTA, 4MAX, 5MEDIAN, 6MIN, 7SUM. The step is the smallest gap between times, and every gap must be a whole number of steps. - Missing points. A missing point, a blank value included, is filled on the line between its neighbours (data_completion 1, the default; the nearest value at either end) or with 0 (data_completion 0). More than 30% of the points missing is
#NUM!. - Seasonality. 1 (the default) detects the season's length, 0 fits none, and a whole number up to 8760 gives it. The detected length is the highest peak of the autocorrelation (with the trend line removed) above 1.96/√n, between 2 and half the series.
FORECAST.ETS.SEASONALITYanswers it, or 0 when there is none. - The interval.
FORECAST.ETS.CONFINTis the half-width of the interval around the forecast, at 95% unless given. Its variance grows with each step ahead as for ETS(A,A,A) (Hyndman et al.). - Errors.
#NUM!: a target before the last time, a seasonality, data_completion, aggregation or confidence outside its values, fewer than two full seasons of values (three points without a season), or a step that is not constant.#N/A: values and timeline of different sizes.#VALUE!: text, or a blank time.
Monthly or weekly totals need no function of their own: group by each date's month end, =GROUPBY(MAP(A2:A100, LAMBDA(d, EOMONTH(d, 0))), B2:B100, SUM) (or by each week's first day). EOMONTH takes one date, so MAP gives it each one.
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, or a table: an array of rows ([[1, 2], [3, 4]]), which spills like any table. A flat array is one column, and short rows are filled with#N/A. A time-out or a non-finite number shows#NUM!; a thrown error, an empty array or any other return value shows#VALUE!. In a table,nullis a blank cell and any other value that is not a number, text or a logical is a#VALUE!cell. - 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
