Wonky Sheets can be extended with scripts written in Dart. Scripts are bound to one
spreadsheet. They provide custom formula functions, macros, custom menus, triggers (on open,
on edit, on selection change, on change, time-driven), HTTP fetches and simple UI (alerts,
prompts, toasts). The feature set mirrors Google Apps Script for Sheets. The API is idiomatic
Dart, and execution is sandboxed with dart_eval.
Read first:
- Suite 01-architecture.md decision A8 (compile on the
server, run bytecode on server and client), §2 packages (
wonky_sheets_script). - 02-data-model.md §3.6 (
ScriptProject,ScriptFile,ScriptBuild,ScriptTrigger,Macro,ScriptProperty), §3.7 (op log), §7 (writes); suite 17 part 5 §6.2 (scriptshook), §8 (server changes). - Suite 08-commands-and-undo.md (dynamic
sheets.macro.<id>commands, undo). - 05-formula-engine.md (custom-function hook, recalculation), 12-mcp-and-ai.md (headless workbook used by server-side scripts).
Priorities: P0 = first complete release, P1 = v1 if possible, P2 = later (designed now).
| Use case | Prio | Where it runs | Section |
|---|---|---|---|
Custom functions callable from formulas (=TO_F(A1:A10)) |
P0 | client (each viewer/editor evaluates), server for headless evaluation | §8.6 |
| Run a function from the script editor | P0 | client (UI available); P1 "Run on server" | §8.1 |
Simple triggers onOpen(e), onEdit(e), onSelectionChange(e), onChange(e) |
P0 | client of the user who caused the event | §8.7 |
Custom menus built in onOpen (ui.createMenu(...)) |
P0 | client | §16 |
| Alerts, prompts, toasts | P0 | client (server: alerts/prompts throw, toasts are logged) | §16 |
Logging Logger.log → execution log and editor console |
P0 | both | §13 |
| Properties (document + user) | P0 | both | §6.9 |
| Macros: record actions → Dart code; run from menu/shortcut | P1 | client (server when invoked via MCP) | §15 |
| Installable triggers: time-driven, installable onEdit/onChange/onOpen | P1 | server, as the installing user | §8.7 |
HTTP fetch UrlFetchApp.fetch via server proxy |
P1 | client → server proxy; server | §8.3 |
| MCP: list/run functions, read/update script files, read executions | P1 | server | §18 |
| Sidebars/dialogs with a declarative UI DSL rendered with shad | P2 | client | §16.3 |
LockService, CacheService, DriveApp-lite |
P2 | server (lock), both | §6.12 |
Non-goals: debugger with breakpoints (logs only), deployments/versions/add-ons, web apps
(doGet), arbitrary Flutter UI from scripts, standalone (unbound) script projects, email
sending.
| Fact (dart_eval 0.8.5, eval_annotation 0.8.1) | Consequence |
|---|---|
Compiler is pure Dart and uses package:analyzer (large). Compiler().compile({'pkg': {'main.dart': src}}) → Program; program.write() → EVC bytecode (Uint8List). |
Compile only on the server (A8). Clients never ship the compiler. |
Runtime(ByteData) / Runtime.ofProgram(program); runtime.executeLib('package:script/main.dart', 'fn', [args]). |
Clients download bytecode and only instantiate Runtime. |
Values cross the boundary boxed as $Value ($String, $int, $double, $bool, $List, $Map, $null, $Closure, …). |
Bridge converts between native Dart values and $Value; bulk conversions have fast paths (§8.2.4). |
Bindings: @Bind() on a class + dart_eval bind CLI → $Foo wrapper classes and an EvalPlugin (compiler.addPlugin, runtime.addPlugin). |
The Wonky Script API is a set of @Bind classes in wonky_sheets_script (§6). One plugin, used by both the server compiler and every runtime. |
Sandbox: file system, network and process access are denied by default; runtime.grant(NetworkPermission.url(…)) etc.; custom Permissions possible. |
We never grant built-in permissions. All side effects go through our bindings, which check our own WonkyScope permissions (§11). |
| No CPU or memory limits. | Every execution runs in a separate isolate (native) or Web Worker (web) that the host kills on timeout (§8.2). Memory is approximated by bridge counters plus process-level caps (§10). |
| 10–50× slower than AOT Dart. | Bulk APIs (values, setValues) are the recommended style; per-cell loops are documented as slow; the bridge batches writes. |
Unsupported: mixins, extension methods, late, typedefs, sync/async generators, await for, switch expressions, conditional/deferred imports, isolates. Partial: records, patterns, generics, enums, null safety, streams, futures, labels, spreads, abstract classes/implements, const expressions. |
We publish a "Wonky Dart" language guide (in-app help) and the compile service runs a subset lint that reports unsupported constructs with friendly messages before dart_eval sees them (§9.3). The API uses no generics-heavy or stream-based patterns. |
flutter_eval exists |
Not used. Scripts can't build Flutter widgets; UI is a declarative DSL (§16.3). |
Vendoring (rules in suite 01 §2.3): third_party/dart_eval
holds a pinned copy (0.8.5, a workspace member because the server uses it) with small,
upstreamable patches tracked in third_party/dart_eval/PATCHES.md:
- DE-1 source positions: scope labels carry
name@library:offsetof the enclosing declaration andRuntime.throwScopeskeeps the stack of the last throw; the host maps offsets to lines with the build's line tables (function-level frames, §13.2). - DE-2 cooperative interrupt hook:
Runtime.onCheckpointcalled every N instructions (N = 10,000), used for the time limit of the inline (web) runner (§8.2.3) and as a second guard in isolates;resetExecutionState()after aborted runs. - DE-3…DE-19 fixes found by the conformance suite (§21): static getters of source
classes,
toString/==on null, dynamic calls (thishandling ofInvokeDynamic), try/catch/finally frame corruption, boxing of class types, conditional expressions with null, 64-bit integer literals, implicit super constructors, nullable casts, catchable setter exceptions,DateTime.utc/fromMillisecondsSinceEpoch,UncatchableException(time limits cannot be caught by scripts).
dart_eval 0.8.5 needs analyzer ^8.2.0, so the workspace resolves analyzer 8.4.1; moving to
a newer analyzer means porting the dart_eval compiler (its AST API changed in 9–13).
Remaining language gaps (documented in the Wonky Dart guide): no enums with name/toString
(the API uses constant classes such as ButtonSet.yesNo), is on a class that only
implements the tested type is false for an instance-creation expression, named arguments to
dynamic receivers, no generators/extensions/mixins/late/typedefs (lint errors, §9.3).
┌──────────────── RevLog file (spreadsheet) ────────────────┐
│ ScriptProject ─ ScriptFile* ─ ScriptBuild ─ ScriptTrigger* │
│ └ Macro* ─ ScriptProperty* │
└───────────────┬───────────────────────────────▲────────────┘
source edits │ (ops) │ ScriptBuild row (system op) + bytecode StoredFile
▼ │
┌─────────────── wonky_server ────────────────────────────────────────────────────────┐
│ ScriptCompileService (isolate, analyzer + dart_eval Compiler + WonkyScriptPlugin) │
│ ScriptScheduler (time-driven) · `scripts` hook (compile, installable onEdit/onChange) │
│ ScriptJobQueue ──► ScriptWorker(s): isolate pool, ServerScriptHost ─► HeadlessWorkbook │
│ └► server change (submitServerChange) │
│ UrlFetchProxy (SSRF-safe) · ScriptEndpoint · MCP sheets.* script tools │
└──────────────────────────────────────────────────────────────────────────────────────┘
│ bytecode download (signed URL, cached locally)
▼
┌─────────────── wonky_sheets (app) ─────────────────────────────────────────────────────┐
│ ScriptService ─ ClientScriptHost ─► WorkbookModel + session write + UI (dialogs/toasts)│
│ │ SyncChannel (native: Mailbox · web: SharedArrayBuffer + Atomics) │
│ ▼ │
│ Sandbox: Isolate (desktop) / Web Worker (web) running dart_eval Runtime + guest proxies│
└──────────────────────────────────────────────────────────────────────────────────────┘
| Library | Imported by | Content |
|---|---|---|
wonky_sheets_script.dart |
app, server | src/api/api_source.dart: the API library package:wonky/wonky.dart as Dart source (§6) compiled with every script; src/guest/: GuestRuntime, GuestBridge (the two bridge functions wonkyHostCall/wonkyHostError, write batching, boxing with lazy rows), WonkyRuntimePlugin (bridge functions, CellError binding, allocation guards); src/protocol/: HostCall/HostReply, ScriptError, the binary ValueCodec; src/host/: ScriptHost, WorkbookAccess/ModelWorkbookAccess, ExecutionContext, ScriptUiDelegate, PropertyStore, UrlFetcher, TriggerStore, limits; src/runner/: ScriptRunner, InlineScriptRunner, runScript, events; ScriptCustomFunctions; MacroRecorder; ScriptSession; ScriptExports/ScriptDiagnostic. Web-safe. |
native.dart |
desktop app, server | IsolateScriptRunner: guest isolates with a Mailbox sync channel, watchdog (time, RSS). |
compiler.dart |
server only | ScriptCompiler (analyzer parse + subset lint, exports extraction, dispatcher generation, dart_eval Compiler with WonkyCompilerPlugin). Importing it (or package:analyzer, package:dart_eval/dart_eval.dart) from the app is forbidden (tool/check_dependencies.dart). |
testing.dart |
tests | compileScript, ScriptTestBench, fakes, the conformance corpus. |
Decision (implementation): the API is interpreted Dart source over one bridge function
instead of @Bind classes generated by dart_eval bind. Every API member forwards
(handle, method, args) to the host; only CellError is a bridged class. One source of truth
(the same file documents the API), exception classes scripts can catch by type, no generated
code to keep in sync. The interpretive overhead per call is small next to the host round
trip. The autocomplete index (api_index.json, §14.4) is generated from that source with the
analyzer (P1, not built yet).
The client depends on dart_eval (runtime), and therefore transitively on analyzer at pub
resolution time. Tree shaking removes the compiler: a probe with the client runtime
(tool/web_bundle_probe.dart) has no analyzer symbols. Measured (2026-10, Dart 3.13,
-O4 JS / -O2 wasm), over a baseline with the workbook model, planner and formula engine
the app already ships: +736 KB JS (+213 KB gzip), +714 KB wasm (+241 KB gzip); the
dart_eval Runtime alone is 567 KB JS / 152 KB gzip. Well under the 1.5 MB gzip budget.
Call overhead (tool/benchmark.dart, JIT, Apple M-series): the interpreter runs about
170 M simple instructions/s (1 M-iteration loop: 60–80 ms); one host call (range.value)
costs ≈ 14 µs inline and ≈ 30 µs through an isolate Mailbox; a buffered single-cell write
≈ 25–35 µs (planner included); reading 100 k cells with values ≈ 20 ms; writing 30 k cells
with values ≈ 1.1 s (planner dominated).
Both hosts implement the same interface, so API semantics are identical on client and server:
abstract interface class WorkbookAccess {
WorkbookModel get model; // reads (cells, formats, structure)
FormulaEngine get engine; // computed values (pull-based recalculation)
FormulaHooks get formulas; // A1 ⇄ stored formulas
EffectiveFormatResolver get formats;
String get userId; // auth user (PlanContext.userId)
IdGenerator get ids;
MutationPlan apply(SheetsOperation op, {bool undoable = true}); // plan + apply to model
List<AppliedPlan> get applied; // the caller persists these
}ScriptHost (shared) implements every API member on top of it, so semantics are identical on
client and server. ModelWorkbookAccess is the implementation over a WorkbookModel (it
creates or reuses a ModelEngineBinding). Writes are SheetsOperations planned by the
SheetsMutationPlanner and applied to the model immediately (read-your-writes); the caller
persists ExecutionResult.applied: the app through its session (one undo step), the
server as a recorded server change (SheetsBulkWriter.applyRecorded, one ServerChangeRecord) after the run, on a
private copy of the HeadlessWorkbook model (script tables loaded from the database).
- A spreadsheet has at most one
ScriptProject, created lazily on first open of the script editor (sheets.script.openEditor) withname = "<spreadsheet title> script"and one filemain.dartcontaining the template below. - Files: flat list (no folders in v1), ordered by
sortKey. Names match^[a-z][a-z0-9_]{0,63}\.dart$, unique per project (client-enforced; merge duplicates are renamed with the same deterministic rule as sheet names). - Each file is a library
package:script/<name>. Files may import each other (import 'utils.dart';) and must importpackage:wonky/wonky.dartto use the API. - Allowed imports:
dart:core(implicit),dart:math,dart:convert,dart:collection,dart:async(Futures only),package:wonky/wonky.dart, other project files. Anything else is a compile error ("Import not allowed in scripts: dart:io"). - Max 50 files, 500 KB per file, 2 MB per project.
Template main.dart:
import 'package:wonky/wonky.dart';
/// Runs when the spreadsheet is opened. Add custom menus here.
void onOpen(OpenEvent e) {
SpreadsheetApp.ui
.createMenu('My tools')
.addItem('Say hello', 'sayHello')
.addToUi();
}
void sayHello() {
SpreadsheetApp.active.toast('Hello from Wonky Script!');
}The compile service extracts exports from all files. Exported = every public top-level
function (name not starting with _).
| Kind | How it is declared | Signature rules |
|---|---|---|
| Runnable function | any exported function | no required parameters to be runnable from menus/editor/triggers |
| Custom function | doc comment tag @customfunction |
params: Object? (scalars or List<List<Object?>>), returns Object?; name usable in formulas as NAME (case-insensitive, the Dart name upper-cased) |
| Simple trigger | reserved names onOpen, onEdit, onSelectionChange, onChange |
one optional parameter of the matching event type |
| Macro | Macro row pointing to a function (recorder or Import macro) |
no parameters |
| Menu item | Menu.addItem(caption, 'functionName') at runtime |
no parameters |
| Installable trigger target | ScriptTrigger.functionName |
optional event parameter |
Custom function doc comment format (shown in formula autocomplete and the function help card, same UI as built-ins, see 05-formula-engine.md):
/// Converts Celsius to Fahrenheit.
///
/// @customfunction
/// @param celsius A temperature in °C, or a range of temperatures.
/// @return The temperature(s) in °F.
/// @example =TO_F(A2:A20)
Object? TO_F(Object? celsius) { … }Name conflicts: a custom function whose upper-cased name equals a built-in function is
shadowed by the built-in and gets a compile warning. Names must match ^[A-Z_][A-Z0-9_.]*$
after upper-casing.
Library package:wonky/wonky.dart. All classes are bound with @Bind() (wrapper interop:
scripts can use but not extend them). Design rules:
- Idiomatic Dart: getters/setters for properties, methods for actions, named parameters for
options,
List<List<Object?>>for 2D data,DateTimefor dates. - Synchronous wherever possible (the sandbox blocks on the bridge, §8.2). Only
UrlFetchApp.fetchAllis optionally parallel. - Row and column numbers are 1-based (spreadsheet convention, Apps Script parity).
- Cell values crossing the API:
null(empty),num(intwhen integral, elsedouble),String,bool,DateTime(cells with a date/time format; converted in the spreadsheet time zone),CellError(errors, e.g.#N/A). Writing aStringthat starts with=viavalueswrites a formula (Apps Script parity); usesetValuesRawto force text.
/// Static entry point for the bound spreadsheet.
abstract class SpreadsheetApp {
/// The spreadsheet this script is bound to.
static Spreadsheet get active;
/// UI of the current user's window. Throws [UiUnavailableException] in
/// server-side executions (time-driven triggers, MCP) and in custom functions.
static Ui get ui;
/// The active (selected) sheet / range of the user running the script, or the
/// first sheet / A1 in server-side executions.
static Sheet get activeSheet;
static Range get activeRange;
static List<Range> get activeRangeList; // multi-range selection
static Range? get currentCell;
/// Applies all pending writes and recalculates formulas now. Reads flush
/// implicitly, so this is only needed before long non-spreadsheet work.
static void flush();
}abstract class Spreadsheet {
String get id; // spreadsheet (file) UUID
String get name; set name(String value); // renames the Drive item (undoable)
String get url; // https://<host>/sheets/s/<id>
String get locale; set locale(String value);
String get timeZone; set timeZone(String value);
List<Sheet> get sheets; // in tab order, including hidden
Sheet? sheetByName(String name);
Sheet? sheetById(String id);
Sheet get activeSheet; set activeSheet(Sheet sheet); // client only (no-op on server)
Sheet insertSheet({String? name, int? index, Sheet? template}); // index: 1-based tab position
Sheet duplicateSheet(Sheet sheet, {String? newName});
void deleteSheet(Sheet sheet);
void moveSheet(Sheet sheet, int index); // 1-based tab position
/// "Sheet1!A1:B10", "A1" (active sheet), "MyNamedRange".
Range getRange(String a1Notation);
RangeList getRangeList(List<String> a1Notations);
List<NamedRange> get namedRanges;
Range? getRangeByName(String name);
void setNamedRange(String name, Range range);
void removeNamedRange(String name);
/// Bottom-right toast in every open window of the running user.
void toast(String message, {String? title, double timeoutSeconds = 5});
User get owner;
List<User> get editors; // requires scope `spreadsheet.sharing.read` (P2)
}abstract class Sheet {
String get id;
String get name; set name(String value);
int get index; // 1-based tab position
bool get isHidden; void hide(); void show();
String? get tabColor; set tabColor(String? hex);
Spreadsheet get parent;
// ---- Ranges ----
Range getRange(String a1Notation); // "A1:C10", "B:B", "3:3"
Range getRangeAt(int row, int column, [int numRows = 1, int numColumns = 1]);
Range get dataRange; // A1 to (lastRow, lastColumn)
int get lastRow; // last row with content (0 if empty)
int get lastColumn;
int get maxRows;
int get maxColumns;
Range? get activeRange; // client only
// ---- Structure ----
void insertRowsBefore(int row, int count);
void insertRowsAfter(int row, int count);
void insertColumnsBefore(int column, int count);
void insertColumnsAfter(int column, int count);
void deleteRows(int row, [int count = 1]);
void deleteColumns(int column, [int count = 1]);
void hideRows(int row, [int count = 1]);
void showRows(int row, [int count = 1]);
void hideColumns(int column, [int count = 1]);
void showColumns(int column, [int count = 1]);
void setRowHeight(int row, double px);
void setColumnWidth(int column, double px);
void autoResizeColumns(int startColumn, int count);
int get frozenRows; set frozenRows(int n);
int get frozenColumns; set frozenColumns(int n);
// ---- Content ----
/// Appends after [lastRow]; extends the grid if needed.
void appendRow(List<Object?> values);
void clear({bool contentsOnly = false, bool formatsOnly = false});
void sort(int column, {bool ascending = true}); // whole sheet below frozen rows
// ---- Objects & rules ----
List<EmbeddedChart> get charts;
ChartBuilder newChart(); // P1
void insertChart(EmbeddedChart chart); // P1
void removeChart(EmbeddedChart chart);
Filter? get filter; // basic filter (P1)
List<ConditionalFormatRule> get conditionalFormatRules; // P1 read; P2 write
Protection protect(); // sheet protection (P1)
}Built (2026-10-14): charts, newChart() → EmbeddedChartBuilder (setChartType,
addRange, removeRange, clearRanges, setTitle, setPosition(row, column),
setSize, setOption(jsonPath, value) = a ChartSpec patch, build()), insertChart
(returns the inserted chart; an empty type picks the recommended chart), updateChart
(for chart.modify()…build()), removeChart; filter → Filter (range,
get/set/removeColumnFilterCriteria, sort, remove) and Range.createFilter();
FilterCriteria.hideValues/condition/whenNumberGreaterThan/…; conditionalFormatRules,
addConditionalFormatRule (boolean conditions with fill/text color/bold/italic/strikethrough,
two-color gradients; ConditionalFormatRuleBuilder), removeConditionalFormatRule;
protect() / Range.protect() / protections → Protection (description,
warningOnly, editors, addEditor, removeEditor, unprotectedRanges, remove). Every
write is one planner operation of the execution's undo step.
abstract class Range {
Sheet get sheet;
String get a1Notation; // "B2:D10" (without sheet name)
int get row; int get column; // 1-based top-left
int get numRows; int get numColumns;
int get lastRow; int get lastColumn;
// ---- Values (top-left cell) ----
Object? get value; set value(Object? v);
String get displayValue;
String get formula; set formula(String a1Formula); // "" when no formula
String? get note; set note(String? text);
// ---- 2D data ----
List<List<Object?>> get values; set values(List<List<Object?>> v);
List<List<String>> get displayValues;
List<List<String>> get formulas; set formulas(List<List<String>> f);
List<List<String?>> get notes; set notes(List<List<String?>> n);
void setValues(List<List<Object?>> values); // same as the setter (chainable style)
void setValuesRaw(List<List<Object?>> values); // strings never parsed as formulas/numbers
// ---- Formats (getter = 2D, setter = apply to all cells; plural setters take 2D) ----
List<List<String?>> get numberFormats; set numberFormat(String pattern);
void setNumberFormats(List<List<String?>> patterns);
List<List<String?>> get backgrounds; set background(String? color); // "#RRGGBB" or null
void setBackgrounds(List<List<String?>> colors);
List<List<String?>> get fontColors; set fontColor(String? color);
List<List<String>> get fontWeights; set fontWeight(String weight); // 'bold' | 'normal'
List<List<String>> get fontStyles; set fontStyle(String style); // 'italic' | 'normal'
List<List<String>> get fontFamilies; set fontFamily(String family);
List<List<double>> get fontSizes; set fontSize(double size);
List<List<String>> get horizontalAlignments; set horizontalAlignment(String a); // left|center|right|general
List<List<String>> get verticalAlignments; set verticalAlignment(String a); // top|middle|bottom
set wrapStrategy(String s); // overflow|wrap|clip
set textDecoration(String d); // none|underline|lineThrough
void setBorder({bool? top, bool? left, bool? bottom, bool? right,
bool? vertical, bool? horizontal, String? color, String style = 'thin'});
// ---- Operations ----
void clear({bool contentsOnly = false, bool formatsOnly = false, bool notesOnly = false});
void clearContent();
void clearFormat();
void merge(); void mergeAcross(); void mergeVertically(); void breakApart();
bool get isMerged;
bool get isBlank;
void sort(List<SortSpec> specs); // SortSpec(column: 2, ascending: false)
void copyTo(Range destination, {bool contentsOnly = false, bool formatsOnly = false});
void moveTo(Range destination);
Range offset(int rowOffset, int columnOffset, [int? numRows, int? numColumns]);
Range cell(int row, int column); // 1-based, relative to this range
Range activate(); // client only: selects in UI
void insertCheckboxes(); void removeCheckboxes();
Range trimWhitespace();
Range removeDuplicates([List<int>? columnsToCompare]); // returns resulting range (P1)
DataValidation? get dataValidation; set dataValidation(DataValidation? rule); // P1
Filter createFilter(); // P1
}
class SortSpec { SortSpec({required int column, bool ascending = true}); }
abstract class RangeList { List<Range> get ranges; void clear(); set background(String? c); /* … */ }
abstract class NamedRange { String get name; Range get range; void remove(); }
class CellError { String get code; /* '#N/A', '#DIV/0!', … */ }abstract class Ui {
/// Modal alert. Blocks the script until dismissed (time waiting does not count
/// toward the execution limit; max 5 minutes, then returns Button.close).
Button alert(String message, {String? title, ButtonSet buttons = ButtonSet.ok});
PromptResponse prompt(String message, {String? title, ButtonSet buttons = ButtonSet.okCancel});
Menu createMenu(String caption);
void showSidebar(UiPanel panel); // P2 (§16.3)
void showModalDialog(UiPanel panel, String title); // P2
}
// Constant classes (dart_eval enums lack name/toString): ButtonSet.okCancel, b.name.
class ButtonSet { static const ok, okCancel, yesNo, yesNoCancel; String get name; }
class Button { static const ok, cancel, yes, no, close; String get name; }
abstract class PromptResponse { Button get selectedButton; String get responseText; }
abstract class Menu {
Menu addItem(String caption, String functionName);
Menu addSeparator();
Menu addSubMenu(Menu menu);
/// Adds the menu under Extensions (top-level menu in the menubar when
/// "Show script menus in menubar" is on, the default) for this session.
void addToUi();
}abstract class Session {
static User get activeUser; // the person running (or triggering) the script
static User get effectiveUser; // run-as user (installer for installable triggers)
static String get scriptTimeZone; // project time zone (defaults to spreadsheet's)
static String get activeUserLocale;
}
abstract class User { String get email; String get name; String get id; }email requires scope user.email (§11.1); without it email is "" (Apps Script parity).
abstract class UrlFetchApp {
static HttpResponse fetch(String url, [FetchOptions? options]);
static List<HttpResponse> fetchAll(List<FetchRequest> requests); // P1, ≤ 20, parallel on host
}
class FetchOptions {
FetchOptions({String method = 'get', Map<String, String>? headers, String? payload,
String? contentType, bool followRedirects = true, bool muteHttpExceptions = false,
int timeoutSeconds = 30});
}
abstract class HttpResponse {
int get statusCode;
Map<String, String> get headers;
String get contentText; // decoded per charset (UTF-8 default)
List<int> get bytes;
Object? json(); // jsonDecode(contentText)
}abstract class Utilities {
static String formatDate(DateTime date, String timeZone, String pattern); // spreadsheet patterns ("yyyy-mm-dd")
static DateTime parseDate(String text, String timeZone, String pattern);
static void sleep(int milliseconds); // ≤ 5,000 per call; counts toward the time limit
static String uuid(); // UUIDv4
static String base64Encode(List<int> bytes);
static List<int> base64Decode(String text);
static String computeDigest(String algorithm, String value); // md5|sha1|sha256|sha512 → hex
static String computeHmac(String algorithm, String value, String key); // sha256|sha512 → hex
static String jsonEncode(Object? value);
static Object? jsonDecode(String text);
static String formatString(String template, List<Object?> args); // printf-like %s %d %.2f
}
abstract class Logger {
static void log(Object? message); // also: print() is captured as log
static void info(Object? message);
static void warn(Object? message);
static void error(Object? message);
}abstract class PropertiesService {
/// Shared by everyone with access; stored in ScriptProperty (a table of the spreadsheet's file).
static Properties get documentProperties;
/// Per user per spreadsheet; stored in ScriptUserProperty (server table, private per user).
static Properties get userProperties;
}
abstract class Properties {
String? getProperty(String key);
void setProperty(String key, String value); // value ≤ 9 KB; store ≤ 500 KB
void deleteProperty(String key);
Map<String, String> getProperties();
void setProperties(Map<String, String> values, {bool deleteAllOthers = false});
List<String> get keys;
}Property writes are not on the spreadsheet undo stack (Apps Script parity) but are written as ops of the spreadsheet with the script's label (so collaborators receive them).
User properties — where they live (suite 17 part 2 §2.4).
ScriptUserProperty(userId, fileUuid, key, value, updatedAt) is a
server table, private per user and spreadsheet (unique (userId, fileUuid, key); ≤ 500
properties, values ≤ 9 KB), outside the spreadsheet's RevLog file. Clients read and write it
with sheets.getUserProperties(fileUuid) / sheets.setUserProperties(fileUuid, changes)
(Viewer+, null = delete; ScriptUserProperties in
wonky_server/lib/src/sheets/scripts/script_user_properties.dart), keep the values in memory
while the script service runs (scripts read synchronously) and write changes back in the
background. A copy of the values and the unsent changes are kept in the user's local store
(sheets.userProps.<fileUuid>, features/scripts/runtime/user_properties.dart), so offline
runs read the last values and their writes are sent on the next load or write (last write wins
per key). Server runs load the run-as user's rows before the run and write the changes
after it in one transaction (ServerUserProperties in
wonky_server/lib/src/sheets/scripts/script_service.dart).
abstract class OpenEvent { Spreadsheet get source; User get user; AuthMode get authMode; }
abstract class EditEvent {
Spreadsheet get source; Range get range; User get user;
Object? get value; // only when a single cell was edited
Object? get oldValue; // only when a single cell was edited
String? get triggerUid; // installable only
bool get offline; // installable: the edit's op was made offline (`op.offline`) and synced later
}
abstract class SelectionChangeEvent { Spreadsheet get source; Range get range; User get user; }
abstract class ChangeEvent {
Spreadsheet get source; User get user;
String get changeType; // EDIT | INSERT_ROW | INSERT_COLUMN | REMOVE_ROW | REMOVE_COLUMN |
// INSERT_GRID | REMOVE_GRID | FORMAT | OTHER
}
abstract class TimeEvent { String get triggerUid; DateTime get scheduledAt; DateTime get firedAt; }
class AuthMode { static const limited, full; } // limited: user hasn't authorized the project (§11.2)abstract class ScriptApp {
static TriggerBuilder newTrigger(String functionName);
static List<Trigger> get projectTriggers; // triggers of the current user
static void deleteTrigger(Trigger trigger);
}
abstract class TriggerBuilder {
TimeTriggerBuilder timeBased();
SpreadsheetTriggerBuilder forSpreadsheet(); // .onEdit() / .onChange() / .onOpen()
}
abstract class TimeTriggerBuilder {
TimeTriggerBuilder everyMinutes(int n); // 1, 5, 10, 15, 30
TimeTriggerBuilder everyHours(int n); // 1, 2, 4, 6, 8, 12
TimeTriggerBuilder everyDays(int n);
TimeTriggerBuilder everyWeeks(int n);
TimeTriggerBuilder atHour(int hour); // 0–23, ±15 min jitter
TimeTriggerBuilder onWeekDay(int weekday); // DateTime.monday…
TimeTriggerBuilder onMonthDay(int day);
TimeTriggerBuilder at(DateTime when); // one-shot
Trigger create();
}
abstract class Trigger { String get uid; String get functionName; String get eventType; }abstract class LockService { static Lock get documentLock; } // server-side; client returns a no-op lock
abstract class Lock { bool tryLock(int timeoutMs); void waitLock(int timeoutMs); void releaseLock(); }
abstract class CacheService { static Cache get documentCache; static Cache get userCache; }
abstract class Cache { String? get(String key); void put(String key, String value, {int ttlSeconds = 600}); void remove(String key); }
abstract class DriveApp { // scope drive.readonly / drive
static List<DriveFile> search(String query);
static Spreadsheet openSpreadsheetById(String id); // must have access; read-only unless editor
static Spreadsheet createSpreadsheet(String name);
}Built: LockService.documentLock/scriptLock/userLock (tryLock, waitLock, hasLock,
releaseLock; released when the execution ends; no-op locks in the app, process-wide locks
on the server) and CacheService.documentCache/scriptCache/userCache (get, getAll,
put, putAll, remove, removeAll; TTL ≤ 6 h, values ≤ 100 KB, in-memory per process;
usable from custom functions). Not built: DriveApp (needs cross-spreadsheet workbooks and
the drive scopes).
ScriptException (base, code, message) with subclasses RangeException (invalid A1,
out of bounds), PermissionDeniedException (role or protected range), ScopeNotGrantedException,
UiUnavailableException, QuotaExceededException, UrlFetchException (statusCode,
muteHttpExceptions), OfflineException, ExecutionTimeoutException (not catchable, kills
the run), CustomFunctionSideEffectException.
import 'package:wonky/wonky.dart';
/// Converts Celsius to Fahrenheit.
///
/// @customfunction
/// @param celsius A value or range in °C.
/// @return Value(s) in °F.
Object? TO_F(Object? celsius) {
if (celsius is List) {
return celsius.map((row) => (row as List).map(_convert).toList()).toList();
}
return _convert(celsius);
}
Object? _convert(Object? c) => c is num ? c * 9 / 5 + 32 : null;import 'package:wonky/wonky.dart';
/// Writes the edit time into column E whenever column D ("Status") changes on "Tasks".
void onEdit(EditEvent e) {
final range = e.range;
if (range.sheet.name != 'Tasks' || range.column > 4 || range.lastColumn < 4) return;
final stamps = <List<Object?>>[];
for (var i = 0; i < range.numRows; i++) {
stamps.add([DateTime.now()]);
}
range.sheet.getRangeAt(range.row, 5, range.numRows, 1).values = stamps;
}import 'package:wonky/wonky.dart';
void onOpen(OpenEvent e) {
SpreadsheetApp.ui
.createMenu('Report')
.addItem('Highlight negatives', 'highlightNegatives')
.addSeparator()
.addSubMenu(SpreadsheetApp.ui.createMenu('Export').addItem('Copy to Archive', 'archive'))
.addToUi();
}
/// Registered as macro "Highlight negatives" (Mod+Alt+Shift+1).
void highlightNegatives() {
final range = SpreadsheetApp.activeRange;
final values = range.values;
final colors = <List<String?>>[];
for (final row in values) {
colors.add(row.map((v) => v is num && v < 0 ? '#F4C7C3' : null).toList());
}
range.setBackgrounds(colors);
}
void archive() {
final ss = SpreadsheetApp.active;
final src = ss.sheetByName('Report')!;
final dst = ss.sheetByName('Archive') ?? ss.insertSheet(name: 'Archive');
final data = src.dataRange.values;
dst.getRangeAt(dst.lastRow + 1, 1, data.length, data.first.length).values = data;
ss.toast('Archived ${data.length} rows');
}import 'package:wonky/wonky.dart';
/// Install once from the editor: Run ▸ installDailyTrigger.
void installDailyTrigger() {
ScriptApp.newTrigger('dailySummary').timeBased().everyDays(1).atHour(7).create();
}
void dailySummary(TimeEvent e) {
final ss = SpreadsheetApp.active;
final orders = ss.sheetByName('Orders')!.dataRange.values;
var total = 0.0;
var count = 0;
final today = DateTime.now();
final yesterday = today.subtract(const Duration(days: 1));
for (final row in orders.skip(1)) {
final date = row[0];
if (date is DateTime && date.year == yesterday.year && date.month == yesterday.month &&
date.day == yesterday.day) {
total += (row[3] as num).toDouble();
count++;
}
}
final log = ss.sheetByName('Daily') ?? ss.insertSheet(name: 'Daily');
log.appendRow([today, count, total]);
Logger.log('Summary written: $count orders, $total total');
}import 'package:wonky/wonky.dart';
void importRates() {
final res = UrlFetchApp.fetch('https://api.example.com/rates?base=EUR',
FetchOptions(headers: {'Accept': 'application/json'}));
final data = res.json() as Map;
final rates = data['rates'] as Map;
final rows = <List<Object?>>[['Currency', 'Rate']];
for (final key in rates.keys) {
rows.add([key, rates[key]]);
}
final sheet = SpreadsheetApp.active.sheetByName('Rates') ??
SpreadsheetApp.active.insertSheet(name: 'Rates');
sheet.clear(contentsOnly: true);
sheet.getRangeAt(1, 1, rows.length, 2).values = rows;
sheet.getRange('A1:B1').fontWeight = 'bold';
sheet.frozenRows = 1;
}import 'package:wonky/wonky.dart';
/// Trims text, normalizes e-mail case, removes empty and duplicate rows in the selection.
void cleanUpSelection() {
final range = SpreadsheetApp.activeRange;
final seen = <String>{};
final out = <List<Object?>>[];
for (final row in range.values) {
final cleaned = row.map((v) {
if (v is String) {
final t = v.trim().replaceAll(RegExp(r'\s+'), ' ');
return t.contains('@') ? t.toLowerCase() : t;
}
return v;
}).toList();
if (cleaned.every((v) => v == null || v == '')) continue;
final key = Utilities.jsonEncode(cleaned);
if (seen.add(key)) out.add(cleaned);
}
final removed = range.numRows - out.length;
while (out.length < range.numRows) {
out.add(List<Object?>.filled(range.numColumns, null));
}
range.values = out;
SpreadsheetApp.active.toast('Removed $removed empty or duplicate rows', title: 'Cleanup');
}import 'package:wonky/wonky.dart';
void countRuns() {
final props = PropertiesService.documentProperties;
final n = int.parse(props.getProperty('runs') ?? '0') + 1;
props.setProperty('runs', '$n');
SpreadsheetApp.ui.alert('This macro has run $n times.');
}| Invocation | Runtime | Run-as | UI | Time limit | Undo | Op actorKind |
|---|---|---|---|---|---|---|
| Custom function | client sandbox (pure worker), server for headless reads | n/a (no side effects) | no | 3 s per call, 30 s per recalc batch | — | — |
| Run from editor | client sandbox (UI worker) | current user | yes | 30 s (P1 "Run on server": 6 min) | one step "Script: fn" | script |
| Menu item / macro | client sandbox | current user | yes | 30 s | one step | script |
Simple onOpen |
client, after first render | current user (limited if unauthorized) | yes | 30 s | — (menu building only; writes allowed and undoable) | script |
Simple onEdit / onChange |
client of the editor that made the change | current user | yes (toasts) | 30 s | merged into the triggering step (§8.7.2) | script |
Simple onSelectionChange |
client | current user | toasts only | 5 s, debounced 300 ms | own step if it writes | script |
| Installable time-driven | server worker | installer | no | 6 min | revertible via changes.revert |
script |
| Installable onEdit/onChange/onOpen | server worker | installer | no | 6 min | revertible | script |
MCP sheets.run_function |
server worker | token user | no | 6 min (or timeout_s) |
revertible (change_id) |
mcp (label "Script: fn") |
| AI assistant runs a function | server worker | user | no | 6 min | part of the AI undo step | ai |
Macro via MCP UI bridge (ui.invoke_command sheets.macro.<id>) |
client | user | yes | 30 s | one step | script |
Each execution has a host (main isolate / main thread; owns the workbook, writer, UI,
quotas) and a guest (isolate or Web Worker; owns the dart_eval Runtime). The guest
calls the host through a synchronous request/response channel, so the script API can be
synchronous.
abstract interface class SyncChannel {
/// Guest side: sends a request and blocks until the host replies.
Uint8List call(Uint8List request);
/// Host side: stream of requests with a reply callback.
Stream<(Uint8List request, void Function(Uint8List reply))> get requests;
}| Platform | Guest | Channel implementation |
|---|---|---|
| Desktop (macOS/Windows/Linux) | Isolate.spawn(guestMain, …) |
package:native_synchronization Mailbox (0.3.1, pinned). The guest sends each call through a SendPort (no polling) and blocks in mailbox.take(); the host answers asynchronously with mailbox.put(reply). On kill the host closes the mailbox first (a guest blocked in a native wait cannot be interrupted by Isolate.kill). Implemented as IsolateScriptRunner (native.dart). |
| Server | same as desktop, inside a script worker (§8.8) | Mailbox |
| Web (first release) | main thread | Inline runner (§8.2.3): InlineScriptRunner, host answers synchronously, DE-2 checkpoints enforce the time limit. |
| Web (later, cross-origin isolated) | Worker('script_worker.dart.js') |
SharedArrayBuffer ring buffer + Atomics.wait in the worker, Atomics.notify from the main thread. Not built (decision below). The guest code (GuestRuntime, GuestTransport) is ready to run in a worker. |
Decision (2026-10-14): no cross-origin isolation yet; the web keeps the inline runner and Run on server (with live logs) for alerts/prompts/fetch. Investigation:
- Google sign-in: no longer a blocker. The suite's popup flow (
google_popup_web.dart) already survivesCOOP: same-origin: the server callback/auth/callbackrelays the result throughlocalStoragewhen the popup has no opener, and the opener polls it. COEP: require-corp: Flutter loads images and fonts with CORS (fetch), which satisfies COEP, so the app itself works; but every cross-origin no-cors subresource must sendCross-Origin-Resource-Policy(user avatars from identity providers, any future embeds), and cross-origin iframes need COEP themselves. Safe only once all images go through the same-origin/files/proxy.COEP: credentialless: tolerates no-cors subresources (sent without cookies) and is the better fit, but Safari does not implement it — Safari would lose isolation (and the worker) while still paying the restrictions. Per-browser headers are possible (user agent sniffing in the web server) but fragile.- Isolation is per document: it can be enabled for
/sheets/only, leaving/write/alone. - What it needs besides headers: a separate worker entrypoint compiled with
dart compile js, the SAB channel, fallbacks whencrossOriginIsolatedis false (snapshot mode or the inline runner), and a cross-browser live check of sign-in, image upload and printing — none of which can be verified without the release web build. Snapshot mode alone does not bring alerts, prompts or synchronous fetch (§8.2.3a), so it was not built either. Next step when this is picked up: serve/sheets/withCOOP: same-origin+COEP: credentialless(Chromium/Firefox), keep Safari on the inline runner, and route avatar images through/files/.
Requirements on the web deployment: the Sheets web app is served with
Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp
(or credentialless), and every subresource is same-origin (fonts bundled, images via the
same-origin /files/ proxy). Flutter's multi-threaded wasm renderer benefits from the same
headers. The script worker is a separate Dart entrypoint
(wonky_sheets/web_worker/script_worker.dart) compiled with dart compile js -O4
into web/script_worker.dart.js by the web build script.
Binary codec (custom compact tag-length-value; strings UTF-8; numbers float64/int64; 2D arrays as a typed header + column-major values). Messages:
| Message | Direction | Content |
|---|---|---|
Execute |
host → guest (async, not on SyncChannel) | executionId, buildHash, bytecode (first time per guest), library, function, args, context {userId, role, scopes, sessionLocale, tz, activeSheetId, activeRange, kind} |
Call |
guest → host (sync) | callId, target (handle id), method, args |
WriteBatch |
guest → host (sync, ack) | ordered list of WriteOps (set values/formulas/formats/notes for a range, structure ops) |
Result |
host → guest | value or error {code, message} |
Log |
guest → host (async, fire-and-forget via the same channel with no-wait flag) | level, message, timestamp |
Done |
guest → host | return value (JSON-able), stats (instructions, bridge calls, cells read/written) |
Cancel |
host → guest | (web: worker terminate; native: isolate kill after unblocking the mailbox with a Cancelled reply) |
API objects (Sheet, Range, …) are handles: small guest objects holding host-side
identifiers (sheet id + A1 resolved at creation as ids, so a Range keeps pointing at the
same cells if rows are inserted during the script, like Apps Script).
Implemented: InlineScriptRunner runs the guest on the calling (main) thread. The host answers
calls synchronously and DE-2 checkpoints (every 10,000 instructions) abort the run at its time
limit (ExecutionTimeoutException, not catchable). Consequences: the UI does not repaint while
a script runs (bounded by the limit: 30 s runs, 3 s custom functions); Ui.alert/prompt
throw UiUnavailableException and UrlFetchApp throws ScriptException(unsupported) with a
hint to use Run on server; Utilities.sleep busy-waits. Custom functions and menu/macro
writes work as on desktop. The snapshot worker below stays a P1 option.
When crossOriginIsolated is false (embedded contexts, some browsers), synchronous calls are
impossible. The host sends the guest a read snapshot of the sheets the script may read
(all sheets ≤ 200k non-empty cells; otherwise the execution is offered as Run on server).
Reads are served from the snapshot plus the guest's own pending writes. Writes are batched and
sent asynchronously. values of formula cells written during the run are stale until the
end (documented). UI calls (alert/prompt) are not available in snapshot mode
(UiUnavailableException). If DE-2 has landed, the runtime can also run on the main thread
with checkpoint-based time slicing as a last resort for custom functions.
- Cell-sized writes (
value,formula,note, single-format setters,setBorder,clear,appendRow) are buffered guest-side and sent as onebatchcall when any other call happens (read-your-writes), onSpreadsheetApp.flush(), when the buffer exceeds 10,000 cells, or when the function returns. Their errors surface on the next call (or fail the execution). 2-D writes (values,formulas,notes,setBackgrounds…) are sent at once so dimension errors throw where they happen. The host applies each batch inside the execution's single undo transaction (chunked into ≤ 5,000 row changes per DB transaction, as in 02-data-model.md §7). - Before answering a value read after writes, the host runs
recalcIfDirty()(bounded by the formula engine's normal limits) so scripts see computed values. - 2-D arrays are boxed lazily per row (
_LazyBoxedList) so reading a 100k-cell range doesn't allocate all boxes up front; the bridged-value counter (§10) counts boxes.
ScriptHost.handle(Call) validates the handle, checks scopes (§11) and role
(PermissionDeniedException for viewers writing, or for protected ranges unless the user is
in the protection's editors), updates quota counters, executes against WorkbookAccess, and
returns. All host work for one call must finish in ≤ 2 s (except UI and fetch). Calls are
processed strictly in order.
All fetches go through the server: the client host forwards fetch to
ScriptEndpoint.urlFetch(spreadsheetUuid, executionId, request); server-side executions call
UrlFetchProxy directly. Offline → OfflineException.
UrlFetchProxy rules (also used by image-by-URL and IMAGE() proxy, see
08-charts-and-objects.md §10):
- Schemes
httpsonly (httpallowed only whenscripts.allowHttpconfig is true, default false). Ports 443/80/8443 only. - Resolve DNS once, reject if any resolved address is private, loopback, link-local,
multicast, CGNAT (100.64/10), unique-local IPv6, IPv4-mapped private, or the metadata
endpoints (169.254.169.254,
metadata.google.internal,fd00:ec2::254), or the server's own hostnames. Connect to the resolved IP with SNI/Host set to the name (prevents DNS rebinding). - Redirects: max 5, each hop re-validated.
- Request limits: headers ≤ 16 KB, payload ≤ 10 MB. Response limit 10 MB (download is aborted beyond), timeout ≤ 60 s (default 30 s).
- Allowlist:
ScriptProject.settingsJson.urlAllowlist(host patterns,*.example.com). If non-empty, only those hosts are allowed. Admins can set a server-wide denylist/allowlist (config: scripts.urlDenylist). - Headers added:
User-Agent: WonkySheets-Script/1.0 (+https://<host>/scripts-ua). Cookies are never forwarded. The user's auth token is never forwarded. - Quotas (§10) counted per run-as user.
- Custom functions may fetch only if the host is in the project allowlist (a non-empty allowlist is required). Responses for custom functions are cached server-side for 1 h keyed by (method, url, headers hash).
- Lives in
wonky_sheets/lib/features/scripts/runtime/. - Opens an undo transaction "Script: " (source
script) when the first write arrives; commits whenDonearrives (or on error/timeout: commits what was applied, then shows the error toast "Script error in fn: message — View details · Undo"). - Writes go through the session like any command (
session.execute(op, actorKind: 'script')), producing ops withactorKind: scriptand the same label. - Structure ops (insert rows, add sheet…) call the same internal operations as the
corresponding commands (
sheets.rows.insert…) so maintenance invariants and id generation are identical. - UI calls show
ShadDialog.alert/prompt dialogs (viaWonkyDialogSpec, so they're MCP-describable),ShadSonnertoasts, and menus viaScriptMenuRegistry(§16). - Simple triggers and menu actions never run while another execution of the same user and
spreadsheet is running (queue, max 10 pending; drops the oldest
onSelectionChangefirst).
- Uses
HeadlessWorkbook(shared with MCP tools; the RevLog headless model of the file, which follows its ops; see 12-mcp-and-ai.md §2). The function runs against a private copy of it. - Writes after the run as RevLog server changes of the run-as user (
SheetsBulkWriter→RevLogService.submitServerChange, chunked;actorKind: script, ormcp/aiwhen invoked by those, label"Script: <fn>"), so the sequencer checks the run-as user's role and the protected ranges like any edit (RevLog spec 05 §3.5; onlysystemops, e.g. script builds, are exempt). The ops share one record id and oneServerChangeRecordper execution, sochanges.revert(change_id)works for 24 h (12-mcp-and-ai.md §2.5). A rejected commit fails the execution ("The changes could not be saved"). UithrowsUiUnavailableException;toastis logged and, if the run-as user has an open window, relayed as a toast through the presence channel (P1).activeSheet= first visible sheet;activeRange= A1 of it.- Job queue (built): installable triggers are enqueued as
ScriptJobrows (ScriptJobQueue,wonky_server/lib/src/sheets/scripts/script_jobs.dart) and run by instances with the worker role (wonky.sheets.scriptWorker, default true; instances without it only enqueue). Claims are leases (FOR UPDATE SKIP LOCKED, lease = server timeout + 2 min), serialized across instances by a transaction-scoped advisory lock so the per-user and per-trigger limits hold; an expired lease is queued again (3 attempts). At mostwonky.sheets.scriptUserConcurrency(10) running jobs per run-as user and one per trigger; at most 100 queued per trigger, the overflow is merged into the newest job (e.coalesced, bounding range). Finished jobs are pruned after a day (scriptsPrune). Run on server, MCPrun_functionand AI runs stay synchronous request/response (ScriptEndpoint.run;runLivestreams log lines first). LockServiceuses process-wide locks (MemoryScriptLocks.shared); with several worker instances a document lock is per instance (a Redis lock is the follow-up).
Evaluation through the formula engine's custom function hook (05-formula-engine.md):
abstract interface class CustomFunctionProvider {
/// Known names (from the last good build's exports).
bool has(String upperName);
FunctionHelp help(String upperName);
/// Returns a cached value synchronously, or null and schedules evaluation.
CustomFunctionResult? lookup(String upperName, List<FormulaValue> args, CellId caller);
}- During recalculation, the engine calls
lookup. On a cache hit (key =buildHash, name, argsHash) it returns the value. On a miss it returnsnull, and the engine stores the transient valueLoading…for the cell (a special error-like value#LOADING, displayed as "Loading…" in muted italic; dependents see#LOADINGand also wait). - Misses collected during one recalc pass are sent as one batch to the pure worker
(
evaluateBatch([{callId, name, args}]), ≤ 500 calls per message). The worker runs them sequentially, each with a 3 s budget. - Results arrive → cache (in-memory LRU 50k entries +
ComputedValueCachepersistence for reopen) → the engine marks the calling cells dirty → normal recalculation. - Args conversion: single cell → scalar; range →
List<List<Object?>>(row-major, empty cellsnull); errors in args are passed asCellError. Return conversion: scalar → value;List<List>→ array result that spills like array formulas;List(1D) → a single row; unsupported return types →#VALUE!with message. - Side effects are forbidden: the pure worker's host rejects every mutating call, UI call
and property write with
CustomFunctionSideEffectException(cell shows#ERROR!with tooltip). Reading the spreadsheet through the API is also forbidden (all inputs must be arguments; this keeps the dependency graph correct).Utilities,Session.scriptTimeZoneand allowlistedUrlFetchAppare allowed. - Determinism: results are cached by args. Volatile behavior (
DateTime.now(),Random) is allowed but only re-evaluated when args change, on Recalculate all (sheets.calc.recalculateAll), or after a new build. - Errors: thrown exceptions →
#ERROR!with the message in the cell's error tooltip; timeouts →#ERROR!"Exceeded maximum execution time (3 s)"; no build/compile errors →#NAME?"Unknown function" only if the name never existed in a good build. - Offline: works with the last downloaded good build. If no build was ever downloaded,
#LOADINGstays until online. - Headless (MCP reads, server-side scripts, exports): the server's formula engine uses the
same provider backed by a server pure worker; MCP reads wait up to 10 s for loading cells
and then return
#LOADINGwithpending: true. - Viewers' and commenters' clients evaluate custom functions too (they're part of rendering).
| Trigger | Fires when | Event | Notes |
|---|---|---|---|
onOpen |
spreadsheet finished first render for this window, and again after a new build arrives (menus rebuilt) | OpenEvent |
Runs for all roles; viewers get read-only access (writes throw). |
onEdit |
a local undo transaction with kinds ∋ values committed by a user-initiated command (menu, toolbar, shortcut, context menu, palette, cell editor, paste, fill, AI with live UI) |
EditEvent (range = bounding box of changed cells on the first changed sheet) |
Not fired for: script writes, remote changes, undo/redo, MCP UI-bridge commands (configurable P2). |
onChange |
local transaction with structure/format/sheet changes | ChangeEvent |
P1 (installable onChange is the Apps Script equivalent; we also offer it as simple). |
onSelectionChange |
active cell/range changed, debounced 300 ms | SelectionChangeEvent |
Only runs if the function exists; max 1 concurrent; skipped while the user drags. |
Simple triggers run in AuthMode.limited if the user has not authorized the project's
scopes: calls needing scopes (UrlFetchApp, Session.activeUser.email, ScriptApp) throw
ScopeNotGrantedException. They never show the authorization dialog.
If onEdit completes within 2 s and the user hasn't started another undoable action, its
writes are appended to the triggering undo step (so one Mod+Z undoes the edit and the
timestamp). Otherwise they form their own step "Script: onEdit".
- Created in the Triggers dialog (§14.6) or via
ScriptApp.newTrigger. Stored asScriptTriggerrows (runAsUserId= creator). Server state (next run, failures) lives in the server-onlyScriptTriggerStatetable. - Time-driven triggers are read from the
script_triggertable (plain reads by file); changes to them reach the server as ops like any other edit. - Time-driven:
ScriptSchedulerFutureCallsheets.scripts.tickevery minute selects due triggers (nextRunAt ≤ now,FOR UPDATE SKIP LOCKED), enqueuesScriptJobs, computes the next run in the project time zone (with ±15 min deterministic jitter per trigger id foratHour). - Installable onEdit/onChange: the
scriptspost-commit hook (suite 17 part 5 §6.2) enqueues one job per op whoseactorKind ∈ {user, ai}(andmcpif the trigger optionfireForMcpis set; neverscript). Per trigger, at most 100 jobs queued; overflow is coalesced into one job withe.coalesced = trueand the bounding range. Offline edits fire when they sync, withe.offline = true(the op'sofflineflag). - Installable onOpen (P1): the client calls
ScriptEndpoint.notifyOpen(spreadsheetUuid)after opening online; the server enqueues the jobs. - Run-as checks before each run: installer still has edit access (via
AccessService), is not deleted, has scripts enabled, and is within quota. Otherwise the trigger is disabled (ScriptTriggerState.disabledReason), and the installer gets ascriptFailurenotification ("Trigger dailySummary was disabled because you no longer have edit access to Budget 2026"). - Failure policy: 5 consecutive failures → disabled + notification; failure notifications are digested daily (P1 setting per trigger: immediately / daily / never).
- One execution per trigger at a time; a time-driven run that is still running when the next
is due is skipped (logged as
skipped).
Client:
- Two guests per open spreadsheet, created lazily: pure guest (custom functions; warm;
holds the current build's
Runtime) and UI guest (everything else). A third guest is spawned if a run is queued while the UI guest is busy beyond 5 s (max 3). Runtimeinstantiation per build: the guest caches runtimes bybuildHash(max 2). Bytecode is downloaded from the signed URL and stored inLocalBlobCachekeyed bybytecodeFileUuid.- On timeout: the host replies
Cancelledto any pending call, kills the guest (Isolate.kill(priority: immediate)/worker.terminate()), recordstimedOut, and spawns a fresh guest on demand.
Server:
- Script execution happens in script workers: the same server binary started with
sheets.scripts.worker: true(production: dedicated instances/containers with memory limits; development: in-process). Jobs come from the PostgresScriptJobqueue (lease 7 min,SKIP LOCKED). MCPrun_functionwaits for job completion viasession.messageschannelscriptjob:<id>. - Each worker keeps an isolate pool (default 8 guests, config
scripts.poolSize). Guests are recycled after 200 executions or any timeout. A watchdog checksProcessInfo.currentRssevery second; abovescripts.maxWorkerRssMb(default 1,536) it cancels the execution with the highest bridged-value count (QuotaExceededException: memory).
- Trigger: the editor saves files (ops) and, when online, waits until they are
acknowledged (
session.whenSynced, 10 s) and callsScriptEndpoint.compile(spreadsheetUuid). The server also compiles when thescriptshook seeskinds ∋ scriptsfor file changes without a matching build (Editor+ authors; covers offline saves that synced later and MCP updates). The build row is written as asystemserver change (script_buildis not client-writable). - Load all
ScriptFilerows of the project (admin read), computesourceHash = sha256(apiVersion ‖ compilerVersion ‖ sorted(name ‖ \0 ‖ source)). IfScriptBuild.sourceHash == sourceHash→ done. - Compile in a dedicated compiler isolate (analyzer is memory-hungry; pool of 2 per
server, 60 s timeout, queue per project with "latest wins" coalescing):
- Parse + resolve with
analyzeragainst an in-memory resource provider containing the project files and a stub ofpackage:wonky/wonky.dart(generated fromapi.dart). Collect errors and warnings. - Subset lint (§9.3).
- Generate
package:script/__exports.dartthat imports every file and references every exported function (prevents dart_eval tree shaking), thenCompiler()..addPlugin(WonkyScriptPlugin())→compile({'script': files, ...})→program.write(). - Extract exports metadata from the AST (§5.1) and required scopes (§11.1).
- Parse + resolve with
- Upload bytecode as a
StoredFile(owner = the spreadsheet's DriveItem; content-addressed namescripts/<projectId>/<sourceHash>.evc). - Write the
ScriptBuildrow (deterministic idSheetsIds.scriptBuildId(projectId)) in asystemserver change (submitServerChange, attributed to the requesting user): on success setbytecodeFileUuid,lastGood*fields; on errors keeplastGood*untouched and setbytecodeFileUuid = null(Additions A-09-1). - Return diagnostics to the caller of
compiledirectly (fast path) in addition to the build row.
[ { "severity": "error", // error | warning | info
"code": "unsupported_extension", // analyzer code or wonky_* lint code
"message": "Extension methods are not supported in Wonky scripts.",
"file": "main.dart", "line": 14, "column": 1, "endLine": 18, "endColumn": 2,
"fix": "Use a top-level function instead." } ]| Code | Construct |
|---|---|
wonky_mixin |
mixin, with |
wonky_extension |
extension declarations |
wonky_late |
late |
wonky_typedef |
typedef |
wonky_generator |
sync*, async*, yield |
wonky_await_for |
await for |
wonky_switch_expression |
switch expressions |
wonky_import |
disallowed imports, conditional/deferred imports |
wonky_isolate |
dart:isolate |
wonky_records_patterns (warning) |
records/patterns beyond the supported subset |
wonky_top_level_state (warning) |
mutable top-level variables (state doesn't persist between executions) |
wonky_custom_function_signature |
custom function with non-Object? params or async |
wonky_builtin_shadowed (warning) |
custom function name equals a built-in |
- Editing and saving work offline (file saves are ops in the outbox). The editor status shows "Will compile when online" and the problems panel keeps the last known diagnostics marked "outdated".
- Custom functions, menus and triggers always use the last good build (
lastGood*), even if the latest source has errors. The editor shows a banner "Running the last working version (2 min ago) — fix errors to update". apiVersionbump: clients that receive a build with a newerapiVersionthan they support show "Update the app to run this script" and fall back to no script features.
ScriptEndpoint.analyze(spreadsheetUuid, fileName, source) → diagnostics for unsaved editor
content (no bytecode, no storage). Debounced 1.5 s in the editor, rate-limited 30/min per user.
| Limit | Value (config key under sheets.scripts.*) |
|---|---|
| Execution time: client run/menu/macro/simple trigger | 30 s (clientTimeoutMs) |
Execution time: onSelectionChange |
5 s |
| Execution time: custom function call | 3 s; 30 s per recalc batch |
| Execution time: server (triggers, MCP, Run on server) | 6 min (serverTimeoutMs) |
| UI wait (alert/prompt) | excluded from time limit, max 5 min each |
| Daily server runtime per user | 90 min (dailyServerRuntimeMs) |
| Daily client runtime per user (reported, soft) | 6 h (warn only) |
| URL fetch calls per user per day | 20,000; response ≤ 10 MB; daily fetched bytes ≤ 500 MB |
| Installable triggers per user per spreadsheet / total | 20 / 200 |
| Concurrent server executions per user | 10 (more are queued) |
| Bridge cells read / written per execution | 10,000,000 / 2,000,000 (approximates memory) |
| Live bridged values in guest (boxed handles + lazy rows materialized) | 5,000,000 |
| Logs per execution | 100 KB (truncated with notice) |
| Properties | 9 KB per value, 500 KB per store |
| Files / file size / project size | 50 / 500 KB / 2 MB |
| Compile | 60 s, 20 compiles per project per hour |
Quota usage is recorded in ScriptQuotaUsage (server) per user per day. Client executions
report their runtime in batches (ScriptEndpoint.reportExecutions); client-side limits are
enforced locally (and are advisory, since a modified client can bypass them). Server limits
are enforced strictly. Quota errors surface as QuotaExceededException with the quota name
and reset time (UTC midnight).
| Scope | Granted by | Allows |
|---|---|---|
spreadsheet.current |
implicit | read/write the bound spreadsheet within the user's role |
ui |
implicit | alerts, prompts, toasts, menus, sidebars |
user.email |
authorization dialog | Session.activeUser.email |
external_request |
authorization dialog | UrlFetchApp |
script.triggers |
authorization dialog | ScriptApp (install/delete triggers) |
drive.readonly / drive |
authorization dialog (P2) | DriveApp over files the user can access |
Required scopes are detected statically by the compiler (references to API members are mapped
to scopes in api_index.json) and listed in exportsJson.scopes.
- The first time a user runs a function (editor, menu, macro, MCP) and the build's scopes exceed the user's grant for this spreadsheet, the Authorization dialog appears: " wants to: • Connect to external services • See your email address • Run when you're not present (triggers)". Buttons Allow / Cancel. The function runs after Allow.
- Grants are stored server-side in
ScriptAuthorization(user, spreadsheet, scopes, grantedAt) and cached on the client (offline runs use the cached grant; server-mediated actions like fetch are checked server-side anyway). - A new build with additional scopes requires re-authorization on next run. Simple triggers run limited until then (§8.7.1).
- Extensions › Script settings › Revoke my authorization removes the grant and disables the user's installable triggers for this spreadsheet.
- MCP
sheets.run_functionrequires the token scopescripts:runand an existing authorization covering the build's scopes; otherwise it fails withauthorization_required(the user must authorize once in the app). This prevents agents from silently granting network access.
| Action | Viewer | Commenter | Editor | Owner |
|---|---|---|---|---|
| Custom functions evaluated in their client | ✓ | ✓ | ✓ | ✓ |
onOpen/menus shown |
✓ (writes fail) | ✓ (writes fail) | ✓ | ✓ |
| Run functions/macros that write | — | — | ✓ | ✓ |
| Open script editor | read-only, if "viewers can download/copy" is on | same | ✓ | ✓ |
| Edit files, triggers (own), macros | — | — | ✓ | ✓ |
| Delete other users' triggers | — | — | — | ✓ |
| Executions panel | own runs | own runs | all runs | all runs |
Users can disable scripts for themselves (Settings › Sheets › Enable scripts): then no
script code runs in their client (custom functions show #LOADING→#ERROR! "Scripts are
disabled") and their installable triggers pause. Admins can disable scripts server-wide
(sheets.scripts.enabled: false).
- Sandbox: dart_eval runtime with no granted built-in permissions; only the
WonkyScriptPluginand the safe stdlib subset (dart:core,dart:math,dart:convert,dart:collection,dart:asyncfutures) are registered.dart:io,dart:isolate,dart:ffi,dart:html,dart:js_interopare not registered at all (compile error). - Process isolation: guests run in isolates/workers with no access to the host's objects except through the channel; handles are opaque ids validated by the host.
- Server: script workers run in separate containers in production, with a container memory
cap, no outbound network except through the
UrlFetchProxyegress (network policy), and no access to secrets beyond DB/storage credentials of the worker role. The DB role used by workers can only write the file tables through server changes (submitServerChange). - Run-as integrity: server executions use the run-as user's
AccessServicerole at execution time; protected ranges are enforced by the script host's guard and, authoritatively, by the RevLog sequencer on the run-as user's ops (RevLog spec 05 §3.5). - SSRF: §8.3.
- Denial of service: time limits, kill, quotas, queue caps, compile rate limits.
- Data exfiltration by a malicious collaborator's script: scripts run with the runner's permissions (like Apps Script). The authorization dialog makes network access explicit. Custom functions (which run for everyone silently) cannot use network unless the project has an explicit allowlist, and the allowlist change itself is shown in the Executions/Activity log ("Anna changed the script URL allowlist").
- Escape tests: §21.3.
class: ScriptExecution
table: script_execution
fields:
uuid: UuidValue, defaultPersist=random_v7
spreadsheetUuid: UuidValue
projectId: UuidValue
functionName: String
kind: ScriptExecutionKind # editor, menu, macro, customFunction, onOpen, onEdit, onSelectionChange,
# onChange, timeDriven, installableOnEdit, installableOnChange,
# installableOnOpen, mcp, ai
triggerId: UuidValue?
userId: int # initiator (UserProfile id)
runAsUserId: int
runtime: ScriptRuntimeKind # clientWeb, clientDesktop, server
status: ScriptExecutionStatus # queued, running, completed, failed, timedOut, cancelled, quotaExceeded, skipped
buildSourceHash: String
startedAt: DateTime
durationMs: int
logs: String # ≤ 100 KB, JSON lines {t, level, msg}
errorMessage: String?
errorStackJson: String? # mapped frames [{function, file, line, column}]
changeId: UuidValue? # ServerChangeRecord for revert (server runs)
cellsRead: int
cellsWritten: int
fetchCalls: int
callCount: int, default=1 # custom functions: one row per recalc batch
indexes:
script_exec_sheet_idx: { fields: spreadsheetUuid, startedAt }
script_exec_user_idx: { fields: userId, startedAt }Retention: 30 days, max 10,000 rows per spreadsheet (oldest pruned daily). Client executions
are uploaded best-effort (batched every 10 s or on route exit; queued offline in
LocalScriptExecutionLog, max 1,000 rows).
With patch DE-1, frames carry the library and source offset of their declaration; the
host maps them to (file, line, column) with exportsJson.lineStarts (no separate positions
file; ScriptBuild.positionsFileUuid stays null). Frames are function-level: the line is the
declaration line of each function on the stack. The error card shows: exception type, message, top frame as a link
(opens the file at the line in the editor), "Show full trace".
In the script editor (bottom tab Executions) and as a side panel in the spreadsheet
(Extensions › Executions): ShadTable with columns Function, Type (trigger kind badge),
Started, Duration, Status (badge colors: success/warning/destructive), User; filters (status,
type, function, "only mine"), live updates while open (ScriptEndpoint.watchExecutions).
Row → detail sheet with logs (monospace, level colors), error card, stats (cells read/written,
fetch calls), Revert changes (server runs, 24 h, Editors+), Open in editor.
Full-screen in-app route /s/:uuid/script (suite routing table). Back arrow returns to
/s/:uuid keeping the grid state. The editor shares the spreadsheet's session (same file and
outbox), so it works offline.
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ ← Budget 2026 · Script [Budget script ✎] ● Saved · Built 10:42 ✓ (avatars) ⋯ │ title row
├─────────────────────────────────────────────────────────────────────────────────────┤
│ [💾 Save ⌘S] [▶ Run ▾ highlightNegatives] [⏱ Triggers] [📋 Executions] [⚙ Settings] │ toolbar
│ [✨ Generate] [?] [Find ⌘F] │
├──────────────┬──────────────────────────────────────────────────────────────────────┤
│ Files + │ main.dart × utils.dart ●× │ tabs
│ ▸ main.dart │ 1 import 'package:wonky/wonky.dart'; │
│ utils.dart │ 2 │
│ macros.dart│ 3 /// @customfunction │
│ │ 4 Object? TO_F(Object? c) { … } │
│ Functions │ ~~~~ warning squiggle │
│ ƒ TO_F fx │ │
│ ƒ onOpen ⚡ │ │
│ ƒ archive │ │
├──────────────┴──────────────────────────────────────────────────────────────────────┤
│ [Problems 2] [Log] [Executions] ▾ collapse │ bottom panel
│ ⚠ main.dart:4:1 Custom function names should be upper case. (wonky_naming) │
└─────────────────────────────────────────────────────────────────────────────────────┘
- Left rail (
WonkyTreeView): Files (create, rename inline, delete, drag to reorder; ● marks unsaved), Functions (from the last build's exports plus a local scan; icons for custom functionfx, trigger ⚡, macro ⌨; click → jump to declaration). - Editor tabs: multiple open files; middle-click/× closes; unsaved dot.
- Bottom panel (
ShadTabs, resizable): Problems (diagnostics; click → navigate), Log (live output of runs started in this window; clear; filter by level), Executions (§13.3). - Narrow widths (< 900 px): the file rail collapses into a dropdown, and the bottom panel becomes a sheet.
Decision: re_editor 0.10.0 (MIT, reqable.com, published 2026; optimized for large texts;
not based on TextField; line numbers, folding, CodeAutocomplete hooks, custom find panel
builder, desktop shortcuts, web support; syntax highlighting via re_highlight, which includes a
Dart grammar).
Rejected: flutter_code_editor 0.3.5 (last release ~12 months ago, TextField-based, slow
on large files), flutter_monaco (WebView/iframe, breaks single-window/offline/theming),
code_forge (Rust backend, native-only concerns on web).
Fallback if re_editor is abandoned: fork it into third_party/ (MIT).
Integration (wonky_sheets/lib/features/scripts/editor/):
ScriptCodeTheme.light/darkmapsre_highlighttoken classes towonky_uitokens (keywords →primary, strings →success, comments →mutedForeground, numbers →info, types →chart3…). Backgroundcard, guttermuted, selectionselection, current lineaccentat 40 %. Font GeistMono 13 px, line height 1.5. Follows app theme live.- Find/replace panel built with shad inputs (
findBuilder): regex, case, whole word. - Shortcuts (editor scope
scriptEditor): Mod+S save, Mod+Enter run selected function, Mod+/ toggle comment, Mod+F find, Mod+H replace, Mod+D add next occurrence (P2), Tab/Shift+Tab indent, Alt+↑/↓ move line, Mod+Shift+K delete line, F8 next problem, Mod+P quick open file. - Gutter: error/warning markers; hover shows the message.
- Save (
sheets.script.saveFile, Mod+S; Save all Mod+Alt+S) writesScriptFile.source,contentHash,updatedBy,updatedAtthrough the session. Saving is not an undo step on the spreadsheet stack: text undo/redo is editor-local (re_editorhistory, kept across saves for the session). Unsaved drafts are persisted every 2 s so a reload or crash loses nothing (built in the device's script storesheets_scripts_<user>.db, tablelocal_script_draft, next to the bytecode cache and the local history; drafts can be large, so they are not in the smallLocalViewStateJSON). - File create/rename/delete/reorder are commands and are undo steps on the spreadsheet stack (labels "Create script file", "Delete main.dart"…), restoring source on undo.
- After save: if online, call
compile; the title row shows "Building…" → "Built 10:42 ✓" or "Build failed (3 errors)". Offline: "Saved on this device · Will compile when online". - Concurrent edits (LWW per file): the editor remembers
baseHash(content hash at load or last save). On save, inside the write transaction it re-reads the row: ifcontentHash ≠ baseHashandupdatedBy ≠ me, the save is paused and a conflict dialog appears: "Bo saved a newer version of utils.dart 3 minutes ago." Actions: Compare (opens a side-by-side diff view; line diff via Myers inwonky_core/lib/diff/), Merge (3-way merge with the local base; non-overlapping hunks auto-merge, overlapping hunks inserted with<<<<<<< yours / ======= / >>>>>>> theirsmarkers and the problems panel lists them), Overwrite with mine, Discard mine. Offline saves that later sync may still overwrite each other (LWW); the losing text is recoverable from the file's local history (last 20 saved versions per file kept inLocalScriptHistory, P1) and from spreadsheet version history. - Remote update to an open file: not dirty → reload silently with a toast "utils.dart was updated by Bo"; dirty → banner "A newer version is available — Compare · Reload".
- Presence: avatars in the title row show who is in the script editor; the file tab shows a
colored dot for files others have open (presence cursor
{route: script, file}).
- Source:
api_index.json(generated bytool/gen_api_index.dartinwonky_sheets_scriptfromapi.dartdoc comments: classes, members, signatures, docs, scopes) + project symbols (exports metadata + a lightweight local scan of top-level declarations and local variables with explicit types). - Member completion after
.uses a small type inference over the expression chain using API return types (SpreadsheetApp.active.→Spreadsheetmembers;final s = ss.sheetByName('x')!;→Sheet). Unknown receivers fall back to all identifiers. - Popup built with shad styling (via
CodeAutocompletebuilder): icon by kind, signature, first doc line; Enter/Tab accepts, Esc closes. Signature help (P1) and hover docs (P1). - Help › Script API reference opens an in-app docs route rendered from the same index.
Run ▾: split button. Main part runs the selected function; the chevron opens a picker of
runnable functions (no required params) with search. Runs use the client runtime; P1 adds
Run on server (enqueues a server job, streams logs back). While running, the button turns
into Stop (sheets.script.stop). Unsaved changes: run asks "Save and run?" (needs a
successful build; offline uses the last good build with a warning).
Lists the user's installable triggers (owner sees all: column "Owner"): Function, Event
(time-driven/edit/change/open), Schedule summary, Last run (status), Enabled switch, ⋮
(Edit, Delete, View executions). Add trigger form (WonkyDialogSpec): function select,
event source (Time-driven / From spreadsheet), event type, schedule fields (type: minutes /
hourly / daily / weekly / monthly / specific date; interval; time-of-day window; weekday;
day), failure notifications (immediately / daily / never), Fire for agent (MCP) edits
checkbox for edit triggers. Saving requires scope script.triggers (authorization dialog).
Name, time zone (defaults to spreadsheet), URL allowlist (chips input), "Show script menus in menubar" toggle, API version (read-only), build info (hash, time, size), Revoke my authorization, Delete project (Owner; confirm; deletes files, triggers, macros).
- Extensions › Macros › Record macro (
sheets.macro.record) shows a floating recording bar at the bottom center: "● Recording new macro…", segmented control Use absolute references / Use relative references, Save, Cancel. - While recording, the
MacroRecorderlistens toCommandDispatcherCommandInvokedevents with sourcesmenu,toolbar,shortcut,contextMenu,palette, plus cell-editor commits and paste/fill. Each recordable command implements an optionalScriptCodegen:
abstract interface class ScriptCodegen {
/// Returns Dart statements reproducing the command, or null if not recordable.
List<String>? toScript(CommandArgs args, MacroContext ctx);
}
class MacroContext {
final MacroReferenceMode mode; // absolute | relative
final GridSelection selectionBefore; // ranges as A1 + sheet name
final GridSelection selectionAfter;
String rangeExpr(GridRange r); // absolute: sheet.getRange('B2:C5'); relative: active.offset(…)
}- Non-recordable commands insert a comment
// Not recorded: Insert chart. - Save → dialog: Name (default "Macro 1"), Shortcut (optional
Mod+Alt+Shift+0..9, shows conflicts with other macros). The recorder appends a function tomacros.dart(created if missing), creates aMacrorow (functionName,shortcutSlot), saves the file and compiles. One undo step "Record macro" (file change + macro row).
Generated code example (relative mode):
/// Macro "Format header" (recorded 2026-10-12).
void formatHeader() {
final sheet = SpreadsheetApp.activeSheet;
final active = SpreadsheetApp.activeRange;
active.offset(0, 0, 1, 5).fontWeight = 'bold';
active.offset(0, 0, 1, 5).background = '#D9EAD3';
sheet.frozenRows = 1;
}Macros: Extensions › Macros ▸ lists macros (with shortcuts), Manage macros (dialog: rename,
change shortcut, edit script, remove), Import macro (choose an exported runnable function).
Each macro registers the dynamic command sheets.macro.m<macroId> (the UUID's 32 hex digits, since command id segments are identifiers) when the spreadsheet loads
or the macro set changes (suite 08 §1.1), with title = macro name and key binding from
shortcutSlot. Macros run on the client; via MCP headless they run on the server with
sheets.run_function.
Shortcut note: Mod+Alt+Shift+0…9 are reserved for macros in the grid scope (Google Sheets
parity). This conflicts with the suite design system's Mod+Alt+Shift+{n} panel cycling
(02-design-system.md §4.5); in Sheets the grid scope wins
(more specific scope), and panel cycling stays available from other scopes. See
13-implementation-plan.md §9.
Menu.addToUi()sends the menu tree to the host, which registers it inScriptMenuRegistryfor this window (per-session; rebuilt on everyonOpenrun and after new builds).- Placement: each menu appears as a top-level menubar item after Extensions (Google Sheets behavior) when "Show script menus in menubar" is on; always also listed under Extensions ▸ . Max 10 top-level script menus, 50 items each, depth 3.
- Items invoke the stable command
sheets.script.runMenuItemwith args{functionName, caption}; they appear in the command palette as " › " and inui.get_menu_tree.
alert/prompt→WonkyFormDialogbuilt from aWonkyDialogSpec(title, message, text field for prompt, buttons perButtonSet); MCP-operable viaui.get_dialog/ui.fill_dialog.toast→ShadSonnertoast bottom-right with title, message, close; timeout per argument (−1 = until closed).
final panel = UiPanel(title: 'Import', children: [
UiText('Choose a source'),
UiSelect(id: 'source', options: ['CRM', 'Shop'], value: 'CRM'),
UiTextField(id: 'from', label: 'From date', type: 'date'),
UiCheckbox(id: 'clear', label: 'Clear sheet first'),
UiRow(children: [UiButton('Import', onClick: 'runImport', primary: true), UiButton('Close', onClick: '_close')]),
]);
SpreadsheetApp.ui.showSidebar(panel);
/// Called with the current form values.
void runImport(UiEvent e) { final source = e.values['source']; … e.panel.update(...); }Components: UiText (Markdown subset), UiTextField, UiTextarea, UiSelect,
UiCheckbox, UiRadioGroup, UiButton, UiRow, UiColumn, UiDivider, UiTable
(read-only), UiProgress, UiImage (wonky-file or https via proxy). Rendered with shad widgets in
the side panel host (WonkySplitView panel id scriptSidebar) or a ShadDialog. Event
callbacks start a new execution of the named function with UiEvent {values, panel}. No HTML,
no arbitrary widgets, no script-controlled styling beyond primary/destructive variants.
Built (2026-10-14): Ui.showSidebar(panel), Ui.showModalDialog(panel, title), components
UiText (bold/italic/code), UiTextField (type text/number/date/email), UiTextarea,
UiSelect, UiCheckbox, UiRadioGroup, UiButton (onClick = exported function or
_close), UiRow (wraps), UiColumn, UiDivider, UiTable (≤ 500 rows), UiProgress;
UiEvent {values, panel, button} with panel.update(UiPanel) / panel.close(). The host
validates panels (ScriptPanelSchema: ≤ 300 components, depth 6, button targets).
Sidebars are the side panel sheets.panel.scriptSidebar, dialogs a ShadDialog; both are
MCP-operable (fields = inputs, buttons by caption). Button runs are menu executions (one undo
step "Script: fn"). Not built: UiImage.
| Command id | Args | Undoable | Role |
|---|---|---|---|
sheets.script.openEditor |
{file?, line?} |
— | Viewer+ (read-only for non-editors) |
sheets.script.closeEditor |
— | — | |
sheets.script.newFile |
{name} |
✓ | Editor |
sheets.script.renameFile |
{fileId, name} |
✓ | Editor |
sheets.script.deleteFile |
{fileId} |
✓ | Editor |
sheets.script.reorderFile |
{fileId, index} |
✓ | Editor |
sheets.script.saveFile / saveAll |
{fileId?} |
— (not on stack) | Editor |
sheets.script.compile |
— | — | Editor |
sheets.script.run |
{functionName, onServer?} |
✓ ("Script: fn") | Editor |
sheets.script.stop |
{executionId?} |
— | |
sheets.script.runMenuItem |
{functionName, caption} |
✓ | Viewer+ (writes need Editor) |
sheets.script.openTriggers / addTrigger / updateTrigger / deleteTrigger |
trigger fields | add/update/delete ✓ | Editor |
sheets.script.openExecutions |
{filter?} |
— | Viewer+ |
sheets.script.revertExecution |
{executionId} |
✓ | Editor |
sheets.script.openSettings / updateSettings |
settings patch | ✓ | Editor |
sheets.script.authorize / revokeAuthorization |
— | — | Viewer+ |
sheets.script.generateWithAi / explainWithAi / fixErrorWithAi |
{prompt?, fileId?, diagnosticIndex?} |
via AI step | Editor |
sheets.macro.record / saveRecording / cancelRecording |
{mode} / {name, shortcutSlot?} |
save ✓ | Editor |
sheets.macro.import |
{functionName, name, shortcutSlot?} |
✓ | Editor |
sheets.macro.manage |
— | — | Editor |
sheets.macro.update / delete |
{macroId, …} |
✓ | Editor |
sheets.macro.<macroId> (dynamic) |
— | ✓ | Editor |
sheets.help.scriptApiReference |
{class?} |
— | Viewer+ (headless: lists the classes) |
Headless (server; namespace sheets.*, token scopes in parentheses). Schemas follow suite
09-mcp.md conventions; listed in 12-mcp-and-ai.md §3.12.
| Tool | Input | Output |
|---|---|---|
sheets.list_script_functions (sheets:read) |
spreadsheet (uuid) |
{build: {sourceHash, builtAt, status, errors}, functions: [{name, params, doc, kind: runnable|customFunction|trigger|macro, macro?: {name, shortcut}}], triggers: […]} |
sheets.run_function (scripts:run + sheets:edit for writes) |
spreadsheet, function, args?: JSON[], timeout_s? (≤ 360) |
{status, result (JSON), logs, error?: {message, stack}, change_id?, execution_id, cells_written} |
sheets.get_script_files (sheets:read) |
spreadsheet, names? |
{files: [{name, source, content_hash, updated_by, updated_at}]} |
sheets.update_script_file (sheets:edit + scripts:run) |
spreadsheet, name, source, base_hash?, create_if_missing? |
{content_hash, build: {status, diagnostics}} (waits ≤ 30 s for the build); conflict error if base_hash mismatches |
sheets.delete_script_file (same) |
spreadsheet, name |
{ok} |
sheets.get_executions (sheets:read) |
spreadsheet, function?, status?, since?, limit, cursor |
executions with logs (truncated 10 KB each) |
UI bridge: ui.invoke_command with the commands above (e.g. sheets.macro.<id>),
ui.navigate('/s/<uuid>/script').
| # | Change |
|---|---|
| A-09-1 | ScriptBuild gains status: ScriptBuildStatus (ok, errors), apiVersion: int, compilerVersion: String, requestedBy: UuidValue, lastGoodSourceHash: String?, lastGoodBytecodeFileUuid: UuidValue?, lastGoodExportsJson: String?, lastGoodBuiltAt: DateTime?, positionsFileUuid: UuidValue? (DE-1 table). |
| A-09-2 | ScriptFile gains contentHash: String (sha256 hex of source). |
| A-09-3 | ScriptTrigger gains optionsJson: String? ({fireForMcp, notify: immediately|daily|never}) and createdAt. TriggerKind gains installableOnOpen; simple triggers are not stored as rows (they're detected from exports). |
| A-09-4 | Server table ScriptUserProperty(userId, fileUuid, key, value, updatedAt), unique (userId, fileUuid, key); endpoints sheets.getUserProperties / setUserProperties (suite 17 part 2 §2.4). |
| A-09-5 | The op summary (summaryJson.kinds) uses scripts for changes to script tables. |
| A-09-6 | Server tables: ScriptExecution (§13.1); ScriptQuotaUsage(userId, day: DateTime (UTC date), serverRuntimeMs, clientRuntimeMs, urlFetchCalls, urlFetchBytes, triggerRuns); ScriptAuthorization(userId, spreadsheetUuid, scopes: List<String>, grantedAt, revokedAt?); ScriptTriggerState(triggerId: UuidValue, spreadsheetUuid, nextRunAt?, lastRunAt?, lastStatus?, consecutiveFailures, disabledReason?); ScriptJob(uuid, kind, spreadsheetUuid, runAsUserId, payloadJson, status, enqueuedAt, leaseOwner?, leaseUntil?, attempts, resultJson?). |
| A-09-7 | Local-only client tables (built in sheets_scripts_<user>.db, features/scripts/local/script_local_store.dart): local_script_execution_log (pending uploads, ≤ 1000 per spreadsheet), local_script_history(fileId, savedAt, source, note) (last 20 per file), local_script_draft(spreadsheet, fileId, text, baseHash, baseText), and the bytecode in local_blob_cache(key, mime, bytes, createdAt) (last 12 builds; the image cache of A-08-5 can share the table). |
| A-09-9 | ScriptTriggerState.lastNotifiedAt (daily failure notifications); ScriptJob gains itemUuid, triggerId, functionName, coalesced, finishedAt (status reuses ScriptExecutionStatus). |
| A-09-8 | SpaceSubscription.reason gains script (already listed in suite 12). |
- A user creates
main.dartwith theTO_Fcustom function, saves, and within 5 s (online)=TO_F(A1:A100)spills 100 converted values; the formula autocomplete showsTO_Fwith its doc. Viewers see the same values. - Offline: editing and saving work; the status shows "Will compile when online"; existing custom functions keep working with the last good build; after reconnect the server builds and functions update.
- A script with an extension method shows a
wonky_extensionerror at the right line; the previous good build keeps running. onOpenadds a "Report" menu next to Extensions; clicking an item runs the function; its writes are one undo step "Script: archive"; the ops haveactorKind = script.- The
onEdittimestamp example writes the time when D changes; one Mod+Z undoes both the edit and the timestamp. - An infinite loop in a menu function is stopped after 30 s with "Exceeded maximum execution
time"; the UI stays responsive throughout (web and desktop); a custom function infinite
loop yields
#ERROR!after 3 s without blocking other custom functions. - A time-driven trigger (every hour) runs on the server as the installer, appears in Executions with logs, and stops with a notification when the installer is downgraded to viewer.
UrlFetchApp.fetch('http://169.254.169.254/'),https://localhost, a DNS name resolving to 10.0.0.5, and a redirect to a private IP all fail withUrlFetchException(blocked).- Responses > 10 MB abort with an error; 20,001st fetch of the day fails with
QuotaExceededException. - Macro recorder: record bold + background + freeze, save with Mod+Alt+Shift+1, run it on another selection with relative references → same result; one undo step.
- Two editors save the same file concurrently online → the second gets the conflict dialog; Merge produces a merged file with conflict markers only for overlapping hunks.
- MCP:
sheets.update_script_filereturns diagnostics;sheets.run_functionreturns the function result, logs and achange_idthatchanges.revertundoes. - The web worker bundle is ≤ 1.5 MB gzip; the main web bundle contains no
analyzercode (CI check by symbol scan). - Every command in §17 works via
ui.invoke_commandand passes the command test harness.
- A corpus of ~300 small scripts with expected workbook state/results (JSON fixtures).
The same corpus runs against three hosts:
FakeWorkbookAccess(pure Dart, CI fast),ClientWorkbookAccess(Flutter test with in-memory sync DB),HeadlessWorkbookAccess(server test withserverpod_test). Results must be identical. - Covers every API member in §6, value conversions (dates in time zones, errors, empty, large ints), A1 parsing edge cases, handle stability across inserts, flush semantics, formulas written then read (recalculated values).
- Infinite loops in each invocation kind (client web worker, client isolate, server worker): terminated within limit + 1 s; host recovers; next run succeeds.
- Memory bomb (
List.filled(1e9, 0)in a loop) → killed by the bridged-value guard or the RSS watchdog; server worker stays alive (or is restarted by orchestration) and the job is marked failed. - Quota counters: fetch calls, runtime, triggers per user.
- Attempted imports of
dart:io,dart:isolate,dart:ffi,package:http→ compile errors. - Reflection-like tricks (
Function.applyon bridged objects,noSuchMethod, accessing$Valueinternals, forged handle ids) → host rejects unknown/foreign handles. - Handles from another spreadsheet/execution → rejected.
- Writes by viewers, into protected ranges, via custom functions →
PermissionDenied/CustomFunctionSideEffect. - SSRF matrix (IPv4/IPv6 private ranges, decimal/octal IP forms, DNS rebinding with a test DNS server, redirects, IDN hosts, ports).
- Authorization: running a build with a new scope without re-authorization fails; MCP cannot authorize.
- Diagnostics positions for each
wonky_*lint; analyzer errors mapping; exports extraction (doc tags, params); deterministicsourceHash; build coalescing under rapid saves.
- Flutter integration tests (flutter_driver + MCP) for the editor: create file, type, save, run, see log, add trigger, record macro.
- Multi-client: two clients with
onEdittriggers editing concurrently; installable trigger fires once per committed op.
| Option | Pros | Cons | Verdict |
|---|---|---|---|
Dart via dart_eval (chosen) |
Same language as the whole stack; API defined once in Dart and bound by codegen; pure Dart runtime runs on web, desktop and server without native blobs; typed, documented API; Flutter developers (our audience) know Dart. | 10–50× slower than AOT; language subset; no built-in CPU/memory limits; analyzer-based compiler is big (server-only); pre-1.0 single-maintainer project. | Chosen with mitigations below. |
| JavaScript via QuickJS (FFI on native, wasm on web) | Apps Script users know JS; mature sandbox with built-in memory limit and interrupt handler; fast enough. | Two languages in the stack; separate binding layer per platform (FFI + JS interop/wasm); larger web bundle (~1 MB wasm); no shared types with our Dart API; more native build complexity for desktop. | Rejected for v1. The host protocol (§8.2.2) is engine-agnostic, so a QuickJS guest could be added later behind the same ScriptHost. |
Lua (lua_dardo pure-Dart interpreter, or native Lua via FFI) |
Small, embeddable, simple sandboxing. | lua_dardo is unmaintained and incomplete; Lua is unfamiliar to most spreadsheet users; same two-language problem. |
Rejected. |
Formula-only extensibility (named functions/LAMBDA) |
No new runtime. | Can't do triggers, menus, HTTP, automation. | Kept as complement (named functions in 05-formula-engine.md). |
| Risk | Impact | Mitigation |
|---|---|---|
| Interpreter performance | Slow per-cell loops, slow custom functions over large ranges | Bulk APIs and lazy row conversion; write batching; custom function batching and caching; documentation and an editor hint ("Use values instead of reading cells in a loop") when the lint detects getRange inside loops. |
| Language coverage gaps / interpreter bugs | Scripts fail in surprising ways | Subset lint with friendly messages; conformance corpus; vendored dart_eval with patches upstreamed; pin versions; apiVersion/compilerVersion in builds so upgrades can be rolled out per build. |
| Web worker complexity (cross-origin isolation, SAB) | Sync bridge unavailable in some contexts | COOP/COEP headers; snapshot-mode fallback; Run on server option; tests in Chrome, Firefox, Safari. |
native_synchronization is experimental |
API changes | Wrap behind SyncChannel; pin; fallback implementation with dart:ffi pthread mutex/condvar if needed. |
| No CPU/memory limits in dart_eval | DoS | Isolate/worker kill; server workers in separate containers; bridged-value and RSS guards; DE-2 checkpoints. |
| Analyzer compile cost on server | CPU spikes | Dedicated compiler isolates with a small pool; coalescing; sourceHash cache; rate limits. |
| Malicious scripts in shared spreadsheets | Data exfiltration | Runner's permissions only; explicit authorization for network; custom functions need an allowlist; activity entries for allowlist changes; per-user kill switch. |
Built (package wonky_sheets_script, third_party/dart_eval, wonky_server/lib/src/sheets/scripts):
- API §6.1–6.11 (P0/P1 members) as interpreted source, plus charts, filters, conditional
formats (read and write), protections (§6.3),
LockService/CacheService(§6.12) and the UI DSL (§16.3);api_index.json(lib/src/api/api_index_data.dart, generated bytool/gen_api_index.dartfrom the API source with the analyzer; a test fails when stale) withApiIndexandScriptCompletion(member completion with type inference over expression chains, locals, for-in, project symbols; hover docs). Runners (isolate, inline);ScriptHostoverWorkbookAccess; custom functions (§8.6) with cache, batching and#LOADING;ScriptSession(live log callbackonLiveLog); macro recorder codegen (§15); compiler with the subset lint (§9.3), exports (§5.1) and the dispatcher; conformance corpus (~150 cases) on inline/isolate runners and on a database-loaded workbook; sandbox escape, time-limit and memory tests.dart_evalpatch DE-20 (interpolating a nullString?). - Server:
ScriptEndpoint(compile,analyze,bytecode,run,runLive(log lines streamed before the result),executions,reportExecutions,urlFetch,authorization/authorize/revokeAuthorization,notifyOpen); tablesScriptExecution,ScriptQuotaUsage,ScriptAuthorization,ScriptTriggerState(+lastNotifiedAt),ScriptJob; the job queue and worker role (§8.5: leases, 10 running jobs per user, one per trigger, 100 queued per trigger with coalescing, stale-lease retry, daily pruning); SSRF-safeUrlFetchProxywith the 1 h custom-function response cache (ScriptFetchCache); compile rate limit 20/h per project and "latest wins" coalescing (CompileCoalescer), analysis rate limit 30/min per user (ScriptRateLimiter, in-memory per instance); failure notifications (scriptFailure: per-triggernotifyimmediately / daily (default, ≤ 1 a day) / never; disabling always notifies); installers with Enable scripts off pause (no failure count); time-driven scheduler (wonky.scriptsTick) and pruning (wonky.scriptsPrune); installable onEdit/onChange from change-log processing, installable onOpen vianotifyOpen; compile-after-sync; MCP tools §18; configwonky.features.scripting,wonky.sheets.scriptTimeoutMs,scriptServerTimeoutMs,scriptDailyQuotaMs,scriptAllowHttp,scriptPoolSize,scriptWorker,scriptUserConcurrency; user properties of server runs (ServerUserProperties, §6.9). - App (
wonky_sheets/lib/features/{scripts,macros},commands/{script,macro}_commands.dart):ScriptService(one per editor): last good build with the persistent bytecode cache (ScriptLocalStore, SQLitesheets_scripts_<user>.db: blob cache, execution log queue, local history, drafts; offline reopen installs the cached build),IsolateScriptRunneron desktop /InlineScriptRunneron web, script writes through the session, one undo step "Script: fn" per execution,onOpenper window and per build,onEdit/onChangeafter a person's command,onEditwrites appended within 2 s,onSelectionChangedebounced 300 ms, executions queued inlocal_script_execution_logand uploaded in 10 s batches (survive reloads offline), authorization dialog, Stop = new session; live log lines while a function runs (client and Run on server); Settings › Sheets › Enable scripts (off: no runs, triggers or menus, custom functions#ERROR!"Scripts are turned off…", banner in the editor with Turn on scripts).- Custom functions in formulas (+ autocomplete/signature help);
#LOADINGis painted as a muted italic "Loading…" without the error triangle. - Script editor
/s/:uuid/script: title row with build status and the avatars of collaborators in the script editor (presence cursor{route: script, file}; colored dots on file tabs others have open), toolbar (Save, Run + picker, Run on server, Stop, Triggers, Executions, Settings, Generate with AI, Find, Local history, Script API reference), file rail with drag-reorder (sheets.script.reorderFile, one undo step) and Functions, tabs,re_editorwith Dart highlighting, API autocomplete (popup with kind icons, signatures and docs; Enter accepts), hover docs, squiggle underlines (wavy, by severity) plus gutter markers, find/replace, Problems (liveanalyze, build diagnostics, unresolved merge markers) / Log (live) / Executions; drafts restored after a reload; save conflicts show Compare (side-by-side diff ≥ 640 px, unified below; Myers line diff inpackage:wonky_core/diff.dart), Merge (3-way with the local base; conflicts marked with<<<<<<< yours/>>>>>>> theirs), Discard mine, Overwrite with mine; the local history dialog (last 20 versions per file, also discarded/overwritten text) restores a version as a draft. Narrow (< 900 px) file dropdown; read-only for viewers. - Help › Script API reference (
sheets.help.scriptApiReference): searchable classes and members fromapi_index.json(dialog, two panes ≥ 600 px, stacked below). - Dialogs: authorization, triggers, project settings, conflict, history, API reference, script alerts/prompts, script sidebars/dialogs (UI DSL); toasts via the shell.
- Executions side panel, custom menus, macros (§15) with
Mod+Alt+Shift+1…0— the keymap now matches the digit row by physical key when Shift/Alt change the typed character (web, macOS layouts), covered by a keymap test. - Commands of §17,
sheets.script.deleteProject,sheets.help.scriptApiReference.
Not built yet (gaps):
- Web: SharedArrayBuffer worker and snapshot mode (decision in §8.2.1: not isolated yet;
alerts/prompts/
UrlFetchAppstay unavailable in the web inline runner, Run on server streams its logs instead). analyzer8.x pin: portingdart_evalto analyzer 13 (assessed 2026-10-14 against 13.3.0) is 93 compile errors in 23 files of its compiler (NamedExpression →NamedArgument,DefaultFormalParameter/SimpleFormalParameter→FormalParameterDefaultClause/RegularFormalParameter, class/enum bodies for primary constructors,DottedName) plus 15 inwonky_sheets_script(compiler, subset lint, index builder); it resolves outside the workspace (analyzer 13.3.0, dart_style 3.1.13, test 1.33) but changes the shared lockfile. Deferred to a quiet window: port, run the 435 dart_eval and 440 package tests, bump the lock.- Editor: Tab does not accept a completion (Enter does;
re_editorbinds only Enter); signature help while typing arguments; Mod+D; completion/hover in non-active files. - Limits are per instance (rate limiters, fetch cache, locks, script cache); a shared Redis
store is the multi-instance follow-up. Digest settings beyond the per-trigger
notifyoption; the server-wide URL denylist config (UrlFetchProxy.deniedHostsexists);UiImage;DriveApp;editorsofSpreadsheet(P2).
{ "apiVersion": 1, "functions": [ { "name": "TO_F", "library": "package:script/main.dart", "line": 12, "params": [ { "name": "celsius", "type": "Object?", "optional": false, "doc": "A temperature…" } ], "returns": "Object?", "async": false, "doc": "Converts Celsius to Fahrenheit.", "customFunction": { "formulaName": "TO_F", "examples": ["=TO_F(A2:A20)"] }, "runnable": false } ], "triggers": { "onOpen": "package:script/main.dart", "onEdit": null, "onSelectionChange": null, "onChange": null }, "scopes": ["spreadsheet.current", "ui", "external_request"], // §11.1, detected statically "customFunctionScopes": [], // scopes reachable from custom functions "warnings": 2, "lineStarts": { "main.dart": [0, 35, 36] } // DE-1 line tables (instead of a positions file) }