Plugin API reference
The complete typed surface of the DreamSheets plugin API — every interface, method, value type, and error rule.
The complete API surface for plugin authors. TypeScript types ship in the @dreamsheets/plugin-sdk package (types only — the implementation lives in the app). For a guided tour, start with Building a plugin.
The module contract
Your entry module must export:
export interface PluginModule {
activate(api: DreamSheetsPluginApi): void | Promise<void>;
deactivate?(): void;
}activate is called when the plugin loads (at app start for enabled plugins, or when enabled/reloaded). All registrations happen here. If activate throws, everything it registered so far is rolled back and the error is shown on the plugin's row in the Plugin Manager. deactivate is optional cleanup — the host already unregisters everything for you.
The manifest
export interface PluginManifestShape {
readonly id: string; // [A-Za-z0-9._-], ≤128 chars, unique
readonly name: string;
readonly version: string;
readonly description?: string;
readonly entry: string; // relative path, no "..", no absolute paths
readonly apiVersion: number; // must be ≤ 4
readonly permissions?: { readonly network?: readonly string[] };
readonly contributes?: {
readonly functions?: readonly { readonly name: string; readonly description?: string }[];
readonly commands?: readonly { readonly id: string; readonly title: string }[];
readonly transforms?: readonly { readonly id: string; readonly title: string }[];
readonly connectors?: readonly { readonly id: string; readonly title: string }[];
};
}permissions.network entries are exact hostnames or a single leading wildcard (*.example.com — matches subdomains at any depth, but not the bare apex, and matches whole DNS labels only). contributes is display metadata for the Plugin Manager; runtime behavior is whatever activate registers.
Values across the boundary
export type PluginValue =
| number
| string
| boolean
| null
| readonly PluginValue[];Everything crossing between formulas/documents and your plugin is a PluginValue. Dates appear as ISO-8601 strings. A whole column arrives as an array. Formula error values arrive as their display string ("#EVAL!"). Returning a non-finite number or any other type from a formula function produces #EVAL! in the calling cell.
DreamSheetsPluginApi
The object passed to activate:
export interface DreamSheetsPluginApi {
readonly pluginId: string; // your manifest id
readonly apiVersion: 4;
readonly functions: { register(name, fn): void };
readonly commands: { register(command: PluginCommandSpec): void };
readonly transforms: { register(transform: PluginTransformSpec): void };
readonly connectors: { register(c: PluginConnectorSpec): void }; // api 3
readonly document: {
getSnapshot(): PluginDocSnapshot;
applyActions(actions: readonly PluginAction[]): Promise<PluginBatchResult>;
onDidChange(handler: () => void): () => void;
};
readonly query: { // api 2
update(tableId: string, sql: string): Promise<PluginOpOutcome>;
};
readonly secrets: { // api 4
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
};
readonly http: { fetch(request): Promise<PluginHttpResponse> };
readonly ui: {
showStatus(message: string, kind?: "ok" | "err" | "info"): void;
showPanel(spec: PluginPanelSpec): PluginPanelHandle; // api 2
};
}Version history
| Version | Added |
|---|---|
| 1 | functions, commands, transforms, document, http, ui.showStatus |
| 2 | ui.showPanel, query.update, and query on table snapshots |
| 3 | connectors.register — contribute a database kind |
| 4 | secrets — per-plugin values in the OS credential store |
Declare the lowest apiVersion your plugin actually needs — the app refuses to install a manifest asking for a version newer than it implements, but an older plugin keeps working on a newer host.
functions.register
register(name: string, fn: (args: readonly PluginValue[]) => PluginValue): voidRegisters a formula function callable from any formula: calc columns, summary cells, tiles. Rules:
namemust match[A-Za-z_][A-Za-z0-9_]*(stored uppercase, called case-insensitively). Invalid or duplicate names throw; built-in names can't be taken (the built-in always wins).- Resolution precedence is built-ins, then the document's custom functions, then plugin functions — a user's custom function of the same name deliberately shadows yours.
- Arguments are eagerly evaluated. If any argument for a row is an error, your function is not called for that row — the error propagates instead.
- A thrown error becomes
#EVAL!with your message; an unknown function name at evaluation time is#NAME!. - Evaluation is batched: for each column formula containing your function, the engine collects the arguments for every row and makes one host call per call-site. Nested plugin calls resolve innermost-first. A call in a not-taken
IFbranch may still be evaluated (results are just unused), so functions should be pure — no side effects per call. - Registrations are session-scoped: they vanish when the plugin unloads and are never written into the document. Documents that use the function show
#NAME!when it's absent. - One composition limit: user-defined custom-function bodies cannot call plugin functions.
commands.register
export interface PluginCommandSpec {
readonly id: string; // stable within the plugin; namespaced as plugin:<pluginId>.<id>
readonly title: string; // Tools-menu label
readonly run: () => void | Promise<void>;
}Adds an entry to the Tools menu and the Command Palette; users can bind a hotkey to it. Throws and promise rejections are caught and surfaced in the status bar as <title>: <message>.
transforms.register
export interface PluginTransformSpec {
readonly id: string;
readonly title: string; // table context-menu label
readonly run: (ctx: PluginTransformContext) => void | Promise<void>;
}
export interface PluginTransformContext {
readonly tableId: string;
readonly tableName: string;
readonly tabId: string;
readonly columns: readonly PluginColumnInfo[];
readonly rowIds: readonly string[]; // stable ids, parallel to rows
readonly rows: readonly (readonly PluginValue[])[]; // row-major, calc cells computed
applyActions(actions: readonly PluginAction[]): Promise<PluginBatchResult>;
}A table-anchored operation shown in the table's context menu. The context is a snapshot of the right-clicked table; ctx.applyActions behaves exactly like api.document.applyActions.
document.getSnapshot
export interface PluginDocSnapshot {
readonly title: string | null;
readonly tabs: readonly { readonly id: string; readonly name: string }[];
readonly tables: readonly PluginTableSnapshot[];
}
export interface PluginTableSnapshot {
readonly id: string;
readonly name: string;
readonly tabId: string;
readonly columns: readonly PluginColumnInfo[];
readonly rowIds: readonly string[];
readonly rows: readonly (readonly PluginValue[])[];
readonly query: PluginTableQuery | null; // api 2
}
export interface PluginTableQuery {
readonly sql: string;
readonly external: boolean; // reads a database, not the document
}
export interface PluginColumnInfo {
readonly id: string;
readonly name: string;
readonly isCalc: boolean; // computed (formula) column
}A plain-data snapshot of the open document. Calc cells arrive computed. rowIds are stable across sorts and edits — use them with the set_cell action.
query is non-null only for a linked query table — one that re-runs its SQL and can be rewritten with `query.update`. A static query snapshot keeps no lineage and reports null, same as an ordinary table.
document.applyActions
applyActions(actions: readonly PluginAction[]): Promise<PluginBatchResult>
export interface PluginAction {
readonly action: string;
readonly params?: unknown;
}
export type PluginBatchResult =
| { readonly kind: "ok"; readonly summaries: readonly string[] }
| { readonly kind: "error"; readonly index: number; readonly message: string };Applies a batch of actions atomically: all-or-nothing, and the whole batch is one undo step. On failure, index names the failing action. The action vocabulary is the same one the AI assistant and MCP server use, including: set_title, set_cell, set_cell_range, append_row, add_column, add_calc_column, add_table, create_table_with_data, add_tile, add_chart, add_summary_row, add_totals_row, add_tab, set_column_format, set_description, add_custom_function, create_query_table, set_column_fill.
Actions respect the document's rules — e.g. an enforced dropdown validation rejects out-of-list writes from plugins exactly as it does from paste.
document.onDidChange
onDidChange(handler: () => void): () => void // returns unsubscribeFires after every document change (yours included). A throwing handler is caught and can't break the app; unsubscribe by calling the returned function.
http.fetch
fetch(request: {
readonly url: string;
readonly method?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly body?: string;
}): Promise<PluginHttpResponse>
export interface PluginHttpResponse {
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: string;
}Network access, enforced in the native backend against the manifest's permissions.network allowlist — checked on every call against the manifest on disk. Rules: HTTPS only (plain HTTP allowed for localhost / 127.0.0.1); the plugin must be installed and enabled; 30-second timeout; 5 MB response cap (exceeding it is an error, not a truncation). A disallowed host rejects with an Error.
secrets
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>(api 4) Somewhere to keep an API key or an OAuth token between sessions without putting it in the document. Values live in the OS credential store (Windows Credential Manager, macOS Keychain, Secret Service on Linux), scoped to your plugin's id, and never in a .dsheet or a plaintext file. They survive restarts and plugin updates. Disabling the plugin keeps them; removing it deletes them.
Rules: keys are 1–128 bytes with no control characters; values are 1–1280 characters (the Windows credential store's limit, applied on every OS). get returns null for a key that was never set; delete of a missing key is fine. Every call rejects on a bad key or value, when your plugin is disabled, or when the OS credential store is unavailable (for example, a Linux box with no secret service). There is deliberately no plaintext fallback, so catch the error and tell the user.
What this does and doesn't protect: it keeps secrets out of shared documents and off the disk in plaintext, and it keeps plugins' keys from colliding. It does not isolate you from a malicious plugin. Plugins aren't sandboxed (see the trust model), so another installed plugin can ask for your plugin's secrets by your id.
The bundled Sample Secrets plugin is a small working reference.
ui.showStatus
showStatus(message: string, kind?: "ok" | "err" | "info"): voidFlashes a message in the app status bar. Default kind is "info".
ui.showPanel
showPanel(spec: PluginPanelSpec): PluginPanelHandle
export interface PluginPanelSpec {
readonly title: string;
render(el: HTMLElement, close: () => void): void | (() => void);
readonly className?: string;
}
export interface PluginPanelHandle {
close(): void;
readonly isOpen: boolean;
}(api 2) Opens a modal panel your plugin draws into. The app owns the chrome — backdrop, Esc-to-dismiss, focus trap and restore, dialog ARIA — and render gets an empty element inside it plus a close callback.
Return a cleanup function from render to tear down anything you attached outside el (timers, window listeners). el's own subtree and its listeners are discarded for you.
Style the panel with the app's CSS custom properties so it follows the user's theme automatically: --bg-card, --ink, --ink-muted, --border, --hover, --accent, --ok / --err, --sp-1…--sp-6, --fs-xs…--fs-lg, --r-sm…--r-lg, --font-mono. Only the four object accents carry a prefix: --ds-accent-{query,script,chart,table}. A misspelled variable name fails silently — an undefined custom property with no fallback voids the whole declaration.
The app shows one modal at a time. Opening another panel — or any host dialog — replaces this one, and the panel closes when your plugin is disabled, removed, or reloaded. In every case the cleanup runs and isOpen becomes false.
The modal card is a fixed 440px; widen it from your own className (.modal.my-panel { width: 560px; max-width: 92vw; }) rather than setting a min-width on the content, which just overflows the card.
query.update
update(tableId: string, sql: string): Promise<PluginOpOutcome>
export type PluginOpOutcome =
| { readonly kind: "ok" }
| { readonly kind: "error"; readonly message: string };(api 2) Rewrites a linked query table's SQL, re-runs it, and replaces the table's rows and columns in place. The table keeps its identity, placement, and links, and the whole thing is one undo step.
Check table.query !== null first — this errors on an ordinary table. Errors (invalid SQL, wrong table kind) come back as {kind: "error"}; nothing throws. Any natural-language prompt stored on the table is cleared, since it no longer describes the SQL.
To create a query table instead, use the create_query_table action through `document.applyActions`.
connectors.register
register(connector: PluginConnectorSpec): void
export interface PluginConnectorSpec {
readonly id: string; // namespaced as <pluginId>.<id>
readonly title: string; // "Snowflake"
readonly connectionPlaceholder?: string;
readonly note?: string; // shown under the field
readonly isFile?: boolean; // true = a path, not a secret
query(conn: string, sql: string): Promise<PluginQueryResult>;
schema(conn: string): Promise<readonly PluginSchemaTable[]>;
}
export interface PluginQueryResult {
readonly columns: readonly string[];
readonly rows: readonly (readonly PluginValue[])[]; // ROW-major
}
export interface PluginSchemaTable {
readonly name: string;
readonly columns: readonly string[];
}(api 3) Contributes a database kind. This is not a side channel for loading data — the kind you register is a peer of the built-in engines:
- it appears in File → Database Connections… with your title, placeholder and note;
- its connection string is stored in the app's
db-connections.json, never in a `.dsheet` — a shared document references a connection by id only; - Test in that dialog calls your
schema(), and the result also feeds the query editor's autocomplete; - a table imported from it is a real linked query table: Refresh query, Edit query…, canvas lineage, and the 100,000-row cap all work as they do for PostgreSQL.
You do not type the result. Return column names and raw values; the app runs them through the same inference every built-in engine's result goes through. Whole numbers become BIGINT columns, fractional ones DOUBLE, and date-looking text (2026-01-05, 2026-01-05T12:00:00) is promoted to a real DATE/TIMESTAMP column with a date format. Values the row model can't hold (nested objects, arrays) arrive as text; non-finite numbers as empty cells.
Throw from query() to fail the import with your message — the document is left untouched, with no history entry.
A warehouse connector is `http.fetch` inside query():
api.connectors.register({
id: "snowflake",
title: "Snowflake",
connectionPlaceholder: "account=xy12345;warehouse=WH;db=SALES;token=…",
note: "Uses the Snowflake SQL API over HTTPS. Connect as a read-only role.",
async query(conn, sql) {
const { account, token } = parse(conn);
const res = await api.http.fetch({
url: `https://${account}.snowflakecomputing.com/api/v2/statements`,
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ statement: sql }),
});
if (res.status !== 200) throw new Error(`Snowflake said ${res.status}`);
const body = JSON.parse(res.body);
return {
columns: body.resultSetMetaData.rowType.map((c) => c.name),
rows: body.data,
};
},
async schema(conn) { /* SHOW COLUMNS, grouped by table */ },
});Add every host you contact to permissions.network — outbound requests are allowlisted and enforced in the backend, not here.
Registrations are session-scoped, saved connections are not. A connection outlives the plugin that provided it, so when the plugin is disabled, removed, or fails to activate, that connection reports "no plugin is providing the `<key>` connector" and its tables keep their last data. Keep your connector id stable across versions — <pluginId>.<id> is the only link between a saved connection and the code that can refresh it.
The bundled Sample Connector plugin is a complete working reference (Tools → Plugin Manager → Browse Plugins).
Lifecycle summary
| Event | What happens |
|---|---|
| App start | Every installed, enabled, error-free plugin is loaded and activated |
| Enable toggle | Loads / unloads live — no restart |
| ↻ Reload (dev installs) | Unload, re-read from the dev folder, re-activate |
activate throws | Full rollback of that plugin's registrations; error shown; app unaffected |
| Unload / remove | deactivate?() best-effort, then all registrations (functions, commands, transforms, connectors, subscriptions) are unregistered by the host, and any open showPanel panel is closed |
| Remove | Also deletes every secrets entry the plugin stored (if the credential store can't be reached, the remove is refused rather than leaving them behind) |
Environment constraints
- The module loads as a Blob-URL ES module under the app's strict CSP — which is why it must be a single self-contained bundle (no relative imports, no CDN scripts).
- Plugin code runs in the app's UI process with full document access; the network allowlist is the only hard sandbox boundary. See the trust model.
- Columns computed by plugin functions currently read as NULL inside query-table SQL (a known v1 limitation).