Create an add-on
Quick start
Base
Starter template (ZIP). Extract into addons/<your-addon-id>/ and rename id in manifest.
-
Create folder —
addons/my-addon/. Name = add-on id (lowercase, hyphens). Or use the Base template above; folders starting with_are not discovered. -
Add
manifest.json—id,name,tools[](name, description, parameters),defaultSettings. Optional:toolDisplay,systemPromptHint. -
Add
index.js— Exportregister(loader, settings). Callloader.registerTool(name, async (args, context) => result)for each tool. Optionallyloader.registerIpc(channel, handler). -
Enable in app — Settings → Marketplace: enable your add-on. Only add-ons in
config.addons.enabledare loaded.
Optional: add SettingsPage.jsx and/or ToolResultViews.jsx; both are auto-discovered (no manual registration).
Add-on basis (in depth)
This section explains how the add-on system works end-to-end: discovery, loading, tool execution, and how the frontend displays your add-on. Understanding this will help you build add-ons that behave correctly and integrate cleanly with the app.
Architecture overview
Pointer is an Electron app: a main process (Node.js) and a renderer process (React in the browser). Add-ons are loaded only in the main process.
- Main process: Scans
addons/, loadsmanifest.jsonandindex.jsfor each enabled add-on, callsregister(loader, settings). Tools are stored in an internal map; when the AI requests a tool call, the main process runs your handler and returns a result. IPC handlers you register are attached to Electron'sipcMainso the renderer can invoke them. - Renderer: Does not load
index.js. It receives add-on metadata via theaddon:get-manifestsIPC (list of{ id, manifest }). That list is used to populate the tool-display registry (labels, icons, expand labels for the chat UI) and to show the Marketplace. When the user enables an add-on and saves config, the main process re-initializes add-ons. Settings pages and tool-result views are React components that live in your add-on folder; the app discovers them with Vite'simport.meta.globand loads them lazily when needed.
Discovery and loading (main process)
Discovery: The loader function discoverAddons() reads the addons/ directory. For each subdirectory whose name does not start with _, it checks for a valid manifest.json (exists and parses as JSON). Those directory names are the add-on ids. So _base is never in the discovered list; it is only a template. Discovery does not run your code; it only lists ids and is used when the app needs to show "all available add-ons" (e.g. in the Marketplace) and when handling addon:get-manifests.
Loading (init): When the app starts or config is reloaded, addonLoader.init(config) runs. It reads config.addons.enabled (array of add-on ids). Only those ids are initialized. For each id it: (1) loads manifest.json again, (2) checks that addons/<id>/index.js exists, (3) requires that index.js, (4) computes settings with getAddonSettings(config, id) — which merges manifest.defaultSettings with config.addons.settings[id] — (5) builds a loader object and calls mod.register(loader, settings). If register throws, that add-on is skipped and an error is logged. No other add-ons are affected.
After init, registerIpcHandlers(ipcMain) is called. It unregisters any previously registered add-on IPC channels (to avoid duplicates after config reload), then registers each add-on's IPC handlers with ipcMain.handle(channel, handler). So your IPC handler receives (event, ...args) and can return a value (or a promise) that is sent back to the renderer.
The manifest in full
manifest.json is the single source of truth for identity, tool schemas, and UI hints. The main process reads it for discovery and init; the renderer receives it via addon:get-manifests and uses it to build the tool-display registry (labels, icons, expand-detail labels) and to show add-on names and descriptions in the Marketplace.
id(required): Must equal the add-on folder name. Used inconfig.addons.enabled,config.addons.settings[addonId], and when looking up your Settings/result-view components.name,version,description,author: Display only. Shown in the Marketplace and in tool-call logs (addonName).capabilities: Array of strings, e.g.["settings", "tools", "ipc"]. Declarative only; the loader does not enforce them. Use them so users and docs know what the add-on does.tools: Array of tool definitions. Each hasname(string),description(string, shown to the AI), andparameters(object).parametersmust be JSON Schema–like:type: "OBJECT",properties(each property can havetype,description), andrequired(array of keys). Supported types:STRING,INTEGER,NUMBER,BOOLEAN,OBJECT,ARRAY. The same structure is sent to the AI provider (Gemini, OpenAI, Claude) so the model knows how to call your tool. If you callloader.registerTool(name, handler), the loader only adds that tool to the internal map if the manifest contains a tool with thatname; it also pushes a corresponding entry into the list of tool definitions that are passed to the model.systemPromptHint: Optional string. When the add-on is enabled, this string is appended to the system prompt (viagetAddonSystemHints(enabledAddonIds)). Use it to tell the model how to use your tools (e.g. "Always call discord_list_channels first; then use the exact channel_label in discord_search.").toolDisplay: Optional object. Maps each toolnameto{ label, icon, expandDetailLabel }.labelis shown in the chat tool-call timeline.iconis a key from a fixed set:fallback,discord,search,list,fivem,coding,server,web,photo,bar-chart,eye.expandDetailLabelis used for the expandable section (e.g. "messages", "channels"). The renderer gets manifests and fillstoolDisplayRegistryfrom this; if you omit a tool intoolDisplay, the app falls back to a generic label/icon.defaultSettings: Object. Keys and default values for your add-on's settings. At init,getAddonSettings(config, addonId)merges this withconfig.addons.settings[addonId](saved values win). The merged object is passed toregister(loader, settings)and to your Settings page assettings.
Backend entry (index.js) and the loader
Your index.js is loaded with require() in the main process. It must export { register }. The loader calls register(loader, settings) once per init.
loader has:
loader.addonId: Your add-on id (string).loader.settings: The merged settings object (same as the second argumentsettings).loader.config: The full app config object (read-only). Use it to read global settings or connections if needed.loader.registerTool(toolName, handler): Registers an AI tool.toolNamemust match anameinmanifest.tools; otherwise the tool is still stored but no definition is added for the model.handleris an async function(args, context) => result.argsis the object of arguments from the model (keys match yourparameters.properties).contextis passed through from the tool runner (see below). You can return any JSON-serializable value. The loader uses it to build alogEntryfor the chat: it setslogEntry.rowCountfromresult.rowCountor from the length ofresult,result.results,result.channels, orresult.samples; it setslogEntry.resultto one of those arrays or to{ error }/{ success, hint }for display. If your handler throws, the loader catches, setslogEntry.errorand returns{ functionResult: { error: err.message }, logEntry }.loader.registerIpc(channel, handler): Registers an IPC handler.channelis a string (must be unique across all add-ons; use e.g.addon-<id>-<action>).handleris async(event, ...args) => value. It is later bound toipcMain.handle(channel, ...). The renderer calls it viawindow.electronAPI/ipcRenderer.invoke(channel, ...args).
Tool execution from A to Z
When the AI model decides to call a tool, the backend (Gemini, OpenAI, or Claude) returns a tool-call payload (name + arguments). The app's tool runner (executeToolCall) runs in the main process. It first checks addonLoader.isAddonTool(fcName). If true, it calls addonLoader.executeAddonTool(fcName, args, context) and returns that result. Otherwise it handles built-in tools (database, web search, etc.).
context passed to your handler contains at least agent and options. agent holds the current chat context (e.g. connectionId, safeMode, provider, apiKey). options can include safeMode, signal (AbortSignal), enabledAddonIds, queryTabs, userDataPath, etc. Add-ons should tolerate missing keys.
Return value: Your handler can return a plain object. The loader then:
- Sets
logEntry.rowCountfromresult.rowCountor, if absent, fromresult.length,result.results.length,result.channels.length, orresult.samples.length. - Sets
logEntry.resulttoresult.results,result.channels, orresult.samplesif present (so the UI can show "N results" and pass data to your ToolResultViews); otherwise to{ error: result.error }orresultifresult.successorresult.hintis set. - Returns
{ functionResult: result, logEntry }to the tool runner.functionResultis what the model sees as the tool output.logEntryis sent to the renderer and used to draw the tool-call row (label, icon, row count, expand panel).logEntryalso getsaddonIdandaddonNamefrom the loader so the UI can show "Discord: Search" and load your add-on's result view component.
How the model sees your tools
The model backends (Gemini, OpenAI, Claude) request tool definitions and optional system-prompt text. They call addonLoader.getAddonToolDefinitions(enabledAddonIds) and addonLoader.getAddonSystemHints(enabledAddonIds). enabledAddonIds is the list of add-on ids currently enabled for that chat (from config or from the conversation's options). If you pass an empty array, no add-on tools are sent to the model. The definitions are built from the manifest entries that match the tools you registered; each has name, description, parameters, and addonId. So only enabled add-ons contribute tools, and the model only sees tools you declared in the manifest and registered in register().
IPC: registration and invocation
You register IPC in register(loader, settings) with loader.registerIpc(channel, handler). Handlers are stored per add-on. After init(), the app calls registerIpcHandlers(ipcMain), which (1) removes any existing handlers for the same channels (from a previous init), (2) then registers ipcMain.handle(channel, async (event, ...args) => handler(event, ...args)) for each. The renderer invokes via the preload API (e.g. ipcRenderer.invoke(channel, ...args)). Use a unique channel name (e.g. addon-my-addon-do-something) to avoid clashes. Do not run long blocking work in the handler; the main process must stay responsive.
Config, settings, and persistence
config.addons.enabled is an array of add-on ids. Only those are loaded in addonLoader.init(config). config.addons.settings is an object keyed by add-on id; each value is a free-form object (your defaultSettings keys plus any overrides the user saved). The app persists config to disk (e.g. via config store). When the user changes enabled add-ons or add-on settings in the Settings → Marketplace UI and saves, the app writes the full config and may trigger a reload; the main process then calls init(config) again and registerIpcHandlers(ipcMain), so your add-on's register runs again with updated settings.
Frontend: how manifests reach the UI
The renderer never reads addons/ on disk. It asks the main process for the list of add-on manifests via the IPC addon:get-manifests. The main process calls addonLoader.discoverAddons() and for each id returns { id, manifest: addonLoader.loadManifest(id) }. So the list includes every discovered add-on (including disabled ones). The app (e.g. App.jsx, MarketplacePage.jsx) then calls setManifests(list) on the toolDisplayRegistry. The registry iterates each manifest's tools and toolDisplay and fills a map from tool name to { addonId, addonName, label, icon, expandDetailLabel }. That map is used by the chat UI to show the correct label and icon for each tool call and to build the expandable-detail payload (see below). So: manifests drive both "which add-ons exist" and "how each add-on's tools look in the chat."
Frontend: Settings page
The app discovers settings pages with Vite's import.meta.glob('../addons/*/SettingsPage.jsx'). It builds an object ADDON_SETTINGS_PAGES mapping add-on id to a lazy-loaded React component (default export of that file). The Marketplace settings view shows a list of add-ons; when the user selects an add-on, it renders ADDON_SETTINGS_PAGES[addonId] and passes settings (from config.addons.settings[addonId], merged with defaults) and onSave. onSave(partial) merges partial into that add-on's settings and persists the full config. So you do not register the settings page manually; just add SettingsPage.jsx in your add-on folder and export a default component that accepts { settings, onSave }.
Frontend: Tool result views
When the chat renders a tool-call row, it computes expandDetails via getToolCallExpandDetails(tc). For addon tools, that function uses the tool-display config (from manifests): it builds lines from relevant args (e.g. query, channel_label) and sets raw to the array that was stored on the log entry — tc.result when it's an array, or tc.result.results, tc.result.channels, or tc.result.samples. So expandDetails.raw is the array of items (messages, channels, etc.) your handler returned. The UI then checks whether the tool call has tc.addonId. If so, it gets your component with getAddonResultViewComponent(tc.addonId), which resolves to the default export of addons/<id>/ToolResultViews.jsx (again via a glob). It renders <AddonView expandDetails={expandDetails} tc={tc} /> inside a Suspense. If your component returns null, the app falls back to a generic display (e.g. code block with expandDetails.lines). So: return structured data (results, channels, or samples) from your handler and use expandDetails.raw and tc in your React component to render custom cards. Keep the add-on self-contained: do not import from the app's src/; use existing CSS classes (e.g. live-tc-discord-card) for consistent look.
Directory & conventions
addons/<addon-id>/
manifest.json # Required. Metadata, tools, defaultSettings.
index.js # Required. register(loader, settings), registerTool, registerIpc.
SettingsPage.jsx # Optional. React settings UI (auto-discovered).
ToolResultViews.jsx # Optional. Custom result cards in chat (auto-discovered).
constants/tools.js # Optional. Tool names for settings/backend (e.g. ALL_TOOLS).
settings/ # Optional. Subcomponents for SettingsPage (tabs, panels).
data/ # Optional. Static JSON or assets.id = folder name | Lowercase, digits, hyphens. _-prefix = not discovered (e.g. _base is template only). |
| Discovery | Loader scans addons/ for dirs with valid manifest.json. Settings and result views are found via glob (*/SettingsPage.jsx, */ToolResultViews.jsx). |
| Backend | index.js runs in Node (Electron main). require(), native modules OK. |
Manifest fields
manifest.json defines identity, tools, and UI hints.
| Field | Required | Description |
|---|---|---|
id | ✓ | Must match folder name. |
name, version, description, author | Display metadata. | |
capabilities | Array: "settings", "tools", "ipc". Informational. | |
tools | ✓ if tools | Array of { name, description, parameters }. parameters: JSON Schema (type: "OBJECT", properties, required). |
systemPromptHint | String appended to system prompt when add-on enabled. Guide model on when/how to use tools. | |
toolDisplay | Map tool name → { label, icon, expandDetailLabel }. icon keys: fallback, discord, search, list, fivem, coding, server, web, photo, bar-chart, eye. | |
defaultSettings | Object. Merged with saved settings; passed to register(loader, settings). |
Minimal manifest example
{
"id": "my-addon",
"name": "My Add-on",
"version": "1.0.0",
"capabilities": ["tools"],
"tools": [{
"name": "my_addon_hello",
"description": "Returns a greeting.",
"parameters": {
"type": "OBJECT",
"properties": { "name": { "type": "STRING" } },
"required": []
}
}],
"defaultSettings": { "apiKey": "" }
}Backend (index.js)
Export register(loader, settings). Use loader to register tools and IPC.
loader.addonId | Add-on id. |
loader.settings | Current add-on settings (merged with defaultSettings). |
loader.config | Full app config (read-only). |
loader.registerTool(name, handler) | name must match manifest. handler(args, context) → async, return JSON-serializable value. Return rowCount, results/channels/samples for UI. |
loader.registerIpc(channel, handler) | handler(event, ...args) → async. Use unique channel (e.g. addon-<id>-action). |
function register(loader, settings) {
loader.registerTool('my_addon_hello', async (args) => {
return { message: `Hello, ${args?.name || 'World'}!` };
});
}
module.exports = { register };Tools
Each tool: declare in manifest.tools[] and register with loader.registerTool(name, handler). Names must match.
- Use a unique prefix (e.g.
discord_,fivem_). - Clear
descriptionandsystemPromptHinthelp the model choose the right tool and parameters. - Return
rowCountorresultsfor the tool-call log.
IPC
For renderer → main communication. loader.registerIpc(channel, async (event, ...args) => value). Use unique channel names. Don't block the main thread.
Config & settings
config.addons.enabled | Array of add-on ids. Only these are loaded; their tools are sent to the model. |
config.addons.settings[addonId] | Per-add-on settings. Merged with defaultSettings at load. Persisted via Settings → Marketplace. |
Frontend (optional)
Settings page — addons/<id>/SettingsPage.jsx, default export with { settings, onSave }. Auto-discovered via glob; no manual registration.
Tool result views — addons/<id>/ToolResultViews.jsx, default export with { expandDetails, tc }. Auto-discovered; return null for default view. Stay self-contained (no imports from app src/).
Cheat sheet
| What | Where / API |
|---|---|
| Add-on folder | addons/<id>/ |
| Manifest | manifest.json |
| Backend | index.js → register(loader, settings) |
| Tool | loader.registerTool(name, async (args, context) => result) |
| IPC | loader.registerIpc(channel, async (event, ...args) => value) |
| Settings page | SettingsPage.jsx (auto-discovered) |
| Result views | ToolResultViews.jsx (auto-discovered) |
Template: Base (ZIP). Examples in repo: addons/_base, addons/discord, addons/fivem-tools.