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
Step 2: Link Plugin to Your Vault
# 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:
- Settings → Community plugins → Turn on "Restricted Mode" OFF
- Reload plugins (Cmd/Ctrl + R or restart)
- 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:
editorCallbackgives you the active editor instancereplaceSelectioninserts text at cursor (or replaces selected text)Noticeshows a toast notification
Test it:
- Save
main.ts - The
npm run devwatcher rebuilds automatically - Reload Obsidian (Cmd/Ctrl + R)
- 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:
- Settings → My Plugin → Enter API key
- Data saved to
.obsidian/plugins/my-plugin/data.json - 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:
- Commands appear: Open command palette (Cmd/Ctrl + P), search for your commands
- Settings work: Settings → My Plugin shows your config panel
- No console errors: Open DevTools (Cmd/Ctrl + Shift + I), check Console tab
- 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 installagain - 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
- Create GitHub repo with your plugin
- Add release with built files
- 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
EditorAPI to modify notes,PluginAPI for commands requestUrlhandles 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
Example 1: Auto-Link Related Notes
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:
Plugin won't load:
- Check console for errors: Cmd/Ctrl + Shift + I
- Verify
manifest.jsonis valid JSON - Ensure
main.jsexists after build
Changes not appearing:
- Hard reload: Close and reopen Obsidian
- Check if
npm run devis still running - Clear cache: Delete
.obsidian/workspace.json
API calls failing:
- Use
requestUrlinstead offetch() - Check CORS headers in response
- Log full error:
console.error(JSON.stringify(error))
- Use
Performance issues:
- Avoid reading all files synchronously
- Use
debouncefor 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