Skip to main content

Quick Start: Build Your First Extension

This tutorial walks you through creating a minimal extension that listens for verse changes and shows a notification. By the end you will have a working extension sideloaded into the app.

:::tip Shortcut You can scaffold a complete project -- manifest, TypeScript source, build configuration, and tests -- with:

npx @bible/create-extension my-extension

That is the recommended starting point for anything real. The steps below build the same thing by hand so you can see what each piece does. :::

Step 1: Create the extension directory

Create a folder for your extension with a manifest file:

my-extension/
extension.json
src/
main.js

:::info One file, no imports This tutorial gets away with a hand-written src/main.js because it is a single file that imports nothing. The host evaluates exactly the one file main points at -- there is no module loading at runtime, so require() and import both fail inside the sandbox.

As soon as you want a second source file or an npm dependency, you need a bundler. See Packaging and Build; the scaffold sets this up for you. :::

extension.json

The manifest declares your extension's identity, required permissions, and activation triggers:

{
"id": "ext.my-publisher.hello-verse",
"name": "Hello Verse",
"version": "1.0.0",
"publisher": "my-publisher",
"description": "Shows a notification when the active verse changes.",
"engines": {
"bibleApp": "^1.0.0"
},
"main": "src/main.js",
"permissions": [
"bible:read",
"ui:notification"
],
"activationEvents": [
"onStartup"
]
}

Key fields:

  • id -- Must follow the pattern ext.<publisher>.<name> using kebab-case.
  • engines.bibleApp -- The API version your extension is compatible with (semver range).
  • main -- Entry point relative to the extension root.
  • permissions -- API capabilities your extension needs. The user sees these at install time.
  • activationEvents -- When the host should load your extension. onStartup means it activates immediately when the app launches.

See the Manifest Reference for the complete schema.

Step 2: Write the entry point

Your entry point must export two functions: activate and deactivate.

src/main.js

'use strict';

let eventHandle = null;

/**
* Called by the host when the extension is activated.
* @param {import('@bible/core/Extensions/ExtensionApiTypes').BibleExtensionAPI} api
*/
exports.activate = async function activate(api) {
// Subscribe to verse navigation events
eventHandle = await api.bible.onDidChangeActiveVerse.subscribe(
async (payload) => {
if (!payload) return;

// Fetch the verse text
const verse = await api.bible.getVerse(payload.verseId);

// Show a notification with the verse reference
await api.ui.showNotification(
`Now reading: ${verse.reference}`,
{ type: 'info', duration: 3000 }
);
}
);
};

/**
* Called by the host when the extension is deactivated.
* Always clean up subscriptions and registered contributions.
*/
exports.deactivate = async function deactivate() {
if (eventHandle) {
await eventHandle.dispose();
eventHandle = null;
}
};

Important patterns to note:

  • All API methods are async -- always use await.
  • Event subscriptions return a DisposableHandle. Call dispose() in deactivate() to clean up.
  • The api object is an RPC proxy. Arguments and return values must be JSON-serializable.
  • api is all you get. There is no require, no process, no fetch, no filesystem -- see How Extensions Run.

Step 3: Load it into the app

You do not need to publish anything, sign anything, or go through a marketplace to run your own extension. There are two ways in.

Runs the extension in place from your project folder, and reloads it when you rebuild.

  1. Open the app and run Open Preferences, then choose the Extensions section.
  2. Tick Developer Mode.
  3. Click Load unpacked extension… and select your my-extension/ folder.
  4. Approve the permissions the manifest requests.
  5. Enable the extension in the list. It shows as Untrusted and Unpacked, which is correct -- your build is not signed.

Nothing is copied. The app reads your folder directly, so npm run build is picked up automatically. There is also a Reload button if you want to force it.

Install a copy (for distributing to someone else)

Install from folder… and Install from .zip… copy the package into the app's own extensions directory. Use these when you are handing the extension to someone, or when you have stopped changing it -- a copy does not track your rebuilds.

:::info Permissions are not re-prompted on reload If you add a permission to extension.json and rebuild, the reload picks up the new manifest but not the new permission -- you keep exactly what you approved. Remove and re-load the extension to grant it. This is deliberate: otherwise editing a manifest would be a way to widen access without a dialog. :::

Step 4: See it work

Navigate to any Bible passage. Each time the active verse changes, you should see a notification toast showing the verse reference.

Open DevTools (View > Toggle Developer Tools or Ctrl+Shift+I) to see any console.log or console.error output from your extension's utility process.

A complete example: Word Count

The repository includes a reference extension that demonstrates more API features:

examples/extensions/word-count/
extension.json
src/main.js

The Word Count extension:

  • Registers a status bar item that shows the word count for the current chapter
  • Subscribes to onDidChangeActiveVerse to detect navigation
  • Uses api.bible.getRange() to fetch all verses in a chapter
  • Counts words and updates the status bar in real time

Here is the core of its activate function:

exports.activate = async function activate(api) {
// Register a status bar item
statusBarHandle = await api.ui.registerStatusBarItem({
id: 'ext.bible-app.word-count.display',
text: 'Words: --',
tooltip: 'Word count for the current chapter',
alignment: 'right',
priority: 100,
});

// Update the count whenever the user navigates
eventHandle = await api.bible.onDidChangeActiveVerse.subscribe(
async (payload) => {
if (!payload) return;

const { book, chapter } = parseVerseId(payload.verseId);
const startId = book * 1000000 + chapter * 1000 + 1;
const endId = book * 1000000 + chapter * 1000 + 200;

const verses = await api.bible.getRange(startId, endId);
let totalWords = 0;
for (const v of verses) {
totalWords += countWords(v.textPlain || stripHtml(v.text));
}

await api.ui.registerStatusBarItem({
id: 'ext.bible-app.word-count.display',
text: 'Words: ' + totalWords,
tooltip: `Chapter ${chapter} (${verses.length} verses)`,
alignment: 'right',
priority: 100,
});
}
);
};

Browse the full source at examples/extensions/word-count/.

Next steps