Build Your First Obsidian Plugin in 30 Minutes

Automate your Obsidian note-taking with custom JavaScript plugins and AI integration. Step-by-step guide for TypeScript beginners.

Problem: Your Note-Taking Needs Custom Automation

You're spending 10 minutes every day on repetitive Obsidian tasks - formatting meeting notes, linking related content, or summarizing research. The Community Plugins marketplace doesn't have exactly what you need.

You'll learn:

  • Build a working Obsidian plugin from scratch
  • Add AI text generation using local or cloud APIs
  • Deploy custom commands and UI elements
  • Debug and hot-reload during development

Time: 30 min | Level: Intermediate


Why Build Custom Plugins

Obsidian's plugin API gives you full access to:

  • Vault files: Read/write any note programmatically
  • Editor: Insert text, modify selections, add decorations
  • UI: Custom modals, status bar items, sidebar panels
  • Events: Trigger actions on file save, open, or rename

Common use cases:

  • Auto-generate daily note templates with context
  • Summarize selected text using AI
  • Create backlinks based on semantic similarity
  • Export notes in custom formats

The plugin runs in your vault's JavaScript environment with access to Node.js APIs via Electron.


Prerequisites

You need:

  • Obsidian 1.5.0+ (for latest API features)
  • Node.js 20+ and npm
  • Basic TypeScript knowledge (we'll explain the Obsidian-specific parts)
  • Text editor (VS Code recommended)

Time commitment: 30 minutes for basic plugin, +15 min for AI integration.


Solution

Step 1: Set Up Development Environment

Create your plugin using the official template:

# Clone the sample plugin
git clone https://github.com/obsidianmd/obsidian-sample-plugin.git my-plugin
cd my-plugin

# Install dependencies
npm install

# Build the plugin
npm run dev

Expected: You'll see main.js and manifest.json generated in the root directory.

Project structure:

my-plugin/
├── main.ts          # Your plugin code
├── manifest.json    # Plugin metadata
├── versions.json    # Obsidian version compatibility
├── package.json     # Dependencies
└── esbuild.config.mjs  # Build config

# Create symlink to test vault's plugins folder
# macOS/Linux:
ln -s $(pwd) /path/to/your-vault/.obsidian/plugins/my-plugin

# Windows (run as Administrator):
mklink /D "C:\path\to\vault\.obsidian\plugins\my-plugin" "%CD%"

In Obsidian:

  1. Settings → Community plugins → Turn on "Restricted Mode" OFF
  2. Reload plugins (Cmd/Ctrl + R or restart)
  3. Enable "My Plugin" in the plugins list

If it fails:

  • Plugin not visible: Check symlink path with ls -la .obsidian/plugins/
  • Build errors: Ensure Node.js 20+ with node --version

Step 3: Create a Command

Replace main.ts with this functional plugin:

import { App, Editor, MarkdownView, Plugin, Notice } from 'obsidian';

export default class MyPlugin extends Plugin {
  async onload() {
    console.log('Loading My Plugin');

    // Add command to command palette (Cmd/Ctrl + P)
    this.addCommand({
      id: 'insert-timestamp',
      name: 'Insert current timestamp',
      editorCallback: (editor: Editor, view: MarkdownView) => {
        // Get ISO timestamp
        const timestamp = new Date().toISOString();
        
        // Insert at cursor position
        editor.replaceSelection(`📅 ${timestamp}`);
        
        // Show notification
        new Notice('Timestamp inserted');
      }
    });
  }

  onunload() {
    console.log('Unloading My Plugin');
  }
}

Why this works:

  • editorCallback gives you the active editor instance
  • replaceSelection inserts text at cursor (or replaces selected text)
  • Notice shows a toast notification

Test it:

  1. Save main.ts
  2. The npm run dev watcher rebuilds automatically
  3. Reload Obsidian (Cmd/Ctrl + R)
  4. Open command palette → "Insert current timestamp"

You should see: An ISO timestamp inserted at your cursor position.


Step 4: Add AI Text Generation

Install the Anthropic SDK for Claude API access:

npm install @anthropic-ai/sdk

Add AI summarization command:

import { App, Editor, MarkdownView, Plugin, Notice, requestUrl } from 'obsidian';

export default class MyPlugin extends Plugin {
  async onload() {
    // Previous timestamp command here...

    this.addCommand({
      id: 'summarize-selection',
      name: 'Summarize selected text with AI',
      editorCallback: async (editor: Editor, view: MarkdownView) => {
        const selectedText = editor.getSelection();
        
        if (!selectedText) {
          new Notice('Please select text first');
          return;
        }

        new Notice('Generating summary...');

        try {
          // Using Claude API (requires API key)
          const summary = await this.callClaudeAPI(selectedText);
          
          // Insert summary below selection
          const cursor = editor.getCursor('to');
          editor.replaceRange(
            `\n\n**AI Summary:**\n${summary}\n`,
            cursor
          );
          
          new Notice('Summary complete');
        } catch (error) {
          new Notice(`Error: ${error.message}`);
          console.error(error);
        }
      }
    });
  }

  async callClaudeAPI(text: string): Promise<string> {
    // Using Obsidian's requestUrl (respects CORS)
    const response = await requestUrl({
      url: 'https://api.anthropic.com/v1/messages',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': 'YOUR_API_KEY', // Store in settings instead
        'anthropic-version': '2023-06-01'
      },
      body: JSON.stringify({
        model: 'claude-sonnet-4-20250514',
        max_tokens: 500,
        messages: [{
          role: 'user',
          content: `Summarize this in 2-3 sentences:\n\n${text}`
        }]
      })
    });

    const data = response.json;
    return data.content[0].text;
  }

  onunload() {
    console.log('Unloading My Plugin');
  }
}

Security note: Never hardcode API keys. Add a settings tab (see Step 5) to let users input their own key.

Alternative - Local AI: Replace the API call with a local model using Ollama:

async callLocalAI(text: string): Promise<string> {
  const response = await requestUrl({
    url: 'http://localhost:11434/api/generate',
    method: 'POST',
    body: JSON.stringify({
      model: 'llama3.2',
      prompt: `Summarize this in 2-3 sentences:\n\n${text}`,
      stream: false
    })
  });
  
  return response.json.response;
}

Why use requestUrl: Obsidian's built-in method handles CORS and proxying better than fetch().


Step 5: Add Settings Panel

Create a settings interface for API keys:

import { App, Plugin, PluginSettingTab, Setting } from 'obsidian';

interface MyPluginSettings {
  apiKey: string;
  useLocalAI: boolean;
}

const DEFAULT_SETTINGS: MyPluginSettings = {
  apiKey: '',
  useLocalAI: false
}

export default class MyPlugin extends Plugin {
  settings: MyPluginSettings;

  async onload() {
    await this.loadSettings();

    // Add settings tab
    this.addSettingTab(new MyPluginSettingTab(this.app, this));

    // Use this.settings.apiKey in API calls
    this.addCommand({
      id: 'summarize-selection',
      name: 'Summarize selected text with AI',
      editorCallback: async (editor: Editor) => {
        if (!this.settings.apiKey && !this.settings.useLocalAI) {
          new Notice('Please configure API key in settings');
          return;
        }
        // ... rest of command
      }
    });
  }

  async loadSettings() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }
}

class MyPluginSettingTab extends PluginSettingTab {
  plugin: MyPlugin;

  constructor(app: App, plugin: MyPlugin) {
    super(app, plugin);
    this.plugin = plugin;
  }

  display(): void {
    const {containerEl} = this;
    containerEl.empty();

    new Setting(containerEl)
      .setName('API Key')
      .setDesc('Your Claude API key (stored locally)')
      .addText(text => text
        .setPlaceholder('sk-ant-...')
        .setValue(this.plugin.settings.apiKey)
        .onChange(async (value) => {
          this.plugin.settings.apiKey = value;
          await this.plugin.saveSettings();
        }));

    new Setting(containerEl)
      .setName('Use Local AI')
      .setDesc('Use Ollama instead of Claude API')
      .addToggle(toggle => toggle
        .setValue(this.plugin.settings.useLocalAI)
        .onChange(async (value) => {
          this.plugin.settings.useLocalAI = value;
          await this.plugin.saveSettings();
        }));
  }
}

User flow:

  1. Settings → My Plugin → Enter API key
  2. Data saved to .obsidian/plugins/my-plugin/data.json
  3. Never committed to version control (add to .gitignore)

Step 6: Add UI Elements

Create a status bar item showing AI usage:

export default class MyPlugin extends Plugin {
  statusBarItem: HTMLElement;
  summaryCount: number = 0;

  async onload() {
    // Create status bar item
    this.statusBarItem = this.addStatusBarItem();
    this.updateStatusBar();

    // Increment counter when AI is used
    this.addCommand({
      id: 'summarize-selection',
      name: 'Summarize selected text with AI',
      editorCallback: async (editor: Editor) => {
        // ... AI call code ...
        
        this.summaryCount++;
        this.updateStatusBar();
      }
    });
  }

  updateStatusBar() {
    this.statusBarItem.setText(`🤖 ${this.summaryCount} summaries`);
  }
}

Advanced UI: Add a custom sidebar panel or modal:

import { ItemView, WorkspaceLeaf } from 'obsidian';

const VIEW_TYPE_MY_PANEL = 'my-plugin-panel';

class MyPluginView extends ItemView {
  getViewType() {
    return VIEW_TYPE_MY_PANEL;
  }

  getDisplayText() {
    return 'AI Assistant';
  }

  async onOpen() {
    const container = this.containerEl.children[1];
    container.empty();
    container.createEl('h4', { text: 'AI Summary History' });
    // Add your UI elements
  }

  async onClose() {
    // Cleanup
  }
}

// In plugin onload():
this.registerView(
  VIEW_TYPE_MY_PANEL,
  (leaf) => new MyPluginView(leaf)
);

// Add ribbon icon to open panel
this.addRibbonIcon('bot', 'Open AI Assistant', () => {
  this.activateView();
});

Verification

Test checklist:

  1. Commands appear: Open command palette (Cmd/Ctrl + P), search for your commands
  2. Settings work: Settings → My Plugin shows your config panel
  3. No console errors: Open DevTools (Cmd/Ctrl + Shift + I), check Console tab
  4. Hot reload works: Edit main.ts, save, reload Obsidian (Cmd/Ctrl + R)

You should see:

  • Timestamp inserted instantly
  • AI summary appears below selected text (3-5 seconds)
  • Status bar updates with usage count

If it fails:

  • "Cannot find module": Run npm install again
  • API errors: Check API key format, network connection
  • UI not updating: Hard refresh (close and reopen Obsidian)

Publishing Your Plugin

Update Metadata

Edit manifest.json:

{
  "id": "my-ai-assistant",
  "name": "AI Note Assistant",
  "version": "1.0.0",
  "minAppVersion": "1.5.0",
  "description": "Summarize notes and automate tasks with AI",
  "author": "Your Name",
  "authorUrl": "https://github.com/yourusername",
  "isDesktopOnly": false
}

Build for Production

# Create production build (minified)
npm run build

# Test the production build
# Copy main.js, manifest.json, styles.css to test vault

Submit to Community Plugins

  1. Create GitHub repo with your plugin
  2. Add release with built files
  3. Submit PR to obsidian-releases

Requirements:

  • README with installation instructions
  • LICENSE file (MIT recommended)
  • No telemetry without explicit opt-in
  • Works on desktop and mobile

What You Learned

  • Obsidian plugins are TypeScript projects built with esbuild
  • Use Editor API to modify notes, Plugin API for commands
  • requestUrl handles API calls better than fetch
  • Store sensitive data in settings, never hardcode keys

Limitations:

  • Plugins can't access system clipboard directly (use navigator.clipboard)
  • Mobile plugins have limited Node.js API access
  • Heavy processing blocks UI (use Web Workers for long tasks)

When NOT to build a plugin:

  • Simple text snippets → Use Templater plugin
  • One-time scripts → Use Obsidian's built-in JavaScript in notes
  • Complex backend needs → Build external tool with Obsidian API

Advanced Examples

this.addCommand({
  id: 'suggest-links',
  name: 'Suggest related note links',
  editorCallback: async (editor: Editor, view: MarkdownView) => {
    const currentFile = view.file;
    if (!currentFile) return;
    
    // Get all markdown files
    const files = this.app.vault.getMarkdownFiles();
    
    // Simple keyword matching (replace with embeddings for better results)
    const currentContent = await this.app.vault.read(currentFile);
    const keywords = currentContent.split(/\s+/).slice(0, 50); // First 50 words
    
    const related = files
      .filter(f => f.path !== currentFile.path)
      .map(file => {
        // Count keyword matches (simplified)
        const content = this.app.vault.cachedRead(file);
        const matches = keywords.filter(k => content.includes(k)).length;
        return { file, score: matches };
      })
      .sort((a, b) => b.score - a.score)
      .slice(0, 5);
    
    // Insert links
    const links = related
      .map(r => `- [[${r.file.basename}]]`)
      .join('\n');
    
    editor.replaceSelection(`\n\n**Related Notes:**\n${links}\n`);
  }
});

Example 2: Daily Note Template with Context

this.addCommand({
  id: 'smart-daily-note',
  name: 'Create daily note with AI context',
  callback: async () => {
    const today = window.moment().format('YYYY-MM-DD');
    const fileName = `${today}.md`;
    
    // Check if file exists
    const file = this.app.vault.getAbstractFileByPath(`Daily/${fileName}`);
    if (file) {
      new Notice('Daily note already exists');
      return;
    }
    
    // Get yesterday's note for context
    const yesterday = window.moment().subtract(1, 'days').format('YYYY-MM-DD');
    const yesterdayFile = this.app.vault.getAbstractFileByPath(`Daily/${yesterday}.md`);
    
    let context = '';
    if (yesterdayFile) {
      const content = await this.app.vault.read(yesterdayFile);
      // Extract tasks that weren't completed
      const incompleteTasks = content
        .split('\n')
        .filter(line => line.includes('- [ ]'))
        .join('\n');
      
      if (incompleteTasks) {
        context = `\n## Carried Over from Yesterday\n${incompleteTasks}\n`;
      }
    }
    
    const template = `# ${today}\n\n## Tasks\n- [ ] \n\n## Notes\n\n${context}`;
    
    await this.app.vault.create(`Daily/${fileName}`, template);
    
    // Open the new file
    const newFile = this.app.vault.getAbstractFileByPath(`Daily/${fileName}`);
    if (newFile) {
      this.app.workspace.openLinkText(newFile.path, '', false);
    }
  }
});

Example 3: Export to Custom Format

import { TFile } from 'obsidian';

this.addCommand({
  id: 'export-to-anki',
  name: 'Export note as Anki cards',
  callback: async () => {
    const activeFile = this.app.workspace.getActiveFile();
    if (!activeFile) return;
    
    const content = await this.app.vault.read(activeFile);
    
    // Parse flashcards (format: Q: question\nA: answer)
    const cards = content.match(/Q: (.+?)\nA: (.+?)(?=\n\n|$)/gs);
    
    if (!cards) {
      new Notice('No flashcards found (use Q:/A: format)');
      return;
    }
    
    // Convert to Anki CSV format
    const csv = cards.map(card => {
      const [q, a] = card.split('\nA: ');
      const question = q.replace('Q: ', '').trim();
      const answer = a.trim();
      return `"${question}","${answer}"`;
    }).join('\n');
    
    // Save to vault
    const exportPath = `Exports/${activeFile.basename}-anki.csv`;
    await this.app.vault.create(exportPath, csv);
    
    new Notice(`Exported ${cards.length} cards to ${exportPath}`);
  }
});

Debugging Tips

Common issues:

  1. Plugin won't load:

    • Check console for errors: Cmd/Ctrl + Shift + I
    • Verify manifest.json is valid JSON
    • Ensure main.js exists after build
  2. Changes not appearing:

    • Hard reload: Close and reopen Obsidian
    • Check if npm run dev is still running
    • Clear cache: Delete .obsidian/workspace.json
  3. API calls failing:

    • Use requestUrl instead of fetch()
    • Check CORS headers in response
    • Log full error: console.error(JSON.stringify(error))
  4. Performance issues:

    • Avoid reading all files synchronously
    • Use debounce for frequent operations
    • Cache expensive computations

Helpful console commands:

// In DevTools console, test plugin code:
app.plugins.plugins['my-plugin'].settings // View settings
app.vault.getMarkdownFiles() // List all notes
app.workspace.getActiveFile() // Current file

Tested on Obsidian 1.5.12, Node.js 22.x, macOS & Windows 11 Claude API version: 2023-06-01, Ollama 0.3.x