Skip to main content

Core Concepts

This page explains the foundational concepts behind the extension platform. Understanding these will help you write extensions that are reliable, secure, and performant.

tip

If you have not read How Extensions Run yet, start there. It covers the runtime your code actually executes in -- which globals exist, what does not, and the resource limits. This page builds on it.

Extension lifecycle

Every extension moves through a well-defined sequence of states:

install → enable → activate → deactivate → disable → uninstall
▲ │
└────────────┘ (repeated per session)
StateWhat happens
InstallThe host validates the manifest, copies the extension to data/extensions/<id>/, and prompts the user to approve permissions.
EnableThe extension is marked as active in the user's settings. Happens automatically after install unless the user defers.
ActivateThe host spawns a utility process, loads the entry point, and calls exports.activate(api). This happens when an activation event fires (e.g., onStartup, onCommand:<id>, onLanguage:<lang>).
DeactivateThe host calls exports.deactivate() and waits up to 5 seconds for the promise to resolve. Then the utility process is terminated. Happens on app shutdown or when the user disables the extension.
DisableThe extension remains installed but will not activate. All registered contributions (commands, panels, decorators) are removed from the UI.
UninstallThe host removes the extension directory, its storage, and its database files.
warning

Always clean up in deactivate(). Dispose event subscriptions, status bar items, and any other registered handles. The host will forcibly terminate the process after the timeout, but leaked handles may cause warnings in the log.

Activation events

Your manifest's activationEvents array controls when the host loads your extension. Common values:

EventWhen it fires
onStartupApp launch (after the main window is ready)
onCommand:<commandId>The user or another extension executes the specified command
onLanguage:<bcp47>The user switches the app to the specified locale
onView:<panelTypeId>A panel of the specified type is opened
*Any activation event (use sparingly -- delays startup)

If no activation events match, the extension stays dormant until one fires. This means an extension with "activationEvents": ["onCommand:ext.my-ext.doThing"] uses zero resources until the user actually triggers that command.

Process model

Each extension gets its own Electron utility process, and your code runs inside a QuickJS realm within it. How Extensions Run covers this in full; the properties that matter for the rest of this page are:

  • Crash isolation: An unhandled exception, a memory blow-up, or a hang is contained to one extension's process. The host logs it and may offer to restart the extension. Nothing reaches the app or another extension.
  • No shared state: Extensions cannot access each other's memory or storage. Inter-extension communication goes through api.extensions.call(), which the host mediates.
  • No ambient authority: The realm has no filesystem, no network, no require, no process. Everything your extension can do arrives through the api object.
  • Bounded resources: A single guest turn is interrupted after 5 seconds, realm memory growth is capped, and live timers are limited. See Resource limits.

If you need a library, bundle it into your entry point -- there is no module loading at runtime.

Permissions

The extension platform uses granular, declare-ahead permissions. Extensions must list every capability they need in the manifest's permissions array. The user sees these at install time and can reject the extension.

Permission categories

CategoryPermissionsWhat they gate
Content readbible:read, commentary:read, dictionary:read, book:readReading installed module data
User datanotes:read, notes:write, highlights:read, highlights:write, bookmarks:read, bookmarks:writeAccessing or modifying the user's study data
Content providebible:provide, commentary:provide, dictionary:provide, book:provide, search:provide, display-mode:provide, import:provide, ai:provide, tts:provideRegistering as a content provider
UIui:contribute-pane, ui:verse-decorator, ui:verse-hover, ui:context-menu, ui:notification, ui:status-bar, ui:mediaAdding UI elements
Storagestorage, storage:secrets, storage:databasePersisting extension data
Commandscommands:registerAdding commands to the palette
TaskstasksRunning background tasks with progress
Networknetwork, network:oauthOutbound HTTP and OAuth flows
Extensionsextensions:callCalling other extensions' exported APIs
File systemfs:read-user, fs:write-userFile picker and save dialogs

Runtime enforcement

Permissions are not just informational. Every RPC call passes through the Permission Guard before reaching the API implementation. If an extension calls api.notes.create(...) without notes:write in its manifest, the call rejects with a PermissionDeniedError.

// This will throw if "notes:write" is not in your manifest's permissions array
const note = await api.notes.create({
verseId: 43003016,
content: 'An interesting observation...',
type: 'study',
});

The check runs in the main process, not in your extension's process, and the api object is a proxy that holds no capability of its own. That is a deliberate structural choice rather than a defensive one: there is nothing on your side of the boundary to tamper with, so a compromised or hostile extension gains nothing by rewriting its own api object. It can only send messages the host is willing to answer.

Network is gated twice

The network permission grants the ability to ask. It does not grant a destination. Every request must also match an entry in the manifest's network.allowedHosts, and a host that is not listed rejects with NetworkHostNotAllowedError even when the permission is granted. See Network access for the full chain, including offline mode and private-network blocking.

tip

Request only the permissions you actually need. Users are more likely to install extensions with a small permission footprint. You can always add permissions in a later version -- the host will prompt the user to approve the new ones on update.

RPC protocol

Extension code talks to the host via a structured RPC protocol over a MessagePort. You never need to work with the protocol directly -- the api object is an RPC proxy that handles serialization and deserialization. But understanding the model helps when debugging.

Worker Host
│ │
│ RpcRequest { method, args } │
├──────────────────────────────▶│
│ │── permission check
│ │── dispatch to API impl
│ RpcResponse { result } │
│◀──────────────────────────────┤
│ │
│ RpcEvent { channel, data } │ (host → worker, for subscriptions)
│◀──────────────────────────────┤

Constraints:

  • All arguments and return values must be JSON-serializable. No functions, class instances, Date objects, or circular references.
  • Each RPC call has a timeout (default 30 seconds). If the host does not respond in time, the promise rejects with RpcTimeoutError.
  • Event subscriptions use a reverse channel: the host pushes RpcEvent messages to the worker when the subscribed event fires.

:::info How a disposable survives a boundary that cannot carry functions A DisposableHandle contains a function, and functions cannot cross the RPC boundary -- so the host never sends one. It keeps the real disposer, sends you an id, and the proxy on your side turns that id back into a handle whose dispose() calls home.

The result is that every method declared Promise<DisposableHandle> gives you exactly that, and await handle.dispose() works the same for a registerPanelType as for an event subscription:

const panel = await api.ui.registerPanelType({ /* … */ });
const sub = await api.bible.onDidChangeActiveVerse.subscribe(onVerse);

exports.deactivate = async () => {
await panel.dispose();
await sub.dispose();
};

Registrations that have a natural identifier keep it as an extra property -- registerProvider results still carry providerId, for instance -- so you can both reference and dispose them.

Disposing twice is safe and does nothing the second time. :::

UI panels

Extensions can contribute custom panels that appear in the app's pane system alongside built-in panels like Commentary and Notes.

How panels work

  1. The extension declares a panel type in its manifest under contributes.panelTypes.
  2. When the user opens the panel, the host creates an iframe loaded via the ext-ui://<extensionId>/ custom protocol.
  3. The iframe is sandboxed: no top or parent access, no navigation outside the custom protocol, strict CSP headers.
  4. The panel communicates with the extension worker through a message bridge (not direct RPC).
{
"contributes": {
"panelTypes": [
{
"id": "myPanel",
"title": "My Panel",
"uiEntry": "ui/panel.html",
"defaultBucket": "right"
}
]
}
}

The uiEntry path is relative to the extension directory. The host serves all files in the extension directory via the ext-ui:// protocol.

See Building Extension UI for a complete guide on creating panel UIs.

Events and subscriptions

The API exposes events through a consistent pattern. Each event property has a subscribe method that returns a DisposableHandle:

// Namespace-specific events
const handle = await api.bible.onDidChangeActiveVerse.subscribe((payload) => {
console.log('Active verse:', payload.verseId);
});

// Generic extension point events (30+ channels)
const handle2 = await api.events.subscribe('verse.hover', (payload) => {
// Return hover content for a verse
});

// Always dispose when done
await handle.dispose();
await handle2.dispose();

Event guarantees:

  • Each subscriber handler has a 2-second timeout. If your handler takes longer, the host disposes it and logs a warning.
  • A throwing handler is caught and logged -- it never blocks the host or other subscribers.
  • Events are delivered in subscription order but there is no guaranteed ordering across extensions.

The full list of event channels is available in the Events API Reference.

Storage

Extensions have three tiers of persistent storage, each requiring a separate permission:

Key-value store (storage permission)

A simple string-keyed store backed by SQLite. Good for settings, cached state, and small data.

await api.storage.set('lastSync', Date.now());
const lastSync = await api.storage.get('lastSync');
const allKeys = await api.storage.keys();
await api.storage.delete('lastSync');
  • Quota: 5 MB per extension (configurable by the user).
  • Keys are automatically namespaced -- extensions cannot see each other's data.

Secrets (storage:secrets permission)

Sensitive values stored in the OS keychain (via keytar). Use this for API tokens, passwords, and credentials.

await api.storage.setSecret('apiToken', 'sk-abc123...');
const token = await api.storage.getSecret('apiToken');
await api.storage.deleteSecret('apiToken');

Per-extension SQLite database (storage:database permission)

For extensions that need to store large datasets (search indexes, embedding vectors, custom corpora), a full SQLite database is available:

const db = await api.storage.openDatabase('my-index');
await db.execute('CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, data TEXT)');
await db.execute('INSERT INTO entries VALUES (?, ?)', ['key1', 'value1']);
const rows = await db.queryAll('SELECT * FROM entries WHERE id = ?', ['key1']);
  • The database file lives at data/extensions/<id>/db/<name>.db.
  • WAL mode and foreign keys are enabled by default.
  • No hard quota -- the Extensions UI shows per-extension disk usage so users can manage space.

User settings

If your extension declares contributes.configuration in the manifest, users can configure it through the Settings UI. Read those values with:

const fontSize = await api.storage.getSetting('fontSize');

// React to changes
await api.storage.onDidChangeSettings.subscribe(({ keys }) => {
if (keys.includes('fontSize')) {
// Re-read and apply
}
});