Overview

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.

Tip: Start with 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.
File Structure

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
FilePurpose
manifest.jsDeclares extension ID, name, version, dependencies, and feature registrations. The extension manager reads this first.
main.jsExports activate(context) and optionally deactivate(). Called during startup. Return a public API object to share with other extensions.
routes.jsExports a factory function that receives (context, extensionApi) and returns an Express router. Mounted at the prefix declared in the manifest.
mcp-tools.jsExports a tools array and a handler(toolName, args) function for manual MCP tool definitions.
renderer.jsLoaded in the renderer process. Has access to window.electron for IPC calls.
panel.htmlHTML panel injected into the browser sidebar or main UI area.
settings.htmlSettings page shown in the extension preferences UI.
Manifest

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

FieldTypeRequiredDescription
idstringYesUnique extension identifier. Used as namespace for IPC, routes, database, and MCP tools.
namestringYesHuman-readable display name.
versionstringYesSemantic version string.
descriptionstringNoShort description shown in the extension manager UI.
privatebooleanNoIf true, the extension is hidden from public listings and cannot be exported.
debugOnlybooleanNoIf true, the extension only loads in development mode.
dependenciesobjectNoDeclares required and optional services. Required deps block loading if unavailable.
mainstringNoPath to main process entry point, relative to extension directory.
rendererstringNoPath to renderer process script.
routesobjectNoREST route configuration: file and prefix.
mcpToolsobjectNoMCP tool file reference.
uiobjectNoUI panel registration.
navigationBararrayNoNavigation bar entries with label, icon, and action.
settingsstringNoPath to settings page HTML.
loadPrioritynumberNoLoad order weight. Lower values load first. Default: 100.

Dependency Formats

Dependencies can be declared as plain strings or as objects with additional configuration:

DependencyFormatDescription
core:browserstringAccess to the BrowserService for tab management, navigation, and automation.
core:databaseobjectRequires tables array listing table names you will create and use.
core:llm-serviceobjectRequires slots array listing named LLM configuration slots.
core:templatestringAccess to the TemplateService for page template lookup and management.
core:licensestringAccess to the LicenseService for checking the user's license tier.
ext:<id>stringDepend on another extension's public API. Use in optional when possible.
Core Services

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.

PropertyTypeDescription
extensionIdstringYour extension's unique ID, as declared in the manifest.
extensionDirstringAbsolute path to your extension's directory on disk.
browserBrowserServiceTab management, navigation, clicks, screenshots, JavaScript execution. Only available if core:browser is declared.
llmLLMServiceSend LLM completions via configured slots. Only available if core:llm-service is declared.
templateTemplateServiceLook up and manage page templates. Only available if core:template is declared.
licenseLicenseServiceCheck the user's license tier and entitlements. Only available if core:license is declared.
dbDatabaseServiceNamespaced key-value store plus SQL access for declared tables. Only available if core:database is declared.
sharedServicesobjectAll core services with unrestricted access. Use sparingly — prefer explicit dependencies.
ipcIPCBridgeRegister IPC handlers for communication between the main and renderer processes.
extensionsobjectPublic APIs returned by loaded extension dependencies.
eventsEventBusSimple publish/subscribe event bus for cross-extension communication.
exposefunctionRegister functions as both REST and MCP endpoints in a single call.
Tip: Services that require a dependency declaration (like 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.
context.expose()

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)
ParameterTypeDescription
namestringEndpoint name. Used in the route path and MCP tool name.
handlerasync functionReceives a params object, returns data. No need to format responses.
options.targetsstring[]Where to register: 'api', 'mcp', or both. Default: ['api', 'mcp'].
options.descriptionstringHuman-readable description. Used in MCP tool listing and API docs.
options.methodstringHTTP method: 'get', 'post', 'put', 'delete'. Default: 'post'.
options.paramsobjectParameter 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:

TargetEndpoint
RESTPOST /api/ext/{extensionId}/searchBooks
MCPTool 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":{...}}' }] }
Important: expose() must be called synchronously during activate(). The endpoint registry is sealed after activation completes, and any later calls will throw an error.
Tip: 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.
Extension Dependencies

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);
Reserved names: The shorthand 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.
Database

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

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'
});
Tip: IPC handlers are automatically removed when your extension is deactivated. You do not need to clean them up manually.
Events

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);
REST Routes (Manual)

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.

MCP Tools (Manual)

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' }
        ]
      };
    }
  }
};
Warning: Manual MCP tools require you to return the MCP protocol format { content: [{ type: 'text', text: '...' }] }. Prefer context.expose() which handles this automatically.
Activation

When LumaBrowser starts, the extension manager loads extensions in loadPriority order and calls each one's activate() function. The full lifecycle is:

  1. Load manifest — validate dependencies and check availability.
  2. Build context — inject requested services based on declared dependencies.
  3. Call activate(context) — your entry point. Register IPC handlers, call expose(), set up event listeners.
  4. Capture return value — whatever you return becomes your extension's public API, available to dependents via context.extensions.
  5. Register routes — if routes.js is declared, the factory is called and the router is mounted.
  6. Register MCP tools — if mcp-tools.js is declared, tools are registered with the MCP aggregator.
  7. 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 };
Deactivation

When an extension is deactivated (app shutdown, user disables it, or an error occurs), the following happens in order:

  1. deactivate() called — clean up timers, intervals, open connections, or external resources.
  2. IPC handlers removed — all handlers registered via context.ipc.handle() are automatically unregistered.
  3. MCP tools unregistered — both manual and expose()-based tools are removed from the aggregator.
  4. REST routes blocked — requests to the extension's route prefix return 404.
Tip: IPC and MCP cleanup is automatic. You only need to implement deactivate() if you have resources the framework does not manage, such as setInterval timers, WebSocket connections, or file watchers.
Enable / Disable

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.

Minimal Extension

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 endpoint
  • hello-world_greet — MCP tool
Full Extension

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 };