Skip to content

Building Plugins

Plugins are JavaScript modules that run inside Atlas and extend it with new functionality. Every plugin gets access to the atlas API — a sandboxed object for interacting with the vault, UI, tools, and more. You write a plugin.json manifest and a main.js file, drop them in your vault, and enable the plugin in Settings.

This guide walks through building a real plugin — a Pomodoro Timer — from scratch. By the end you’ll understand the plugin structure, manifest format, lifecycle hooks, and core APIs.

Let’s build the simplest possible plugin to make sure everything works.

Plugins live in your vault at atlas/plugins/{plugin-id}/. Create a folder:

your-vault/
└── atlas/
└── plugins/
└── hello/
├── plugin.json
└── main.js

Create plugin.json:

{
"id": "hello",
"name": "Hello World",
"version": "1.0.0",
"author": "Your Name",
"description": "A minimal plugin that shows a greeting",
"main": "main.js",
"permissions": ["ui_components"],
"enabled": true
}

Create main.js:

async function onLoad() {
atlas.ui.addToolbarButton({
icon: '👋',
tooltip: 'Say Hello',
onClick: () => {
atlas.ui.showNotification('Hello from my plugin!', 'success');
}
});
}

Open Atlas Settings > Plugins, find “Hello World” in the list, and toggle it on. You should see a 👋 button in the toolbar. Click it and you’ll get a notification.

That’s it — you’ve built a working plugin.

Every plugin is a folder inside {vault}/atlas/plugins/ containing at minimum two files:

my-plugin/
├── plugin.json # Required: plugin manifest
├── main.js # Required: plugin logic (referenced by manifest)
├── index.html # Optional: full UI panel (for tab plugins)
├── data/ # Auto-created: per-plugin persistent storage
└── ... # Any other files your plugin needs
  • plugin.json — Tells Atlas what your plugin is and what it needs
  • main.js — Your plugin code, loaded and executed when the plugin is enabled
  • index.html — Only needed if your plugin has its own tab/panel UI (see Advanced Plugins)
  • data/ — Created automatically when your plugin writes data via atlas.data.*

The manifest is how Atlas discovers and configures your plugin. Here’s a complete example:

{
"id": "pomodoro",
"name": "Pomodoro Timer",
"version": "1.0.0",
"author": "Atlas Team",
"description": "Pomodoro technique timer with work/break intervals and task tracking",
"main": "main.js",
"min_atlas_version": "1.0.0",
"permissions": [
"ui_components",
"execute_tools",
"write_vault"
],
"enabled": true
}
FieldRequiredDescription
idYesUnique identifier for your plugin (lowercase, hyphens OK). Must match the folder name.
nameYesDisplay name shown in the plugin list and marketplace
versionYesSemantic version string (e.g. 1.0.0)
authorYesYour name or organization
descriptionYesShort description of what the plugin does
mainYesPath to the JavaScript entry point (usually main.js)
permissionsYesArray of permission strings the plugin needs
enabledNoWhether the plugin starts enabled (default: false)
min_atlas_versionNoMinimum Atlas version required
uiNoConfiguration for tab-based plugin UI (see Advanced Plugins)

Request only the permissions your plugin genuinely needs. Users see this list before enabling a plugin.

PermissionWhat It Unlocks
read_vaultRead files and list directories in the user’s vault via atlas.vault.*
write_vaultCreate, edit, and delete vault files via atlas.vault.*
execute_toolsCall Atlas agent tools (search, create notes, add tasks) via atlas.tools.*
ui_componentsAdd toolbar buttons, status bar items, modals, and notifications via atlas.ui.*
configRead and write global Atlas configuration via atlas.config.get/set
networkMake outbound HTTP requests to external services

Your main.js defines async functions that Atlas calls at specific moments:

async function onLoad() {
// Called when the plugin is first loaded
// Set up UI elements, register commands, initialize state
}
async function onEnable() {
// Called after onLoad on startup, or when the user toggles the plugin on
}
async function onDisable() {
// Called when the user toggles the plugin off
// Atlas auto-cleans your UI elements and commands, but stop
// your own timers, intervals, or background work here
}
async function onUnload() {
// Called on app shutdown or plugin removal
// Final cleanup opportunity
}

The typical call order:

  1. Plugin enabled → onLoad()onEnable()
  2. User disables → onDisable()
  3. User re-enables → onEnable()
  4. App closes → onDisable()onUnload()

Your main.js file is executed via a sandboxed Function() constructor, not as an ES module or CommonJS module. This means your plugin code runs in a controlled environment with limited access to browser APIs for security.

Your plugin code can use:

  • atlas API object — The primary interface for interacting with Atlas (vault, UI, tools, etc.)
  • consoleconsole.log(), console.error(), console.warn() for debugging
  • Modern JavaScript syntax:
    • async/await for asynchronous operations
    • const/let for variable declarations
    • Template literals (backticks)
    • Arrow functions
    • Classes
    • Promise for async handling
    • setTimeout / setInterval / clearTimeout / clearInterval for timers

Your plugin code cannot directly access:

  • import / require — No ES module imports or CommonJS requires (your code is not a module)
  • window — No direct access to the browser’s window object
  • document — No direct DOM manipulation
  • fetch — No direct HTTP requests (use atlas.vault.* for vault files, request network permission for external APIs in future versions)
  • XMLHttpRequest — No XHR access

If your plugin uses tab UI (has a ui block in plugin.json with an index.html file), the HTML page runs in a sandboxed iframe with a postMessage bridge. Inside the iframe, you DO have access to:

  • Standard browser APIs (document, fetch, XMLHttpRequest, etc.)
  • The iframe environment is isolated from the main Atlas window
  • Communication with main.js happens via the atlas API bridge (postMessage under the hood)

See Advanced Plugins for details on building tab plugins.

Every plugin gets access to a global atlas object. This is your interface to Atlas. The API is organized into namespaces:

NamespacePurposePermission Required
atlas.vault.*Read/write vault filesread_vault / write_vault
atlas.ui.*Toolbar buttons, status bar, modals, notificationsui_components
atlas.tools.*Search, create notes, add tasksexecute_tools
atlas.config.*Plugin settings and global configNone (plugin settings) / config (global)
atlas.data.*Per-plugin persistent file storageNone
atlas.plugin.*Logging, commands, plugin metadataNone
atlas.manifest.*Read-only manifest infoNone

For complete method signatures and parameters, see the Plugin API Reference.

Now let’s build something real. The Pomodoro Timer plugin will:

  • Add a toolbar button and status bar countdown
  • Show a modal to enter the current task
  • Run 25-minute work sessions and 5-minute breaks
  • Log completed pomodoros to the daily note
  • Support a keyboard shortcut

Create atlas/plugins/pomodoro/plugin.json:

{
"id": "pomodoro",
"name": "Pomodoro Timer",
"version": "1.0.0",
"author": "Atlas Team",
"description": "Pomodoro technique timer with work/break intervals and task tracking",
"main": "main.js",
"permissions": [
"ui_components",
"execute_tools",
"write_vault"
],
"enabled": true
}

We need three permissions:

  • ui_components — for the toolbar button, status bar countdown, modal dialog, and notifications
  • execute_tools — to log completed pomodoros as tasks in the daily note
  • write_vault — required by the tools API to write to vault files

Create atlas/plugins/pomodoro/main.js and start with the timer state:

// Pomodoro Timer Plugin
let statusItemId = null;
let timerInterval = null;
let currentMode = 'idle'; // 'idle', 'work', 'break'
let remainingSeconds = 0;
let completedPomodoros = 0;
let currentTask = '';
const WORK_DURATION = 25 * 60; // 25 minutes
const BREAK_DURATION = 5 * 60; // 5 minutes
const LONG_BREAK_DURATION = 15 * 60; // 15 minutes
async function onLoad() {
atlas.plugin.log('Pomodoro Timer plugin loaded');
// Add a toolbar button
atlas.ui.addToolbarButton({
icon: '🍅',
tooltip: 'Start/Stop Pomodoro Timer',
onClick: toggleTimer
});
// Add a status bar item for the countdown display
statusItemId = atlas.ui.addStatusBarItem({
text: '🍅 Ready',
tooltip: 'Pomodoro Timer - Click toolbar button to start'
});
// Register a keyboard shortcut
atlas.plugin.registerCommand({
id: 'toggle',
name: 'Toggle Pomodoro Timer',
hotkey: 'Ctrl+Shift+P',
callback: toggleTimer
});
}

What’s happening:

  • addToolbarButton puts a clickable button in the Atlas toolbar. It returns a button ID (useful if you need to remove it later).
  • addStatusBarItem adds a text element to the bottom status bar. We save the ID so we can update the text as the timer counts down.
  • registerCommand binds a keyboard shortcut. The hotkey format is modifier keys (Ctrl, Shift, Alt, Meta) joined with + and a final key.

When the user clicks the button or presses the shortcut, we toggle between starting and stopping:

async function toggleTimer() {
if (currentMode === 'idle') {
await startWorkSession();
} else {
stopTimer();
}
}
async function startWorkSession() {
// Show a modal dialog to ask what the user is working on
const result = await atlas.ui.showModal({
title: 'Start Pomodoro',
content: `
<div style="padding: 0.5rem;">
<label>What are you working on?</label>
<input type="text" id="task-name" placeholder="Task description"
style="width: 100%; margin-top: 0.5rem; padding: 0.5rem;" />
</div>
`,
buttons: [
{ label: 'Cancel', value: 'cancel', type: 'secondary' },
{ label: 'Start', value: 'start', type: 'primary' }
]
});
if (result.value !== 'start') return;
currentTask = result.formData['task-name'] || 'Untitled task';
currentMode = 'work';
remainingSeconds = WORK_DURATION;
startTimer();
atlas.ui.showNotification('Work session started! Focus for 25 minutes.', 'info');
}

Key detail: showModal returns a Promise that resolves with { value, formData }. The value is which button was clicked (or 'dismiss' if the modal was closed). The formData object contains values from any <input> elements in the modal content, keyed by their id attribute.

function startTimer() {
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(() => {
remainingSeconds--;
updateDisplay();
if (remainingSeconds <= 0) {
handleTimerComplete();
}
}, 1000);
updateDisplay();
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
currentMode = 'idle';
remainingSeconds = 0;
updateDisplay();
atlas.ui.showNotification('Timer stopped', 'info');
}
function updateDisplay() {
if (!statusItemId) return;
if (currentMode === 'idle') {
atlas.ui.updateStatusBarItem(statusItemId, {
text: `🍅 Ready (${completedPomodoros} done)`,
tooltip: 'Click toolbar button to start a pomodoro'
});
} else {
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
const emoji = currentMode === 'work' ? '🍅' : '';
atlas.ui.updateStatusBarItem(statusItemId, {
text: `${emoji} ${timeStr}`,
tooltip: `${currentMode === 'work' ? 'Working' : 'Break'}: ${currentTask}`
});
}
}

updateStatusBarItem lets you change the text and tooltip of a status bar item after creation. Here we update it every second to show the countdown.

Step 6: Handle session transitions and log completions

Section titled “Step 6: Handle session transitions and log completions”
function handleTimerComplete() {
clearInterval(timerInterval);
timerInterval = null;
if (currentMode === 'work') {
atlas.ui.showNotification('Work session complete! Time for a break.', 'success');
startBreakSession();
} else {
atlas.ui.showNotification('Break complete! Ready for another session?', 'info');
currentMode = 'idle';
remainingSeconds = 0;
updateDisplay();
}
}
function startBreakSession() {
completedPomodoros++;
const isLongBreak = (completedPomodoros % 4) === 0;
currentMode = 'break';
remainingSeconds = isLongBreak ? LONG_BREAK_DURATION : BREAK_DURATION;
startTimer();
const breakType = isLongBreak ? 'Long' : 'Short';
const duration = isLongBreak ? '15' : '5';
atlas.ui.showNotification(
`${breakType} break! Rest for ${duration} minutes.`,
'success'
);
// Log the completed pomodoro to the daily note
logPomodoro(currentTask);
}
async function logPomodoro(task) {
try {
await atlas.tools.addTask(`Completed pomodoro: ${task}`);
atlas.plugin.log(`Logged pomodoro: ${task}`);
} catch (error) {
atlas.plugin.log(`Error logging pomodoro: ${error}`, 'error');
}
}

atlas.tools.addTask() adds a task line to the current daily note — the same as the agent’s add_task tool. The atlas.plugin.log() calls write to the browser console with a [Plugin: pomodoro] prefix, helpful for debugging.

async function onDisable() {
atlas.plugin.log('Pomodoro Timer plugin disabled');
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
// Atlas automatically removes the toolbar button, status bar item,
// and keyboard shortcut — no need to remove them manually
}

That’s the full Pomodoro Timer. The complete main.js is about 100 lines of straightforward JavaScript. No build tools, no framework, no bundler — just a manifest and a JS file.

  1. Place your plugin folder in {vault}/atlas/plugins/
  2. Open Settings > Plugins in Atlas
  3. Your plugin should appear in the list — toggle it on
  4. Open the browser dev tools console (Ctrl+Shift+I) to see atlas.plugin.log() output and any errors
  5. If your plugin doesn’t appear, check that plugin.json is valid JSON and the id matches the folder name

Here’s a complete, copy-pasteable example plugin that you can install and run immediately. This Word Count plugin scans all markdown files in your vault and displays the total word count in a modal when you click the toolbar button.

Create {vault}/atlas/plugins/word-count/plugin.json:

{
"id": "word-count",
"name": "Word Count",
"version": "1.0.0",
"author": "Atlas Community",
"description": "Display total word count across all vault markdown files",
"main": "main.js",
"permissions": [
"read_vault",
"ui_components"
],
"enabled": true
}

Create {vault}/atlas/plugins/word-count/main.js:

// Word Count Plugin
let toolbarButtonId = null;
async function onLoad() {
atlas.plugin.log('Word Count plugin loaded');
// Add toolbar button
toolbarButtonId = atlas.ui.addToolbarButton({
icon: '📊',
tooltip: 'Show Word Count',
onClick: showWordCount
});
}
async function onEnable() {
atlas.plugin.log('Word Count plugin enabled');
}
async function onDisable() {
atlas.plugin.log('Word Count plugin disabled');
// Atlas auto-cleans UI elements, but you can do additional cleanup here
}
async function onUnload() {
atlas.plugin.log('Word Count plugin unloaded');
}
async function showWordCount() {
try {
atlas.ui.showNotification('Counting words...', 'info');
// Get all files in vault root
const items = await atlas.vault.list('');
let totalWords = 0;
let fileCount = 0;
// Process all markdown files recursively
totalWords = await countWordsInDirectory('', items);
// Count markdown files
fileCount = await countMarkdownFiles('', items);
// Show results in modal
atlas.ui.showModal({
title: 'Vault Word Count',
content: `
<div style="padding: 1rem; text-align: center;">
<h2 style="margin: 0 0 1rem 0; font-size: 2rem; color: var(--accent);">
${totalWords.toLocaleString()}
</h2>
<p style="margin: 0; color: var(--text-secondary);">
words across ${fileCount} markdown files
</p>
</div>
`,
buttons: [
{ label: 'Close', value: 'close', type: 'primary' }
]
});
atlas.plugin.log(`Total words: ${totalWords} in ${fileCount} files`);
} catch (error) {
atlas.ui.showNotification(`Error counting words: ${error}`, 'error');
atlas.plugin.log(`Error: ${error}`, 'error');
}
}
async function countWordsInDirectory(path, items) {
let totalWords = 0;
for (const item of items) {
const itemPath = path ? `${path}/${item.name}` : item.name;
if (item.isDirectory) {
// Recursively process subdirectories
const subItems = await atlas.vault.list(itemPath);
totalWords += await countWordsInDirectory(itemPath, subItems);
} else if (item.name.endsWith('.md')) {
// Count words in markdown files
try {
const content = await atlas.vault.read(itemPath);
const words = content.split(/\s+/).filter(word => word.length > 0);
totalWords += words.length;
} catch (error) {
atlas.plugin.log(`Could not read ${itemPath}: ${error}`, 'warn');
}
}
}
return totalWords;
}
async function countMarkdownFiles(path, items) {
let count = 0;
for (const item of items) {
const itemPath = path ? `${path}/${item.name}` : item.name;
if (item.isDirectory) {
const subItems = await atlas.vault.list(itemPath);
count += await countMarkdownFiles(itemPath, subItems);
} else if (item.name.endsWith('.md')) {
count++;
}
}
return count;
}
  1. Copy the code above into the two files at the paths shown
  2. Open Atlas Settings > Plugins
  3. Find “Word Count” in the list and toggle it on
  4. Click the 📊 button in the toolbar to see your vault’s word count

This example demonstrates:

  • All four lifecycle hooks (onLoad, onEnable, onDisable, onUnload)
  • Reading vault files with atlas.vault.read() and atlas.vault.list()
  • Recursive directory traversal
  • Modal dialogs with styled content
  • Notifications for user feedback
  • Plugin logging for debugging
  • Proper cleanup patterns

The plugin uses bare function declarations (no export keyword) as shown in the runtime environment documentation. It’s a complete, working plugin that you can use as a starting point for your own creations.

Once you’ve built and tested your plugin locally, you can submit it to the Atlas Plugin Marketplace so other users can discover and install it.

  1. Build and test your plugin locally — Ensure it works correctly in your own Atlas installation
  2. Host it on a public GitHub repository — Your plugin code must be publicly accessible
  3. Submit via the cloud backend — Use the POST /plugins endpoint to submit your plugin listing

Your plugin submission must include:

  • Complete plugin.json manifest with all required fields:
    • id (unique identifier, lowercase with hyphens)
    • name (display name)
    • version (semantic versioning, e.g., 1.0.0)
    • author (your name or organization)
    • description (clear, concise description of functionality)
    • permissions (accurate list of required permissions)
  • Public GitHub repository URL — This goes in the download_url field. Users download your plugin from GitHub, then install it via Settings.
  • Category — Choose a category that best describes your plugin (see the marketplace for available categories)
  1. Submission — When you submit via POST /plugins, your plugin goes into pending status
  2. Review — The Atlas team reviews your plugin for security, quality, and accuracy
  3. Approval — Once approved, your plugin appears in the public marketplace at atlas.app/plugins and in the in-app Marketplace tab
  4. Updates — You can update your plugin listing at any time via PUT /plugins/{id}. Updates also go through review.

The Atlas team reviews plugins based on:

  • No malicious code — Plugins must not contain malicious behavior, data exfiltration, or security vulnerabilities
  • Accurate permission declarations — The permissions array must accurately reflect what your plugin actually does. Don’t request permissions you don’t use.
  • Working plugin that matches description — The plugin must do what the description says it does
  • HTTPS download URL — The download_url must be a public HTTPS GitHub repository URL (e.g., https://github.com/username/atlas-plugin-name)

After your plugin is approved:

  • Users can rate and review your plugin (1-5 stars + optional text review)
  • Highly-rated plugins appear higher in marketplace search results
  • Download counts are tracked and displayed on your plugin’s marketplace page
  • Users can search for plugins by name, description, and category

Publishing your plugin to the marketplace helps grow the Atlas plugin ecosystem and makes your work available to thousands of Atlas users. Happy building!

Problem: You’re seeing a syntax error about export when your plugin loads.

Solution: Export keywords are optional and automatically stripped by the runtime. If you’re still seeing issues, use bare function declarations:

// ✅ Correct
async function onLoad() { ... }
// ❌ Not needed (but works)
export async function onLoad() { ... }

Problem: You’re getting an error about module not being defined, or module.exports.

Solution: Plugins don’t use CommonJS or ES modules. Use bare function declarations instead of module.exports:

// ❌ Wrong - this is CommonJS
module.exports = {
onLoad: async function() { ... }
}
// ✅ Correct - bare functions
async function onLoad() { ... }

Problem: You installed a plugin but it doesn’t show up in the plugin list.

Solution: Check these common issues:

  • The folder name must exactly match the id field in plugin.json (case-sensitive)
  • Make sure plugin.json is valid JSON (use a JSON validator if unsure)
  • The plugin folder must be inside {vault}/atlas/plugins/
  • Try restarting Atlas or toggling Settings > Plugins off and on

Problem: Your plugin appears in the list, but when you enable/disable it, it disappears or stops working.

Solution: This is almost always a JavaScript syntax error in your main.js. Open the browser dev tools console (Ctrl+Shift+I or Cmd+Option+I on Mac) and look for error messages prefixed with [Plugin: your-plugin-id]. Common issues:

  • Missing closing braces } or parentheses )
  • Using an API method that doesn’t exist (check Plugin API Reference)
  • Using an API that requires a permission you didn’t declare in plugin.json

Duplicate UI elements (two buttons or tabs)

Section titled “Duplicate UI elements (two buttons or tabs)”

Problem: Your plugin is showing up twice in the UI — two toolbar buttons or two tabs.

Solution: If your plugin has a ui block in plugin.json and calls addToolbarButton() in your code, you’ll get duplicate UI elements. Choose one approach:

  • Use ui config in plugin.json for simple tab plugins (no addToolbarButton() needed)
  • Use addToolbarButton() for toolbar-only plugins (no ui block needed)
  • If you want both, only call addToolbarButton() and don’t use the ui config

Problem: Your tab plugin (index.html) is trying to call functions defined in main.js but they’re not accessible.

Solution: Tab plugins run in an isolated iframe and can’t directly call main.js functions. Use one of these patterns for communication:

Option 1: Use atlas.data as a message bus:

// In main.js
await atlas.data.write('shared-state.json', JSON.stringify({ counter: 42 }));
// In index.html (iframe)
const state = JSON.parse(await atlas.data.read('shared-state.json'));

Option 2: Use atlas.plugin.emit() and atlas.plugin.on() for event-based communication (available in future releases).

  • Plugin API Reference — Complete reference for every atlas.* method with parameters and return values
  • Advanced Plugins — Tab plugins with full HTML UI, the HTTP server API for external tools, per-plugin data storage, and publishing to the marketplace