Building a plugin
A step-by-step guide to writing, testing, and packaging a DreamSheets plugin.
Plugins extend DreamSheets with JavaScript. A plugin can add formula functions callable from any formula, commands in the Tools menu, table transforms in the table context menu, panels with their own UI, integrations that fetch external data into tables, and database connectors — a whole new database kind that behaves like the built-in ones. This page walks through building one end to end; the complete type-level surface is in the Plugin API reference.
Looking to install a plugin rather than write one? See Browse Plugins.
Anatomy of a plugin
A plugin is a folder containing a manifest and one JavaScript module:
my-plugin/
plugin.json ← the manifest
main.js ← a single bundled ES moduleplugin.json:
{
"id": "com.example.my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "What it does, in one line.",
"entry": "main.js",
"apiVersion": 1,
"permissions": { "network": ["api.example.com"] },
"contributes": {
"functions": [{ "name": "MYFUNC", "description": "…" }],
"commands": [{ "id": "doThing", "title": "Do the Thing" }]
}
}id— unique, stable,[A-Za-z0-9._-]only (it names the install directory). Reverse-DNS style is conventional.entry— the module file, relative to the folder.apiVersion— the current host API version is4. Declare the lowest version you actually need (this manifest only uses version-1 APIs, so it declares1): the app refuses a manifest asking for a version newer than it implements, but an older plugin keeps working on a newer app.ui.showPanelandquery.updateneed2;connectors.registerneeds3;secretsneeds4.permissions.network— the only permission that exists: an allowlist of hosts your plugin may fetch from (exact hostname, or one leading*.wildcard like*.example.com). Omit it for no network access.contributes— display metadata shown in the Plugin Manager. What the plugin actually does is whateveractivate()registers; keep the two in sync for honest listings.
main.js exports two functions:
export function activate(api) {
// register everything here
}
export function deactivate() {
// optional — the host rolls back all registrations for you
}esbuild src/main.ts --bundle --format=esm --outfile=main.js).Step 1: a formula function
export function activate(api) {
api.functions.register("MEDIAN", (args) => {
const nums = args.flat(Infinity)
.filter((v) => typeof v === "number");
if (nums.length === 0) return null;
nums.sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
});
}Users can now write =MEDIAN(@Score) or =MEDIAN(Sales.Amount) anywhere formulas go — calc columns, summary cells, tiles. Things to know:
- Arguments arrive eagerly evaluated as plain values: numbers, strings, booleans,
null, or arrays of those (a column reference arrives as an array). Dates arrive as ISO-8601 strings. - Throwing shows
#EVAL!in the calling cell, with your message on hover. ReturningNaN/Infinityor an unsupported type is also#EVAL!. - Calls are batched per column — one call into your function per row, but marshalled across the engine boundary in a single batch, so a 100,000-row column doesn't make 100,000 round trips. A one-off formula (tile, summary cell, inline
{…}) is a batch of one. - Function names must look like identifiers, can't be a built-in name, and are session-scoped: they exist while the plugin is loaded. A document using
MEDIANopened without the plugin shows#NAME!in that column — same contract as a missing Excel add-in. - One composition limit: a user's custom-function body can't call a plugin function — plugin calls belong at the column level.
Step 2: a command
api.commands.register({
id: "insertStats",
title: "Insert Stats Table",
run: async () => {
const snap = api.document.getSnapshot();
const table = snap.tables[0];
if (!table) { api.ui.showStatus("No tables in this document", "err"); return; }
// …compute something from table.rows…
const result = await api.document.applyActions([{
action: "create_table_with_data",
params: {
name: "Stats",
tabId: table.tabId,
columns: [{ name: "Metric" }, { name: "Value", storageType: "number" }],
rows: [["rows", table.rows.length]],
},
}]);
api.ui.showStatus(result.kind === "ok" ? "Stats inserted" : result.message, result.kind === "ok" ? "ok" : "err");
},
});Commands appear in the Tools menu and the Command Palette, and users can bind hotkeys to them via Configure Hotkeys. Thrown errors and rejected promises are caught and shown in the status bar — your plugin can't take the app down.
Step 3: a table transform
A transform is a command anchored to a table — it appears in the table's context menu and receives that table's contents:
api.transforms.register({
id: "summarize",
title: "Summarize Columns",
run: async (ctx) => {
// ctx.tableName, ctx.columns, ctx.rowIds, ctx.rows (row-major, calc cells computed)
const rows = ctx.columns.map((col, i) => [
col.name,
ctx.rows.filter((r) => r[i] !== null && r[i] !== "").length,
]);
await ctx.applyActions([{
action: "create_table_with_data",
params: {
name: `${ctx.tableName} Summary`,
tabId: ctx.tabId,
columns: [{ name: "Column" }, { name: "NonEmpty", storageType: "number" }],
rows,
},
}]);
},
});Step 4: fetching external data
Network access goes through api.http.fetch, which enforces the manifest's host allowlist in the app's backend — HTTPS only (plain HTTP allowed for localhost), 30-second timeout, 5 MB response cap:
api.commands.register({
id: "fetchRates",
title: "Fetch EUR Exchange Rates",
run: async () => {
const res = await api.http.fetch({ url: "https://api.frankfurter.dev/v1/latest?from=EUR" });
const data = JSON.parse(res.body);
const rows = Object.entries(data.rates).map(([cur, rate]) => [cur, rate]);
await api.document.applyActions([{
action: "create_table_with_data",
params: {
name: "EURRates",
tabId: api.document.getSnapshot().tabs[0].id,
columns: [{ name: "Currency" }, { name: "Rate", storageType: "number" }],
rows,
},
}]);
},
});Requests to hosts not in permissions.network reject with an error. The manifest is re-read from disk on every request, so nothing at runtime can widen the permission.
Step 5: a database connector
If what you want isn't "load some data once" but "connect to X", register a connector instead. The kind you register is a peer of PostgreSQL and the rest: it shows up in File → Database Connections…, its credentials are stored by the app (never in a .dsheet), and tables imported from it are real linked query tables with Refresh query and Edit query….
The whole contract is two methods:
api.connectors.register({
id: "warehouse",
title: "Acme Warehouse",
connectionPlaceholder: "account=…;token=…",
note: "Uses the Acme REST API over HTTPS. Connect as a read-only role.",
async query(conn, sql) {
const res = await api.http.fetch({
url: `https://${host(conn)}/query`,
method: "POST",
headers: { Authorization: `Bearer ${token(conn)}` },
body: JSON.stringify({ sql }),
});
if (res.status !== 200) throw new Error(`Acme said ${res.status}`);
const body = JSON.parse(res.body);
return { columns: body.columns, rows: body.rows }; // row-major
},
async schema(conn) {
return [{ name: "orders", columns: ["id", "region", "amount"] }];
},
});Note what you don't write: no column types, no date parsing, no row cap, no undo handling, no dialog. Return names and raw values and the app types them the same way it types a Postgres result — whole numbers become BIGINT columns, ISO-looking text becomes real DATE columns with a date format. Throwing fails the import with your message and leaves the document untouched.
The bundled Sample Connector plugin is a complete working example with in-memory data — install it from Browse Plugins to see the whole flow with no server or credentials. Full details: `connectors.register`.
Developing and testing
- Tools → Plugin Manager… → Install Folder as Dev… and pick your plugin folder. A dev install loads the folder in place — no copying.
- Edit your files, then click ↻ Reload on the plugin's row. Registrations are torn down and rebuilt; no app restart, ever.
- Errors in
activate()roll back everything the plugin registered and show the error on the plugin's row — the app keeps running. api.ui.showStatus("…")is yourconsole.logfor quick feedback.
The repository's reference plugin, sample-stats, exercises all four extension points in ~160 lines (examples/plugins/sample-stats) — a good starting skeleton. For a panel-based plugin, sql-builder (examples/plugins/sql-builder) shows ui.showPanel and query.update in use; sample-connector (examples/plugins/sample-connector) is the reference for connectors.register.
If you are editing a plugin that ships with the app (examples/plugins/…), note that its files are staged into the build output — rebuild the app to restage, or the marketplace keeps installing the previous copy. Adding a new bundled plugin folder needs the build script itself to re-run (touch build.rs), or the catalog won't see it at all. A dev install loads in place and has no such lag, which is why it's the right loop while developing.
Packaging and installing
Zip the folder (with plugin.json at the zip root, or inside a single top-level folder) as .zip or .dspkg. Users install via Plugin Manager → Install from Zip… (or Install from Folder…). Installs are per-user; enable/disable is instant and remembered.
Browse Plugins
Tools → Plugin Manager… → Browse Plugins… lists the plugins that ship with DreamSheets. Installing from there is a local copy out of the app — nothing is downloaded, so there is no network access, no signature to verify, and a listed plugin can never require a newer plugin API than the app it shipped inside.
Each row shows the plugin's description, what it contributes, and the hosts it may reach before you install it. Once a newer app version bundles a newer copy, the row offers Update instead of Install; updating keeps your enabled/disabled choice.
Four plugins ship in the catalog today:
- FX Rates — currency conversion as a formula:
=FX(@Amount, "USD", "EUR"), backed by the daily ECB reference rates. - SQL Builder — summarize a table without writing SQL: pick fields to group by and values to total, and it writes the query for you.
- Sample Stats — the reference plugin: a
MEDIANformula function, an Insert Stats Table command, a Summarize Columns transform, and an HTTP demo. - Sample Connector — the reference for database connectors: a "Sample Warehouse" kind that behaves like a built-in engine, against in-memory demo data.
Today the catalog is first-party only. Third-party plugins install from a folder or a .zip as above.
The trust model
Be straight with your users, because DreamSheets is straight with them: plugin JS runs inside the app with full access to the open document — the VS Code / Unity model, no hard sandbox. The Plugin Manager says exactly that. The one hard boundary is the network allowlist, enforced in the native backend. Design accordingly: ask for the narrowest hosts you need, and put honest descriptions in contributes.
The same goes for `secrets`: the OS credential store keeps a token out of documents and off the disk, and each plugin's keys are kept separate. But that's namespacing, not isolation. A malicious plugin could still ask for another plugin's secrets, so installing a plugin means trusting it with everything the others store.