name: obsidian-plugin-development description: >- Build, modify, and debug Obsidian plugins using the TypeScript API. Use this skill when working with Obsidian plugin source code, the obsidian npm package, plugin UI (views, modals, settings, commands, ribbons), vault file operations, editor manipulation, workspace management, metadata cache, events, markdown rendering, or the Obsidian CLI. Covers plugin lifecycle, best practices, common patterns, and the full TypeScript API surface. metadata: author: obsidian-gemini version: '1.0' compatibility: Requires Node.js, npm, and TypeScript. Targets the obsidian npm package.
Obsidian Plugin Development
When to use this skill
Use this skill when:
- Creating or modifying an Obsidian plugin
- Working with the
obsidiannpm package TypeScript API - Building plugin UI: views, modals, settings tabs, commands, ribbon icons, context menus, status bar
- Performing vault file operations (read, create, modify, delete, rename)
- Manipulating the editor (cursor, selection, transactions)
- Working with the workspace (leaves, tabs, splits, sidebars)
- Using MetadataCache for frontmatter, links, tags, headings
- Handling events (vault, workspace, editor, metadata changes)
- Rendering markdown programmatically
- Making HTTP requests from a plugin (use
requestUrl, notfetch) - Using the Obsidian CLI for automation, scripting, or developer tooling
- Debugging or testing an Obsidian plugin
Plugin anatomy
An Obsidian plugin consists of:
my-plugin/
├── main.ts # Entry point: default export extending Plugin
├── manifest.json # Plugin metadata (id, name, version, minAppVersion)
├── styles.css # Optional: plugin styles
├── package.json # npm dependencies
├── tsconfig.json # TypeScript config
└── esbuild.config.mjs # Build config (or rollup/vite)
manifest.json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Description of plugin",
"author": "Author Name",
"authorUrl": "https://example.com",
"isDesktopOnly": false
}
Main plugin class
import { Plugin } from 'obsidian';
export default class MyPlugin extends Plugin {
settings: MySettings;
async onload() {
// Called when plugin is activated
await this.loadSettings();
this.addSettingTab(new MySettingTab(this.app, this));
this.addCommand({ id: 'my-cmd', name: 'My Command', callback: () => {} });
this.addRibbonIcon('icon-name', 'Tooltip', () => {});
this.registerView('view-type', (leaf) => new MyView(leaf));
this.registerEvent(this.app.vault.on('modify', (file) => {}));
}
onunload() {
// Called when plugin is deactivated - cleanup happens automatically
// for anything registered via this.register*() or this.add*()
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
Core API classes
The app object (available as this.app in Plugin subclasses) provides access to the entire API:
| Property | Class | Purpose |
|---|---|---|
app.vault |
Vault |
File CRUD operations |
app.workspace |
Workspace |
Panes, tabs, views, layout |
app.metadataCache |
MetadataCache |
Cached file metadata, link resolution |
app.fileManager |
FileManager |
High-level file operations, frontmatter |
Vault - File operations
// Read
const content = await this.app.vault.read(file); // async read
const cached = await this.app.vault.cachedRead(file); // faster, may be stale
// Create
const newFile = await this.app.vault.create('path/file.md', 'content');
await this.app.vault.createFolder('path/folder');
// Modify
await this.app.vault.modify(file, 'new content');
await this.app.vault.append(file, '\nappended text');
// Atomic read-modify-write
await this.app.vault.process(file, (data) => {
return data.replace('old', 'new');
});
// Delete and rename
await this.app.vault.delete(file);
await this.app.vault.trash(file, true); // system trash
await this.app.vault.rename(file, 'new/path.md');
// Find files
const file = this.app.vault.getAbstractFileByPath('notes/note.md');
if (file instanceof TFile) {
/* use it */
}
const allMd = this.app.vault.getMarkdownFiles();
const allFiles = this.app.vault.getFiles();
IMPORTANT: Always use vault.getAbstractFileByPath() and check instanceof TFile/TFolder. Never construct TFile/TFolder objects directly.
Workspace - Views, leaves, layout
// Get active editor/file
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
const file = this.app.workspace.getActiveFile();
// Open files
const leaf = this.app.workspace.getLeaf('tab'); // 'tab' | 'split' | 'window'
await leaf.openFile(file);
await this.app.workspace.openLinkText('Note Name', '', true);
// Find views
const leaves = this.app.workspace.getLeavesOfType('my-view');
this.app.workspace.iterateAllLeaves((leaf) => {
/* ... */
});
// Sidebar leaves
const rightLeaf = this.app.workspace.getRightLeaf(false);
// Layout readiness
this.app.workspace.onLayoutReady(() => {
/* safe to access UI */
});
MetadataCache - Parsed file metadata
const cache = this.app.metadataCache.getFileCache(file);
if (cache) {
cache.headings; // HeadingCache[]
cache.tags; // TagCache[]
cache.links; // LinkCache[]
cache.embeds; // EmbedCache[]
cache.frontmatter; // FrontMatterCache
cache.sections; // SectionCache[]
}
// Resolve a wikilink to a TFile
const target = this.app.metadataCache.getFirstLinkpathDest('Note', 'source/path.md');
FileManager - High-level file operations
// Process frontmatter (atomic read-modify-write for YAML)
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm.tags = ['tag1', 'tag2'];
fm.modified = new Date().toISOString();
});
// Rename with metadata updates (updates links across vault)
await this.app.fileManager.renameFile(file, 'new/path.md');
Editor - Text manipulation
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
const editor = view.editor;
// Cursor and selection
const cursor = editor.getCursor(); // { line, ch }
const selection = editor.getSelection();
editor.replaceSelection('replacement');
editor.setSelection(anchor, head);
// Content
const value = editor.getValue();
const line = editor.getLine(lineNum);
editor.replaceRange('text', from, to);
// Batch operations
editor.transaction({ changes: [{ from, to, text: 'new' }] });
// Scroll
editor.scrollIntoView({ from, to }, true);
}
UI components
Commands
this.addCommand({
id: 'unique-command-id',
name: 'Human-readable name',
callback: () => {
/* runs always */
},
// OR for editor commands:
editorCallback: (editor: Editor, view: MarkdownView) => {
/* runs when editor active */
},
// OR conditional:
checkCallback: (checking: boolean) => {
if (canRun) {
if (!checking) {
doStuff();
}
return true;
}
return false;
},
hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'p' }],
});
Modals
class MyModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
this.setTitle('My Modal');
this.contentEl.createEl('p', { text: 'Content here' });
new Setting(this.contentEl).setName('Option').addToggle((t) => t.setValue(true).onChange((v) => {}));
}
onClose() {
this.contentEl.empty();
}
}
// Usage: new MyModal(this.app).open();
Settings tabs
class MySettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting name')
.setDesc('Description')
.addText((text) =>
text
.setPlaceholder('placeholder')
.setValue(this.plugin.settings.value)
.onChange(async (v) => {
this.plugin.settings.value = v;
await this.plugin.saveSettings();
})
);
// Setting control types: addText, addTextArea, addToggle, addDropdown,
// addSlider, addButton, addExtraButton, addColorPicker, addSearch
}
}
Custom views
class MyView extends ItemView {
getViewType() { return 'my-view-type'; }
getDisplayText() { return 'My View'; }
getIcon() { return 'bot'; }
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.createEl('h4', { text: 'My View' });
this.addAction('refresh-cw', 'Refresh', () => { /* header action */ });
}
async onClose() { /* cleanup */ }
}
// Register in onload:
this.registerView('my-view-type', (leaf) => new MyView(leaf));
// Activate:
async activateView() {
let leaf = this.app.workspace.getLeavesOfType('my-view-type')[0];
if (!leaf) {
leaf = this.app.workspace.getRightLeaf(false)!;
await leaf.setViewState({ type: 'my-view-type', active: true });
}
this.app.workspace.revealLeaf(leaf);
}
Other UI
// Ribbon icon
this.addRibbonIcon('dice', 'Tooltip text', (evt) => {});
// Status bar
const statusEl = this.addStatusBarItem();
statusEl.setText('Status text');
// Notice (toast)
new Notice('Message to user');
new Notice('Persistent message', 0); // 0 = no auto-dismiss
// Context menu
this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => {
menu.addItem((item) => {
item
.setTitle('My action')
.setIcon('icon')
.onClick(() => {});
});
})
);
// Editor context menu
this.registerEvent(
this.app.workspace.on('editor-menu', (menu, editor, info) => {
menu.addItem((item) => {
item.setTitle('Editor action').onClick(() => {});
});
})
);
// HTML elements (Obsidian extends HTMLElement)
const div = containerEl.createDiv({ cls: 'my-class', text: 'Hello' });
const el = containerEl.createEl('span', { cls: 'highlight', attr: { 'data-id': '1' } });
el.empty(); // remove all children
el.addClass('new-class');
el.toggleClass('active', true);
// Icons (Lucide icons built in)
import { setIcon } from 'obsidian';
setIcon(element, 'lucide-icon-name'); // e.g. 'bot', 'settings', 'file-text'
Events
Always use this.registerEvent() to ensure automatic cleanup on plugin unload:
// Vault events
this.registerEvent(this.app.vault.on('create', (file) => {}));
this.registerEvent(this.app.vault.on('modify', (file) => {}));
this.registerEvent(this.app.vault.on('delete', (file) => {}));
this.registerEvent(this.app.vault.on('rename', (file, oldPath) => {}));
// Workspace events
this.registerEvent(this.app.workspace.on('file-open', (file) => {}));
this.registerEvent(this.app.workspace.on('active-leaf-change', (leaf) => {}));
this.registerEvent(this.app.workspace.on('layout-change', () => {}));
this.registerEvent(this.app.workspace.on('editor-change', (editor, info) => {}));
// MetadataCache events
this.registerEvent(this.app.metadataCache.on('changed', (file, data, cache) => {}));
this.registerEvent(this.app.metadataCache.on('resolved', () => {}));
// Timed intervals
this.registerInterval(window.setInterval(() => {}, 60000));
// DOM events (auto-cleaned up)
this.registerDomEvent(document, 'click', (evt) => {});
Network requests
Always use requestUrl instead of fetch for cross-platform compatibility (desktop + mobile):
import { requestUrl } from 'obsidian';
const response = await requestUrl({
url: 'https://api.example.com/data',
method: 'POST',
headers: { Authorization: 'Bearer TOKEN' },
contentType: 'application/json',
body: JSON.stringify({ key: 'value' }),
});
const data = response.json; // parsed JSON
const text = response.text; // raw text
const status = response.status;
Markdown rendering
import { MarkdownRenderer } from 'obsidian';
await MarkdownRenderer.render(
this.app,
'# Heading\nSome **bold** and a [[link]].',
containerEl,
'', // sourcePath for resolving links
this // Component for lifecycle management
);
Key best practices
- Always use Obsidian API over low-level alternatives:
vault.getMarkdownFiles()notvault.adapter.list(),fileManager.processFrontMatter()not manual YAML parsing,app.metadataCachenot re-parsing files - Register everything for cleanup: Use
this.registerEvent(),this.registerInterval(),this.registerDomEvent(),this.addCommand(),this.addSettingTab(),this.registerView()- they all auto-clean on unload - Use
requestUrlnotfetch: Required for mobile compatibility - Wait for layout: Use
this.app.workspace.onLayoutReady()before accessing UI - Check
instanceof: Always verifyTFilevsTFolderwhen usinggetAbstractFileByPath() - Use CSS variables: Use Obsidian's theme variables for styling, test in both light and dark themes
- Use
setIcon(): Useimport { setIcon } from 'obsidian'with Lucide icon names - Debounce frequent operations: Use
import { debounce } from 'obsidian'for editor changes or API calls - Use normalized paths: Obsidian uses forward slashes on all platforms
- Never block the main thread: Use
async/awaitfor file operations, API calls, and heavy computations
Obsidian CLI
Obsidian 1.12+ includes a CLI for terminal-based vault interaction. See the CLI reference for complete command documentation.
Key developer commands:
obsidian dev:eval code="expression"- Execute JavaScript in the app consoleobsidian dev:console- View captured console messagesobsidian dev:dom query="selector"- Query DOM elementsobsidian dev:css query="selector"- Inspect CSS with source locationsobsidian dev:screenshot- Capture app screenshotsobsidian dev:mobile- Toggle mobile emulationobsidian dev:errors- Display captured errorsobsidian plugin:reload- Reload plugins during development
Further reading
- TypeScript API Reference - Complete class and interface definitions
- CLI Reference - Full Obsidian CLI command documentation
- Best Practices - Detailed patterns and anti-patterns