LumaBrowser's extension system lets you add features to the browser without modifying core code. Extensions are self-contained directories under extensions/ with a manifest, a main process entry point, and optional routes, MCP tools, and UI panels.
Extensions receive a context object with injected services — browser automation, LLM, templates, database — so you never need to know about ports, URLs, or the REST API internals. Declare what you need in your manifest, and the extension manager wires it up for you.
context.expose() to create REST and MCP endpoints in a single call. You can always add manual routes or MCP tools later for advanced use cases.
Each extension lives in its own directory under extensions/. Only manifest.js is required — everything else is optional.
extensions/
my-extension/
manifest.js # Required: declares metadata and dependencies
main.js # Main process entry point (activate/deactivate)
routes.js # Optional: Express routes for REST API
mcp-tools.js # Optional: MCP tool definitions
renderer.js # Optional: renderer process UI code
panel.html # Optional: UI panel
settings.html # Optional: settings page
| File | Purpose |
|---|---|
manifest.js | Declares extension ID, name, version, dependencies, and feature registrations. The extension manager reads this first. |
main.js | Exports activate(context) and optionally deactivate(). Called during startup. Return a public API object to share with other extensions. |
routes.js | Exports a factory function that receives (context, extensionApi) and returns an Express router. Mounted at the prefix declared in the manifest. |
mcp-tools.js | Exports a tools array and a handler(toolName, args) function for manual MCP tool definitions. |
renderer.js | Loaded in the renderer process. Has access to window.electron for IPC calls. |
panel.html | HTML panel injected into the browser sidebar or main UI area. |
settings.html | Settings page shown in the extension preferences UI. |
The manifest is a CommonJS module that exports a plain object. It declares everything the extension manager needs to load and wire up your extension.
// manifest.js
module.exports = {
id: 'my-extension',
name: 'My Extension',
version: '1.0.0',
description: 'A helpful extension that does great things.',
// Hide from public listings; only visible to you
private: false,
// Only load when the app is running in dev mode
debugOnly: false,
dependencies: {
required: [
'core:browser',
{ service: 'core:database', tables: ['my_data', 'my_cache'] },
{ service: 'core:llm-service', slots: ['default'] }
],
optional: [
'core:template',
'core:license',
'ext:template-builder'
]
},
// Main process entry point
main: 'main.js',
// Renderer process script (loaded in browser window)
renderer: 'renderer.js',
// REST API routes
routes: {
file: 'routes.js',
prefix: '/api/ext/my-extension'
},
// MCP tool definitions
mcpTools: {
file: 'mcp-tools.js'
},
// UI panel registration
ui: {
panel: 'panel.html'
},
// Add items to the browser navigation bar
navigationBar: [
{ label: 'My Panel', icon: 'grid', action: 'openPanel' }
],
// Settings page
settings: 'settings.html',
// Lower numbers load first (default: 100)
loadPriority: 50
};
Manifest Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique extension identifier. Used as namespace for IPC, routes, database, and MCP tools. |
name | string | Yes | Human-readable display name. |
version | string | Yes | Semantic version string. |
description | string | No | Short description shown in the extension manager UI. |
private | boolean | No | If true, the extension is hidden from public listings and cannot be exported. |
debugOnly | boolean | No | If true, the extension only loads in development mode. |
dependencies | object | No | Declares required and optional services. Required deps block loading if unavailable. |
main | string | No | Path to main process entry point, relative to extension directory. |
renderer | string | No | Path to renderer process script. |
routes | object | No | REST route configuration: file and prefix. |
mcpTools | object | No | MCP tool file reference. |
ui | object | No | UI panel registration. |
navigationBar | array | No | Navigation bar entries with label, icon, and action. |
settings | string | No | Path to settings page HTML. |
loadPriority | number | No | Load order weight. Lower values load first. Default: 100. |
Dependency Formats
Dependencies can be declared as plain strings or as objects with additional configuration:
| Dependency | Format | Description |
|---|---|---|
core:browser | string | Access to the BrowserService for tab management, navigation, and automation. |
core:database | object | Requires tables array listing table names you will create and use. |
core:llm-service | object | Requires slots array listing named LLM configuration slots. |
core:template | string | Access to the TemplateService for page template lookup and management. |
core:license | string | Access to the LicenseService for checking the user's license tier. |
ext:<id> | string | Depend on another extension's public API. Use in optional when possible. |
The context object is passed to your activate() function. It contains all the services and utilities your extension declared as dependencies, plus framework-level tools that are always available.
| Property | Type | Description |
|---|---|---|
extensionId | string | Your extension's unique ID, as declared in the manifest. |
extensionDir | string | Absolute path to your extension's directory on disk. |
browser | BrowserService | Tab management, navigation, clicks, screenshots, JavaScript execution. Only available if core:browser is declared. |
llm | LLMService | Send LLM completions via configured slots. Only available if core:llm-service is declared. |
template | TemplateService | Look up and manage page templates. Only available if core:template is declared. |
license | LicenseService | Check the user's license tier and entitlements. Only available if core:license is declared. |
db | DatabaseService | Namespaced key-value store plus SQL access for declared tables. Only available if core:database is declared. |
sharedServices | object | All core services with unrestricted access. Use sparingly — prefer explicit dependencies. |
ipc | IPCBridge | Register IPC handlers for communication between the main and renderer processes. |
extensions | object | Public APIs returned by loaded extension dependencies. |
events | EventBus | Simple publish/subscribe event bus for cross-extension communication. |
expose | function | Register functions as both REST and MCP endpoints in a single call. |
browser or db) will be undefined if you forget to list them in your manifest. Always check your dependencies block first if a service is missing.
The simplest way to add REST and MCP endpoints. A single call registers both a REST route and an MCP tool from one function.
Signature
context.expose(name, handler, options)
| Parameter | Type | Description |
|---|---|---|
name | string | Endpoint name. Used in the route path and MCP tool name. |
handler | async function | Receives a params object, returns data. No need to format responses. |
options.targets | string[] | Where to register: 'api', 'mcp', or both. Default: ['api', 'mcp']. |
options.description | string | Human-readable description. Used in MCP tool listing and API docs. |
options.method | string | HTTP method: 'get', 'post', 'put', 'delete'. Default: 'post'. |
options.params | object | Parameter schema. Each key maps to { type, required, description, default }. |
Example
context.expose('searchBooks', async ({ query, limit }) => {
const results = await context.browser.executeJs(tabId, `...`);
return { books: results, count: results.length };
}, {
targets: ['api', 'mcp'],
description: 'Search for books by title',
method: 'post',
params: {
query: { type: 'string', required: true, description: 'Search query' },
limit: { type: 'number', description: 'Max results', default: 10 }
}
});
What Gets Created
From the example above, two endpoints are automatically registered:
| Target | Endpoint |
|---|---|
| REST | POST /api/ext/{extensionId}/searchBooks |
| MCP | Tool named {extensionId}_searchBooks |
Your handler function just returns data. Response wrapping is automatic:
- REST response:
{ success: true, data: { books: [...], count: 5 } } - MCP response:
{ content: [{ type: 'text', text: '{"success":true,"data":{...}}' }] }
expose() must be called synchronously during activate(). The endpoint registry is sealed after activation completes, and any later calls will throw an error.
expose() is the recommended way to add endpoints. Use manual routes.js and mcp-tools.js only when you need Express middleware, streaming responses, or custom MCP response formats.
When your extension depends on another extension (declared as ext:other-id in the manifest), the dependent extension's public API is available on the context object.
You can access it via the full path or a convenient shorthand:
// Full path
const api = context.extensions['template-builder'];
// Shorthand (equivalent)
const api = context['template-builder'];
// Use the dependency's public API
const result = await api.generateForTab(tabId, url);
context['name'] only works for extension IDs that do not collide with built-in context properties like browser, db, llm, ipc, events, expose, extensionId, extensionDir, sharedServices, extensions, template, or license. If there is a conflict, use context.extensions['name'] instead.
The database service provides a namespaced key-value store and direct SQL access. Declare core:database with a tables array in your manifest to use SQL.
Key-Value Store
Key-value operations are automatically namespaced to ext.{id}.*, so extensions cannot read or overwrite each other's data.
// Set a value (auto-namespaced to ext.my-extension.lastRun)
context.db.set('lastRun', Date.now());
// Get a value
const last = context.db.get('lastRun');
// Delete a value
context.db.delete('lastRun');
SQL Access
You can only create and query tables that are declared in your manifest's dependencies.required block.
// Create table (must be declared in manifest tables array)
context.db.run(
'CREATE TABLE IF NOT EXISTS my_data (id TEXT PRIMARY KEY, value TEXT)'
);
// Insert data
context.db.run(
'INSERT OR REPLACE INTO my_data (id, value) VALUES (?, ?)',
['key1', JSON.stringify({ hello: 'world' })]
);
// Query data
const rows = context.db.query('SELECT * FROM my_data');
const single = context.db.query('SELECT * FROM my_data WHERE id = ?', ['key1']);
IPC handlers bridge the main process and the renderer. Handlers are automatically namespaced with your extension ID.
Main Process (main.js)
context.ipc.handle('getData', async (event, args) => {
const items = context.db.query('SELECT * FROM my_data');
return { items };
});
context.ipc.handle('saveItem', async (event, { id, value }) => {
context.db.run('INSERT OR REPLACE INTO my_data (id, value) VALUES (?, ?)', [id, value]);
return { success: true };
});
Renderer Process (renderer.js or panel.html)
// Call your extension's IPC handler
const result = await window.electron.invoke('ext.my-extension.getData');
console.log(result.items);
// With arguments
await window.electron.invoke('ext.my-extension.saveItem', {
id: 'item-1',
value: 'Hello'
});
The event bus provides simple publish/subscribe messaging between extensions. Events are global — any extension can listen to any event.
// Subscribe to an event
context.events.on('taskCompleted', (data) => {
console.log(`Task ${data.taskId} finished with status: ${data.status}`);
});
// Emit an event (other extensions can listen)
context.events.emit('taskCompleted', {
taskId: '123',
status: 'success'
});
// Unsubscribe
const handler = (data) => { ... };
context.events.on('myEvent', handler);
context.events.off('myEvent', handler);
For advanced use cases that require Express middleware, streaming, or custom response handling, define routes in a separate routes.js file.
// routes.js
const { Router } = require('express');
module.exports = function createRoutes(context, extensionApi) {
const router = Router();
router.get('/status', (req, res) => {
res.json({ success: true, data: { running: true, version: '1.0.0' } });
});
router.post('/process', async (req, res) => {
try {
const { url } = req.body;
const tabId = await context.browser.createTab(url);
const screenshot = await context.browser.screenshot(tabId);
res.json({ success: true, data: { tabId, screenshot } });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// Streaming response example
router.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
// ... stream data
});
return router;
};
The router is mounted at the prefix declared in your manifest (e.g., /api/ext/my-extension). The factory function receives the same context object and the public API returned by your activate() function.
For custom MCP response formats or complex tool definitions, use a mcp-tools.js file instead of context.expose().
// mcp-tools.js
module.exports = {
tools: [
{
name: 'my-extension_analyzeImage',
description: 'Analyze an image from a browser tab',
inputSchema: {
type: 'object',
properties: {
tabId: { type: 'string', description: 'ID of the tab to capture' },
prompt: { type: 'string', description: 'Analysis prompt' }
},
required: ['tabId']
}
}
],
handler: async (toolName, args, context) => {
if (toolName === 'my-extension_analyzeImage') {
const screenshot = await context.browser.screenshot(args.tabId);
const analysis = await context.llm.complete({
prompt: args.prompt || 'Describe this image',
images: [screenshot]
});
return {
content: [
{ type: 'text', text: JSON.stringify({ success: true, data: analysis }) },
{ type: 'image', data: screenshot, mimeType: 'image/png' }
]
};
}
}
};
{ content: [{ type: 'text', text: '...' }] }. Prefer context.expose() which handles this automatically.
When LumaBrowser starts, the extension manager loads extensions in loadPriority order and calls each one's activate() function. The full lifecycle is:
- Load manifest — validate dependencies and check availability.
- Build context — inject requested services based on declared dependencies.
- Call
activate(context)— your entry point. Register IPC handlers, callexpose(), set up event listeners. - Capture return value — whatever you return becomes your extension's public API, available to dependents via
context.extensions. - Register routes — if
routes.jsis declared, the factory is called and the router is mounted. - Register MCP tools — if
mcp-tools.jsis declared, tools are registered with the MCP aggregator. - Seal
expose()— no more endpoints can be registered after this point.
// main.js
async function activate(context) {
// Set up IPC, events, expose()
context.ipc.handle('ping', async () => ({ pong: true }));
context.expose('hello', async ({ name }) => {
return { message: `Hello, ${name}!` };
}, {
description: 'Say hello',
params: { name: { type: 'string', required: true } }
});
// Return public API for other extensions
return {
greet: (name) => `Hello, ${name}!`
};
}
async function deactivate() {
// Clean up timers, connections, etc.
}
module.exports = { activate, deactivate };
When an extension is deactivated (app shutdown, user disables it, or an error occurs), the following happens in order:
deactivate()called — clean up timers, intervals, open connections, or external resources.- IPC handlers removed — all handlers registered via
context.ipc.handle()are automatically unregistered. - MCP tools unregistered — both manual and
expose()-based tools are removed from the aggregator. - REST routes blocked — requests to the extension's route prefix return
404.
deactivate() if you have resources the framework does not manage, such as setInterval timers, WebSocket connections, or file watchers.
Users can enable and disable extensions at runtime through the extension manager UI. Disabling an extension triggers the full deactivation sequence. Re-enabling it triggers a fresh activation — your activate() function is called again with a new context.
Persisted data (database keys and tables) survives disable/enable cycles. Anything stored only in memory will be lost.
The simplest possible extension: a manifest and a main entry point that exposes one function.
manifest.js
module.exports = {
id: 'hello-world',
name: 'Hello World',
version: '1.0.0',
description: 'A minimal extension example.',
main: 'main.js'
};
main.js
async function activate(context) {
context.expose('greet', async ({ name }) => {
return { message: `Hello, ${name || 'World'}!` };
}, {
description: 'Return a greeting message',
params: {
name: { type: 'string', description: 'Name to greet', default: 'World' }
}
});
}
module.exports = { activate };
This creates:
POST /api/ext/hello-world/greet— REST endpointhello-world_greet— MCP tool
A more complete extension that uses database, browser automation, template services, and exposes multiple endpoints.
manifest.js
module.exports = {
id: 'page-analyzer',
name: 'Page Analyzer',
version: '2.1.0',
description: 'Analyze web pages and store results.',
dependencies: {
required: [
'core:browser',
{ service: 'core:database', tables: ['analysis_results'] },
],
optional: [
'core:template',
'ext:template-builder'
]
},
main: 'main.js',
loadPriority: 80
};
main.js
async function activate(context) {
// Initialize database table
context.db.run(`
CREATE TABLE IF NOT EXISTS analysis_results (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT,
result TEXT,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
)
`);
// Expose: analyze a page
context.expose('analyzePage', async ({ tabId, url }) => {
// Create a tab if only URL provided
if (!tabId && url) {
tabId = await context.browser.createTab(url);
await context.browser.waitForLoad(tabId);
}
const title = await context.browser.executeJs(tabId, 'document.title');
const text = await context.browser.executeJs(tabId,
'document.body.innerText.slice(0, 5000)'
);
// Check if template-builder is available (optional dep)
let templateMatch = null;
const templateBuilder = context['template-builder'];
if (templateBuilder) {
templateMatch = await templateBuilder.generateForTab(tabId, url);
}
const id = `analysis-${Date.now()}`;
const result = { title, textLength: text.length, templateMatch };
context.db.run(
'INSERT INTO analysis_results (id, url, title, result) VALUES (?, ?, ?, ?)',
[id, url, title, JSON.stringify(result)]
);
return { id, ...result };
}, {
description: 'Analyze a web page and store the results',
method: 'post',
params: {
tabId: { type: 'string', description: 'Existing tab ID to analyze' },
url: { type: 'string', description: 'URL to open and analyze' }
}
});
// Expose: get past results
context.expose('getResults', async ({ limit }) => {
const rows = context.db.query(
'SELECT * FROM analysis_results ORDER BY created_at DESC LIMIT ?',
[limit || 20]
);
return { results: rows, count: rows.length };
}, {
description: 'Retrieve stored analysis results',
method: 'get',
params: {
limit: { type: 'number', description: 'Max results to return', default: 20 }
}
});
// IPC for renderer UI
context.ipc.handle('getRecentAnalyses', async () => {
return context.db.query(
'SELECT id, url, title, created_at FROM analysis_results ORDER BY created_at DESC LIMIT 10'
);
});
// Return public API for dependent extensions
return {
analyze: (tabId) => context.browser.executeJs(tabId, 'document.title'),
getResultCount: () => {
const row = context.db.query('SELECT COUNT(*) as cnt FROM analysis_results');
return row[0]?.cnt || 0;
}
};
}
async function deactivate() {
// No manual cleanup needed — IPC and routes are handled automatically
}
module.exports = { activate, deactivate };