Obsidian Core Workflow A: Create a Plugin from Scratch
Overview
Build a complete Obsidian plugin from an empty directory. By the end you will have a working plugin with a ribbon icon, command palette entries, a settings tab, and a production esbuild build. Every file is shown in full -- no stubs.
Prerequisites
- Node.js 18+ installed
- Obsidian desktop app installed
- A vault to test in (create a fresh vault at
~/ObsidianDevif needed)
Instructions
Step 1: Scaffold the project
set -euo pipefail
PLUGIN_NAME="my-obsidian-plugin"
mkdir -p "$PLUGIN_NAME/src"
cd "$PLUGIN_NAME"
# Initialize Node project
npm init -y
# Install Obsidian types and build tool
npm install --save-dev obsidian@latest typescript@latest esbuild@latest \
@types/node@latest tslib@latest
# TypeScript config
cat > tsconfig.json << 'TSEOF'
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2018",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES2018", "ES2021.String"]
},
"include": ["src/**/*.ts"]
}
TSEOF
echo "Scaffolding complete."
Step 2: Create manifest.json
Every Obsidian plugin needs a manifest.json at the project root. This is what
Obsidian reads to register the plugin.
{
"id": "my-obsidian-plugin",
"name": "My Obsidian Plugin",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "A starter Obsidian plugin.",
"author": "Your Name",
"isDesktopOnly": false
}
Step 3: Write the esbuild config
// esbuild.config.mjs
import esbuild from "esbuild";
import process from "process";
const prod = process.argv[2] === "production";
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}
Step 4: Write main.ts -- the full plugin
This single file contains the Plugin subclass, a settings interface with defaults, a settings tab, and three commands.
// src/main.ts
import {
App,
Editor,
MarkdownView,
Notice,
Plugin,
PluginSettingTab,
Setting,
} from "obsidian";
// ── Settings ────────────────────────────────────────────────────────
interface MyPluginSettings {
greeting: string;
showRibbon: boolean;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
greeting: "Hello from My Plugin!",
showRibbon: true,
};
// ── Plugin ──────────────────────────────────────────────────────────
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// Ribbon icon -- shows a Notice when clicked
if (this.settings.showRibbon) {
this.addRibbonIcon("sparkles", "My Plugin: Greet", () => {
new Notice(this.settings.greeting);
});
}
// Command: show greeting as Notice
this.addCommand({
id: "show-greeting",
name: "Show greeting",
callback: () => {
new Notice(this.settings.greeting);
},
});
// Command: insert greeting at cursor (only available in editor)
this.addCommand({
id: "insert-greeting",
name: "Insert greeting at cursor",
editorCallback: (editor: Editor, view: MarkdownView) => {
editor.replaceSelection(this.settings.greeting);
},
});
// Command: count words in current note
this.addCommand({
id: "count-words",
name: "Count words in current note",
editorCallback: (editor: Editor) => {
const text = editor.getValue();
const count = text.split(/\s+/).filter(Boolean).length;
new Notice(`Word count: ${count}`);
},
});
// Status bar item
const statusEl = this.addStatusBarItem();
statusEl.setText("Plugin loaded");
// Settings tab
this.addSettingTab(new MyPluginSettingTab(this.app, this));
console.log("MyPlugin loaded");
}
onunload() {
console.log("MyPlugin unloaded");
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}
// ── Settings Tab ────────────────────────────────────────────────────
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("Greeting message")
.setDesc("Text shown by the greet command and ribbon icon.")
.addText((text) =>
text
.setPlaceholder("Hello from My Plugin!")
.setValue(this.plugin.settings.greeting)
.onChange(async (value) => {
this.plugin.settings.greeting = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Show ribbon icon")
.setDesc("Toggle the sparkles icon in the left ribbon.")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showRibbon)
.onChange(async (value) => {
this.plugin.settings.showRibbon = value;
await this.plugin.saveSettings();
new Notice("Reload plugin to apply ribbon change.");
})
);
}
}
Step 5: Add npm scripts and build
Add these scripts to package.json:
{
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
}
}
Build the plugin:
set -euo pipefail
npm run build
# Output: main.js at project root
ls -la main.js manifest.json
Step 6: Install into your vault and test
set -euo pipefail
VAULT="$HOME/ObsidianDev"
PLUGIN_ID="my-obsidian-plugin"
# Create plugin directory in vault
mkdir -p "$VAULT/.obsidian/plugins/$PLUGIN_ID"
# Copy build artifacts
cp main.js manifest.json "$VAULT/.obsidian/plugins/$PLUGIN_ID/"
echo "Plugin installed. Open Obsidian, enable it in Settings > Community plugins."
In Obsidian:
- Settings > Community plugins > Enable community plugins
- Find "My Obsidian Plugin" in the list, toggle it on
- Click the sparkles icon in the left ribbon
- Open command palette (Ctrl/Cmd+P), search "Show greeting"
- Open Settings > My Obsidian Plugin to change the greeting text
Output
A complete plugin directory containing:
manifest.json-- plugin metadata Obsidian readssrc/main.ts-- Plugin subclass with commands, ribbon icon, settings tabesbuild.config.mjs-- bundler with watch mode supportmain.js-- production build outputpackage.json+tsconfig.json-- standard Node/TS project files
Error Handling
| Error | Cause | Fix |
|-------|-------|-----|
| Cannot find module 'obsidian' | Missing dev dependency | npm install --save-dev obsidian |
| Plugin not in list | manifest.json missing or invalid | Verify id field matches folder name |
| Ribbon icon missing | Invalid icon name | Use a Lucide icon name (sparkles, file-text, search, etc.) |
| Settings not persisting | Forgot await this.saveData() | Always await saveData in onChange |
| editorCallback command greyed out | No active editor | Open a markdown note first |
| Build fails with external error | Forgot to externalize obsidian | Check external array in esbuild config |
Examples
Minimal manifest.json for community submission:
{
"id": "my-obsidian-plugin",
"name": "My Obsidian Plugin",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Does one useful thing.",
"author": "Your Name",
"authorUrl": "https://github.com/yourname",
"isDesktopOnly": false
}
Adding a hotkey-enabled command:
this.addCommand({
id: "toggle-sidebar",
name: "Toggle custom sidebar",
// Users can assign a hotkey in Settings > Hotkeys
callback: () => this.toggleSidebar(),
});
Resources
- Obsidian Sample Plugin -- official starter
- Obsidian Plugin API Reference
- Lucide Icons -- icon names for
addRibbonIcon - esbuild Documentation
Next Steps
- Add custom views and modals: see
obsidian-core-workflow-b - Set up hot-reload development: see
obsidian-local-dev-loop - Apply production patterns: see
obsidian-sdk-patterns