Skip to content

Spreadsheet Macros

Macros let you write JavaScript that reads and writes the worksheets of the spreadsheet section they belong to. They run in a sandboxed environment with a hard 10-second timeout. The full mathjs library is available for numeric and statistical calculations.

For an overview of how to manage and run macros, see Spreadsheets.


In the spreadsheet’s Macros dialog, click + Macro, give it a label, and paste:

const source = context.getSheet("Sheet1");
const target = context.getSheet("Plate-1");
const total = math.sum(
source.range("A1:A10").read().flat().filter(v => typeof v === "number"),
);
target.cell("A1").set(total);
target.cell("A1").setStyle({ background: "#ffd" });

Click Run. The target sheet’s A1 cell is set to the sum of the first ten cells of Sheet1’s column A, with a yellow background. The result row below the editor shows how many cells were written.


Inside every macro, two globals are in scope:

  • context — the spreadsheet API. Lets you read and write the sheets, cells, and ranges of the macro’s own spreadsheet section.
  • math — the full mathjs library. All numeric, statistical, matrix, and unit operations work normally. A small set of code-execution functions are disabled for safety (evaluate, import, simplify, derivative, resolve, createUnit, reviver).

A macro takes effect entirely through the writes it queues (cell.set, range.set, setStyle, …). There is no output value to return — write your results into a sheet instead.


Returns the worksheet currently shown in the spreadsheet.

const sheet = context.activeSheet();
sheet.cell("A1").set("Hello");

Returns the worksheet with the given name, or — when given a number — the worksheet at that 1-based tab position. Throws if no sheet matches or the index is out of range — a typo surfaces immediately rather than silently doing nothing.

const plate = context.getSheet("Plate-1");
plate.cell("B2").set(42);
const first = context.getSheet(1); // leftmost tab, regardless of its name

Use the index form when a macro should work across templates whose sheet names vary.

Both forms resolve within the macro’s own spreadsheet section, where worksheet names are unique — there is nothing to disambiguate.

Returns every worksheet (tab) in the macro’s spreadsheet section. Useful for “do something to every sheet” macros.

for (const sheet of context.getSheets()) {
sheet.cell("Z1").set(new Date().toISOString());
}

MemberDescription
sheet.nameThe worksheet’s display name.
sheet.rowsThe current snapshot’s 2D row array. Includes any writes the macro has queued so far.
sheet.cell(addr)Get a Cell handle. addr is "A1" style or { x, y } 0-indexed coords.
sheet.range(addr)Get a Range handle. addr is "A1:B10" style.
const sheet = context.getSheet("Sheet1");
sheet.cell("A1").set(1);
sheet.cell({ x: 0, y: 1 }).set(2);
sheet.range("A1:B2").read(); // [[1, null], [2, null]]

MemberDescription
cell.addressThe A1 address, e.g. "B5".
cell.x, cell.y0-indexed column / row.
cell.read()Returns the cell’s current value. Sees writes the macro has already queued — cell.set(42); cell.read() returns 42.
cell.set(value)Queues a write. Strings starting with = are treated as formulas.
cell.setBlock(values)Anchors a 2D array at this cell and queues writes for every element. Dimensions are inferred — handy when you don’t know how many rows a CSV will have. Returns the resulting Range.
cell.setStyle(style)Queues a style write. style is a CSS-property map, e.g. { background: "#ffd", fontWeight: "bold" }.
cell.setMeta(meta)Queues a per-cell metadata write — an arbitrary { key: value } payload persisted with the workbook.
const c = context.activeSheet().cell("D7");
c.set("=SUM(A7:C7)");
c.setStyle({ background: "#dfd", color: "#060" });

MemberDescription
range.addressThe A1:B10 form.
range.start, range.end{ x, y } 0-indexed corners.
range.width, range.heightDimensions in cells.
range.read()2D array of values. Sees queued writes. Cells that don’t exist yet read as null.
range.set(values)Queues writes for a 2D array. Dimensions must match the range exactly.
range.setStyle(style)Applies the same style to every cell in the range.
range.cells()Iterator yielding a Cell handle for every coordinate in row-major order.
const sheet = context.activeSheet();
// Bulk write a 3×2 block
sheet.range("A1:B3").set([
["a", 1],
["b", 2],
["c", 3],
]);
// Apply a header style across a row
sheet.range("A1:Z1").setStyle({ fontWeight: "bold", background: "#eee" });

A macro sees exactly the worksheets (tabs) of the spreadsheet section it belongs to. It cannot read or write other sections or other steps — use multiple worksheet tabs within one section when a macro needs to combine data from several sheets.

  • Reads happen against a snapshot taken when the run starts. They’re always fast and synchronous.
  • Writes flow through the live spreadsheet immediately, and the section auto-saves as usual.
const plate = context.getSheet("Plate-1"); // one tab
const summary = context.getSheet("Summary"); // another tab, same section
const od = plate.range("B2:B11").read().flat().filter(v => typeof v === "number");
summary.cell("A1").set("Mean OD");
summary.cell("B1").set(math.mean(od));

Interactive UI — alerts, confirms, prompts, file uploads and attachments

Section titled “Interactive UI — alerts, confirms, prompts, file uploads and attachments”

Macros can pause mid-run to ask the user for input. The 10-second timeout is suspended while a dialog is open.

MethodDescription
await context.alert(message)Show an OK-only message dialog.
await context.confirm(message, opts?)Yes/No dialog. Returns true/false. Cancel counts as false. opts.yes / opts.no rename the buttons.
await context.prompt(message, defaultValue?)Single-line text input. Returns the entered string, or null if cancelled.
await context.openFile(opts?)File picker. Returns a parsed file object, or null if cancelled. See below.
await context.attachFile(file, filename?)Attach a file to the current step. Returns { id, name }. See below.

Every dialog title is prefixed with Macro "<your label>" so users can see that it is macro-driven.

await context.openFile({
accept?: string[]; // e.g. [".csv", ".xlsx"]
as?: "text" | "csv" | "json" | "xlsx" | "blob"; // default "text"
}): IUploadedFile | null

The returned object contains the parsed file data:

PropertySet when
file.name, file.type, file.sizeAlways
file.textas = "text"
file.jsonas = "json"
file.rowsas = "csv" — 2D array of rows
file.sheetsas = "xlsx" — array of { name, rows }, one per sheet
file.bufferas = "blob"

XLSX note: file.rows is always empty for XLSX files. Use file.sheets and pick the sheet by name:

const file = await context.openFile({ accept: [".xlsx"], as: "xlsx" });
if (file) {
const sheet = file.sheets.find(s => s.name === "Results") ?? file.sheets[0];
context.activeSheet().cell("A1").setBlock(sheet.rows);
}

Attach a file to the current step — it appears in the step’s Files section. Typically you attach a file the user just picked with openFile:

await context.attachFile(
file, // an object returned by context.openFile(...)
filename?, // optional — overrides the stored file name
): { id, name } // the created file's id and name

The original bytes are uploaded as-is, regardless of which as mode you used in openFile — so you don’t need as: "blob" just to attach. The optional filename overrides the name the file is stored under.

// Let the user pick a file and attach it to this step
const file = await context.openFile();
if (file) {
const attached = await context.attachFile(file, "raw-data.csv");
await context.alert(`Attached ${attached.name}.`);
}

You can also attach content the macro generates itself, as long as the object carries the bytes in text, json, or buffer:

await context.attachFile(
{ name: "summary.txt", type: "text/plain", text: "All checks passed.\n" },
"summary.txt",
);

Attaching is only available in manually run macros, and only on steps you can edit.

Attachments persist immediately. attachFile uploads the file the moment it is called — it is not rolled back if the macro fails or is terminated later. To avoid leaving a file behind on a failed run, call attachFile near the end of your macro, after your sheet writes. If a run does fail after attaching, delete the unwanted file from the step’s Files section. Re-running a macro attaches the file again, so it can create duplicates.

// Confirm before a destructive write
if (await context.confirm("Wipe column A?", { yes: "Wipe", no: "Keep" })) {
context.activeSheet().range("A1:A1000").set(Array(1000).fill([null]));
}
// Prompt for a numeric threshold
const raw = await context.prompt("OD threshold (e.g. 0.5):", "0.5");
if (raw !== null) {
const threshold = parseFloat(raw);
// ... use threshold to filter, format, etc.
}
// Import a CSV
const file = await context.openFile({ accept: [".csv"], as: "csv" });
if (file) {
context.activeSheet().cell("A1").setBlock(file.rows);
await context.alert(`Imported ${file.rows.length} rows from ${file.name}.`);
}

The full mathjs function set is available except for these blocked functions: evaluate, simplify, derivative, resolve, import, createUnit, reviver. Calling any of them throws an error.

math.sum([1, 2, 3]); // 6
math.std([2, 4, 4, 4, 5, 5, 7, 9]); // 2
math.mean([1, 2, 3, 4]); // 2.5
math.unit("5 km").to("m"); // 5000 m
math.format(math.pi, { precision: 4 }); // "3.142"

Scifeon adds one function on top of mathjs: math.fit4PL, documented below.

math.fit4PL — 4PL standard-curve fitting

Section titled “math.fit4PL — 4PL standard-curve fitting”

Fits a four-parameter logistic (4PL) curve — the standard model for ELISA and other dose-response standard curves — by unweighted least squares, using the Levenberg-Marquardt algorithm as implemented in ml-levenberg-marquardt. Not part of mathjs; a Scifeon addition.

math.fit4PL(
x, // number[] — doses/concentrations, all > 0
y, // number[] — responses, paired with x by index
options?: {
fixedLower?: number; // pin the lower asymptote to a known value (e.g. 0) and fit 3 parameters
maxIterations?: number; // solver iterations per attempt, default 200
},
): IFit4PLResult

The fitted curve, on the natural-log dose scale t = ln(x):

y = lower + (upper - lower) / (1 + exp(-(slope*t + intercept)))

This is the same curve as the GraphPad/Prism form — slope equals the Hill slope, and logEC50 = -intercept / (slope * ln(10)).

PropertyMeaning
lower, upperThe asymptotes (response at zero and infinite dose)
slopeLogit slope on ln(x); equals the Hill slope
interceptLogit intercept on ln(x)
ec50Dose at the curve midpoint, exp(-intercept/slope); NaN for a flat curve
logEC50log10(ec50)
hillSlopeAlias of slope (GraphPad convention)
residualSSSum of squared residuals of the fit
iterationsSolver iterations of the winning attempt
predict(x)The fitted response at dose x; NaN for x <= 0

Replicates are passed as repeated x values — different numbers of replicates per concentration are fine. The fit is deterministic: the same input always reproduces the same parameters.

Estimation is unweighted ordinary least squares: the parameters minimize the residual sum of squares SSE = Σ (yᵢ - y(tᵢ))² over all observations. Every replicate enters as its own observation and contributes equally — no averaging per concentration, no variance weighting.

Under the hood, fit4PL owns everything between “arrays of numbers in” and “fitted parameters out”:

  1. Initial estimates come from a logit linearization of the data: provisional asymptotes just outside the observed response range, then a linear regression of the logit-transformed responses on log-dose. Deterministic — no random starting points.
  2. The solver (ml-levenberg-marquardt, MIT-licensed, pinned to an exact version that only changes through Scifeon’s release process) is run once per damping magnitude (0.001, 0.1, 10). The pinned version keeps its damping fixed within a run, so multiple attempts guard against a single run stalling.
  3. The best candidate wins: every attempt — including the untouched initial estimate — is scored by its residual sum of squares, recomputed independently of the solver, and the lowest-SSE candidate with finite parameters is returned. If none qualifies, fit4PL throws "4PL fit did not converge" rather than returning garbage.

The whole pipeline is covered by automated unit and macro-pipeline tests that run on every Scifeon platform change.

Constrained vs. free fit. fixedLower pins the lower asymptote to a known value (for example 0 for blank-corrected responses) and estimates the remaining 3 parameters; without it, all 4 are estimated. For downstream statistics: with p estimated parameters (3 or 4) and n points used, residual degrees of freedom = n - p and residual variance = residualSS / (n - p). Fitted values at any dose come from predict(x) inside the macro, or from the closed-form curve formula in sheet formulas.

fit4PL throws an Error (with a descriptive message) instead of returning a bad fit when the data cannot support one: mismatched array lengths, non-finite values, doses ≤ 0, fewer than 4 points (3 with fixedLower), fewer than 2 distinct doses, or constant responses. Filter out excluded values (empty cells, "N/A", …) before calling:

// ELISA standard curve: 8 two-fold dilutions x 4 replicates in C4:F11,
// starting concentration in B3. Empty or non-numeric cells are excluded.
const sheet = context.getSheet("Standard curve");
const startConc = Number(sheet.cell("B3").read());
const plate = sheet.range("C4:F11").read();
const xs = [], ys = [];
for (let row = 0; row < plate.length; row++) {
for (const value of plate[row]) {
const num = Number(value);
if (value === null || String(value).trim() === "" || !Number.isFinite(num)) continue;
xs.push(startConc / 2 ** row);
ys.push(num);
}
}
const fit = math.fit4PL(xs, ys, { fixedLower: 0 });
sheet.cell("I15").set(fit.ec50);
sheet.cell("I16").set(fit.slope);

const sheet = context.activeSheet();
const values = sheet.range("A2:A100").read().flat().filter(v => typeof v === "number");
sheet.cell("A1").set(math.sum(values));
context.activeSheet().cell("D2").set("=SUM(A2:C2)");
const sheet = context.getSheet("QC");
const data = sheet.range("B2:B100").read();
for (let i = 0; i < data.length; i++) {
const v = data[i][0];
if (typeof v !== "number") continue;
const color = v < 0.5 ? "#fdd" : v > 1.5 ? "#dfd" : "#fff";
sheet.cell({ x: 1, y: i + 1 }).setStyle({ background: color });
}
const source = context.getSheet("Master");
const target = context.getSheet("Today");
target.range("A1:Z1").set(source.range("A1:Z1").read());
const rows = [["Sheet", "Rows"]];
for (const s of context.getSheets()) {
rows.push([s.name, s.rows.length]);
}
context.activeSheet().cell("A1").setBlock(rows);

When a macro doesn’t do what you expect, it helps to pause and look at a value mid-run.

Add a debugger; statement, open your browser’s developer tools (F12), then click Run. Execution pauses on that line so you can inspect variables and step through the code.

const values = context.activeSheet().range("A2:A100").read().flat();
debugger; // pauses here while dev tools are open
const numbers = values.filter(v => typeof v === "number");
context.activeSheet().cell("A1").set(math.sum(numbers));

With dev tools closed, debugger does nothing and the macro runs straight through. The 10-second timeout keeps counting while you are paused, so use it for a quick look — pausing too long terminates the run.

To check a value without opening dev tools, show it in a dialog. The timeout is suspended while the dialog is open:

const values = context.activeSheet().range("A2:A100").read().flat();
await context.alert(`Read ${values.length} cells: ${JSON.stringify(values.slice(0, 10))}`);

Macros run in an isolated Web Worker.

  • No DOM or network access. document, fetch, XMLHttpRequest, WebSocket, and WebAssembly are not available. Trying to use them throws an error.
  • No persistent state. Each run gets a fresh environment — nothing carries over between runs.
  • Hard 10-second timeout. Runaway macros (infinite loops, slow computation) are forcibly terminated. The app never freezes.
  • Op cap. A macro that generates more than 1,000,000 cell writes is aborted with a clear error.

Error messageCause
Worksheet "Foo" not found.getSheet("Foo") found no match. Check spelling; use getSheets() to list every available sheet.
Worksheet index 3 is out of range (workbook has 2 worksheet(s)).getSheet(3) points past the last tab. Indexes are 1-based.
Worksheet index must be a positive integer (1-based), got 0.getSheet(0) or a fractional/negative index. The first tab is getSheet(1).
No active worksheet available.activeSheet() was called before the spreadsheet finished loading. Rare.
Range A1:B2 expects 2 rows, got 1.The 2D array passed to range.set() doesn’t match the range dimensions.
Function evaluate is disabledOne of the blocked mathjs functions was called. Use direct functions instead (math.sum, math.mean, etc.).
Macro exceeded 10s and was terminated.Hit the hard timeout. Reduce work or split into multiple macros.
Macro produced too many operations (>1000000).Hit the op cap. You are probably looping over more cells than intended.
fetch is not a functionNetwork access is not available in macros.
A macro is already running.Another macro is still executing. Wait for it to finish before starting a new one.