obsidian-plugin-development
Skill534 starsChanged 36 days agoMITView on GitHub
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.
We split this skill into the rules it gives an agent
11 rules. About 0 are common, 2 are distinctive, and 9 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
9
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 20
Use this skill when:
- Very specific: line 142
IMPORTANT: Always use vault.getAbstractFileByPath() and check instanceof TFile/TFolder.
- Very specific: line 392
Always use this.registerEvent() to ensure automatic cleanup on plugin unload:
- Very specific: line 420
Always use requestUrl instead of fetch for cross-platform compatibility (desktop + mobile):
- Very specific: line 454
Register everything for cleanup: Use this.registerEvent(), this.registerInterval(), this.registerDomEvent(), this.addCommand(), …
- Very specific: line 456
Wait for layout: Use this.app.workspace.onLayoutReady() before accessing UI
- Very specific: line 457
Check
instanceof: Always verify TFile vs TFolder when using getAbstractFileByPath() - Distinctive: line 458
Use CSS variables: Use Obsidian's theme variables for styling, test in both light and dark themes
- Very specific: line 459
Use
setIcon(): Use import { setIcon } from 'obsidian' with Lucide icon names - Very specific: line 460
Debounce frequent operations: Use import { debounce } from 'obsidian' for editor changes or API calls
- Very specific: line 462
Never block the main thread: Use async/await for file operations, API calls, and heavy computations
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- delorenj/skills★ 18same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository8 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- eval-harnessSkill
- gemini-api-devSkill
- obsidian-cliSkill
- plugin-testSkill
- triage-issuesSkill
- ui-ux-guidelinesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

