Use when building, debugging, or iterating on an Obsidian plugin (TypeScript, `manifest.json` + `main.js`). Covers scaffolding a repo, the build/copy/reload deploy loop, registering commands and events, settings UI, vault I/O, `requestUrl` networking, `safeStorage` secrets, pure-core testing, and the `obsidian-cli` debug loop (`dev:debug on`, `dev:console`, `eval`). Invoke on requests like "scaffold an Obsidian plugin", "my plugin's event handler isn't firing", "how do I reload my plugin without restarting Obsidian", or any task that touches `.obsidian/plugins/<id>/`.
How this skill is triggered — by the user, by Claude, or both
Slash command
/obsidian-plugin-creator:obsidian-plugin-creatorThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Distilled from building `jira-bases` (JIRA link insertion, hover preview, auto-lookup, stub sync). Everything below has been exercised end-to-end through the `obsidian-cli` deploy+debug loop.
references/adapter-pattern.tsreferences/developer-policies.mdreferences/plugin-guidelines.mdreferences/runbook.mdreferences/scaffold/esbuild.config.mjsreferences/scaffold/main.tsreferences/scaffold/manifest.json.tmplreferences/scaffold/package.json.tmplreferences/scaffold/settings.tsreferences/scaffold/tsconfig.jsonreferences/scaffold/vitest.config.tsreferences/settings-coupled-controls.tsDistilled from building jira-bases (JIRA link insertion, hover preview, auto-lookup, stub sync). Everything below has been exercised end-to-end through the obsidian-cli deploy+debug loop.
The canonical long-form narrative lives in references/runbook.md — read it when this skill's summary is not enough.
Before scaffolding anything, confirm:
node and npm are on PATH.obsidian CLI is installed and the target vault is the active one. Check with:
obsidian vault # prints name<TAB>path for the active vault
Settings → Community plugins → Turn on). This is a one-time manual step; the CLI cannot flip it.Cache the vault path at the start of the session. All <vault> placeholders refer to it.
Args: <id> <name> <description>.
manifest.json.id AND the folder name under .obsidian/plugins/. Never change it — settings are keyed on it.Target repo layout:
<plugin-repo>/
manifest.json # id, name, version, minAppVersion, main: "main.js"
package.json # dev deps: obsidian, esbuild, typescript, vitest
tsconfig.json # strict, module: esnext, target: es2020
esbuild.config.mjs # src/main.ts → main.js (CJS, external: obsidian)
vitest.config.ts
src/
main.ts # Plugin subclass, lifecycle, command registration
settings.ts # PluginSettings + PluginSettingTab
<feature>.ts # pure modules, no obsidian imports where possible
<feature>.test.ts
main.js # built output; ship alongside manifest.json
Copy-paste templates are in references/scaffold/ — see §11 for the exact file list. Substitute <id>, <name>, <description> before writing.
Commit main.js if you want BRAT-style GitHub installs; otherwise gitignore it and tag release builds.
Rename every placeholder the sample ships with: MyPlugin, MyPluginSettings, SampleSettingTab, etc. Reviewers flag these on submission. Organize src/ into subfolders once the plugin outgrows a handful of files.
The load-bearing workflow is two-phase. First install and subsequent reload use different CLI verbs — conflating them is the #1 cause of a broken initial deploy.
PLUGIN_ID=<id>
VAULT=$(obsidian vault | awk -F'\t' '/^path\t/{print $2}')
DEST="$VAULT/.obsidian/plugins/$PLUGIN_ID"
mkdir -p "$DEST"
[ -d node_modules ] || npm ci # first run after clone/scaffold — esbuild, typescript, etc.
node esbuild.config.mjs production
cp main.js manifest.json "$DEST/"
[ -f styles.css ] && cp styles.css "$DEST/"
After the copy, pick the right reload path:
obsidian plugin:reload id="$PLUGIN_ID"
Fast, preserves the running app. Use this every iteration once the plugin is already installed and enabled.
obsidian plugin:reload fails with "Plugin not found" on the very first copy — Obsidian hasn't scanned .obsidian/plugins/<id>/ yet, so the id is unknown to the plugin manager. Rescan the manifests and enable in one shot via obsidian eval:
obsidian eval code='(async()=>{
await app.plugins.loadManifests();
await app.plugins.enablePluginAndSave("'"$PLUGIN_ID"'");
})()'
loadManifests() picks up newly-copied plugin folders; enablePluginAndSave() enables + persists the choice and is a no-op if the plugin is already enabled. This block is idempotent — safe to run every iteration if you'd rather not branch. The "CLI has no install & enable verb" claim in older docs is wrong; this is that verb.
A robust deploy helper:
obsidian plugin:reload id="$PLUGIN_ID" 2>/dev/null || \
obsidian eval code='(async()=>{
await app.plugins.loadManifests();
await app.plugins.enablePluginAndSave("'"$PLUGIN_ID"'");
})()'
Do not rm -rf the plugin folder between runs — data.json lives there and holds user settings (incl. encrypted secrets).
Prerequisite (one-time, manual): the vault must have Community plugins enabled globally (§0). The CLI cannot flip that switch.
this.addCommand({
id: "do-thing", // plugin-id prefix is added automatically
name: "My plugin: do the thing",
editorCallback: (editor) => this.doThing(editor),
});
Pick the narrowest callback shape (official guidance):
callback — runs unconditionally.checkCallback — only runs under certain conditions; return true from the checking === true branch to show it in the palette.editorCallback / editorCheckCallback — requires an active Markdown editor; Obsidian gates availability for you.Do not set a default hotkey (hotkeys: [...]). Defaults collide across OSes and stomp user-configured bindings. Let the user assign one.
The palette is the human surface. Agents driving a vault through the obsidian CLI can't reach a palette-only command, and neither can scripts or keyboard launchers firing obsidian:// URIs. Every command an agent or user might invoke should be exposed via the palette and the CLI. Fire-and-forget commands (no output needed by the caller) may additionally expose a URI endpoint.
Factor the work into a plain method so all three surfaces call the same code path:
async doThing(target: string): Promise<string> { /* ... */ return "ok"; }
onload() {
// 1. Palette
this.addCommand({
id: "do-thing",
name: "My plugin: do the thing",
callback: () => this.doThing(this.app.workspace.getActiveFile()?.path ?? ""),
});
// 2. CLI subcommand — obsidian 1.12.2+
this.registerCliHandler(
"do-thing",
"Do the thing to a note",
{ path: { value: "<path>", description: "Note path", required: true } },
async (params) => this.doThing(params.path as string), // returns string printed by the CLI
);
// 3. URI endpoint — obsidian://my-plugin-do-thing?path=foo.md (fire-and-forget)
this.registerObsidianProtocolHandler("do-thing", (params) => {
void this.doThing(params.path ?? "");
});
}
registerCliHandler(command, description, flags, handler) — arg 3 is a Record<string, { value?, description, required? }>; omit value for boolean flags. The handler receives a flat params object (string | 'true' values) and must return string | Promise<string> — the CLI prints it. Requires "minAppVersion": "1.12.2" in manifest.json if the plugin depends on it; otherwise feature-detect with typeof this.registerCliHandler === "function".
registerObsidianProtocolHandler(action, handler) — callable as obsidian://<action>?k=v&…. Fire-and-forget: there is no response channel back to the caller, so don't use it for queries whose output matters. Good for "open this", "append this", "trigger this sync".
registerEventthis.registerEvent(
this.app.workspace.on("editor-change", (editor) => this.onEdit(editor)),
);
Raw .on(...) without registerEvent leaks on plugin reload. Same rule for vault.on, metadataCache.on, workspace.on.
For timers: registerInterval(window.setInterval(...)). For DOM listeners on elements you created: registerDomEvent(el, "click", ...).
Editor-event catalog — the commonly useful ones:
| Event | Fires when |
|---|---|
workspace.on("editor-change", (editor, info) => ...) | Any edit to a markdown editor |
workspace.on("file-open", (file) => ...) | User opens a note |
workspace.on("active-leaf-change", (leaf) => ...) | Focus moves between panes |
vault.on("modify", (file) => ...) | File write (incl. programmatic) |
vault.on("rename", (file, oldPath) => ...) | File moved/renamed |
vault.on("delete", (file) => ...) | File deleted |
metadataCache.on("changed", (file) => ...) | Frontmatter/headings re-parsed |
Full catalog: node_modules/obsidian/obsidian.d.ts — grep for '<event>'.
src/settings.ts holds interface PluginSettings, DEFAULT_SETTINGS, and the PluginSettingTab subclass.
async loadSettings() {
this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) };
}
async saveSettings() { await this.saveData(this.settings); }
Persistence: loadData/saveData read/write .obsidian/plugins/<id>/data.json as JSON.
Coupled controls (one control updates another — e.g. a Mode dropdown and a Template text field): capture each Setting's setValue in a local variable so you can update peers without re-rendering the whole tab. See references/settings-coupled-controls.ts. Never call this.display() on change — it loses focus, selection, and scroll position.
Pick the right API — this order is enforced in plugin review:
| Need | API |
|---|---|
| Edit the active note | editor.replaceRange/setValue/... via the Editor API |
| Modify a background note atomically | app.vault.process(tFile, data => newData) |
| Read a note as text | app.vault.read(tFile) / cachedRead(tFile) for bulk scans |
| Create a note | app.vault.create(path, content) |
| Create a folder | app.vault.createFolder(path) (ignore "already exists") |
| Delete (trash) | app.vault.delete(tFile) (Obsidian trash, not permanent) |
| Look up by path (file) | app.vault.getFileByPath(path) |
| Look up by path (folder) | app.vault.getFolderByPath(path) |
| Look up by path (unknown) | app.vault.getAbstractFileByPath(path) + instanceof TFile/TFolder |
| Update frontmatter safely | app.fileManager.processFrontMatter(tFile, fm => { ... }) |
| Rename/move (updates links) | app.fileManager.renameFile(tFile, newPath) |
| List markdown notes | app.vault.getMarkdownFiles() |
| Normalize a user-supplied path | normalizePath(path) from obsidian |
Rules of thumb:
Vault.modify > Vault.process. Vault.modify on the active note loses cursor/selection/fold state; the Editor API preserves it. Vault.process is atomic — use it for background writes so you don't race other plugins editing the same file.app.vault.adapter.*). The Vault API has a read cache and serializes writes; the Adapter API is raw FS and bypasses both.getFiles() / getMarkdownFiles() to find a path. Use getFileByPath / getFolderByPath — O(1) vs O(n).normalizePath anything that came from user input or that you stitched together yourself. It collapses \//, strips leading/trailing slashes, replaces NBSPs, and runs Unicode NFC.fileManager.processFrontMatter over parsing YAML yourself — it preserves formatting and runs atomically. Prefer fileManager.renameFile over vault.rename so wikilinks across the vault get rewritten.Use requestUrl from obsidian for all HTTP. Raw fetch() hits CORS for cross-origin.
import { requestUrl } from "obsidian";
const r = await requestUrl({
url,
method: "GET",
headers: { Authorization: `Bearer ${token}` },
throw: false, // let the caller branch on status
});
if (r.status >= 400) { /* handle */ }
const data = r.json; // already parsed
Wrap it behind a narrow adapter interface (HttpRequest) so the network layer is testable without Obsidian present — see §8.
innerHTML, outerHTML, and insertAdjacentHTML are banned by the plugin guidelines. User-supplied text concatenated into an HTML string is an XSS vector — a note title containing <script> is enough. Use Obsidian's helpers, which escape text for you:
// ❌ Banned
container.innerHTML = `<div class="hit"><b>${name}</b></div>`;
// ✅ Use DOM helpers
const hit = container.createDiv({ cls: "hit" });
hit.createEl("b", { text: name });
// Clear contents
container.empty();
createEl, createDiv, createSpan, and el.empty() are attached to every HTMLElement inside Obsidian. Pass { text, cls, attr, href } in the options object. For anything richer, use document.createElement + appendChild.
app.workspace.getActiveViewOfType(MarkdownView) — returns null if the active view is a different type. Avoid workspace.activeLeaf (the field can lag and is slated for removal).app.workspace.activeEditor?.editor. Works for Markdown editors across main/sidebar/popover surfaces.// ❌ Leaks the view across plugin reloads
this.registerView(MY_VIEW, () => (this.view = new MyView()));
// ✅ Look it up when you need it
this.registerView(MY_VIEW, () => new MyView());
for (const leaf of this.app.workspace.getLeavesOfType(MY_VIEW)) {
const v = leaf.view;
if (v instanceof MyView) { /* ... */ }
}
detach() leaves in onunload. When the user updates your plugin, Obsidian re-opens leaves in their original position; detaching throws away the user's layout.app.workspace.updateOptions():
this.editorExt.length = 0;
this.editorExt.push(this.buildExtension());
this.app.workspace.updateOptions();
Creating a new array breaks the registration; updateOptions is what flushes the change to every open editor.The four rules reviewers flag on submission:
new Setting(el).setName("…").setHeading(), not raw <h1>/<h2>.Full checklist (Notice vs toasts, section grouping, etc.): references/plugin-guidelines.md.
Never store PATs / API keys in data.json as plaintext. Use Electron's safeStorage:
function getSafeStorage() {
const electron = require("electron");
const ss = electron?.remote?.safeStorage
?? require("@electron/remote").safeStorage;
if (!ss) throw new Error("safeStorage unavailable in this Obsidian build");
return ss;
}
const ss = getSafeStorage();
const encrypted = ss.encryptString(token).toString("base64"); // store this
const decrypted = ss.decryptString(Buffer.from(encrypted, "base64"));
Key the encrypted blob in data.json by API base URL so one vault can hold credentials for multiple hosts.
Obsidian's runtime classes (Editor, TFile, Vault) are hostile to unit testing. The pattern that works:
obsidian imports): all non-trivial logic — parsers, scanners, templaters, schedulers. Covered by vitest.VaultAdapter, HttpRequest, IndexerDeps): narrow structural types the pure core depends on. main.ts constructs a concrete impl backed by app.vault.*; tests pass a fake.main.ts, modals, setting tabs): not unit-tested. Exercised via the deploy loop + obsidian eval (§9).Target: 100% of non-trivial logic in pure modules. The glue should be flat enough to read and see correct.
See references/adapter-pattern.ts for a worked example.
Three commands do all the work:
| Command | Purpose |
|---|---|
obsidian dev:debug on | Attach DevTools debugger + start capturing console into a buffer. Persists across plugin reloads. |
obsidian dev:console | Dump the buffer. obsidian dev:console clear empties it. `level=log |
obsidian eval code='<js>' | Run arbitrary JS in the renderer with access to app, window, every plugin instance. Returns last expression. |
obsidian help lists everything. Never obsidian <subcommand> --help — the CLI treats --help as note content and creates a junk Untitled N.md.
If obsidian plugin:reload id=<id> fails with "Plugin not found", the plugin manager hasn't seen the folder yet — this is the first-install case. Use the loadManifests() + enablePluginAndSave() eval from §2 instead of restarting the app.
The CLI prints the last expression via JSON.stringify(value, null, 2) prefixed with => . A few failure modes look like "the eval silently ate my output":
return inside an async IIFE → the promise resolves to undefined → blank output, no error. By far the most common cause of "empty output." Always return explicitly from (async()=>{ … })().app, a live plugin instance, or anything holding a back-reference to app → Error: Converting circular structure to JSON on stdout. Project out the fields you need (return { id: p.manifest.id, enabled: !!p.settings, hasStore: !!p.store }) rather than returning the instance.Map / Set serialize to {} silently — JSON.stringify ignores them. Convert first: [...map.entries()], [...set].undefined anywhere in the return → blank line. Use null if you need to signal "nothing."globalThis in one eval and read back a projected string in a second eval.Plain objects, nested objects, arrays (including thousands of elements), strings, numbers, booleans, and null all print fine — complexity itself is not the problem.
obsidian dev:debug on
obsidian plugin:reload id=<id>
obsidian dev:console clear
# Simulate typing at the end of the active note
obsidian eval code='(()=>{
const ed = app.workspace.activeEditor?.editor;
if (!ed) return "no editor";
const p = { line: ed.lineCount()-1, ch: ed.getLine(ed.lineCount()-1).length };
ed.replaceRange("\nSRE-2222 ", p, p);
return "typed";
})()'
sleep 4
obsidian dev:console
obsidian eval code='(()=>{
const p = app.plugins.plugins["<id>"];
return { enabled: p.settings.autoLookupEnabled, pending: p.scheduler?.pending };
})()'
ed.replaceRange(text, ed.getCursor(), ed.getCursor()){line: ed.lineCount()-1, ch: <len>} and replaceRange thereed.setSelection(from, to)app.commands.executeCommandById("<id>:<command-id>")app.workspace.trigger("file-open", file)app.metadataCache.getFileCache(tFile)?.frontmatterFor DOM-clicky flows (modals, settings tabs, hover popovers), open Obsidian manually — don't script clicks through eval.
| Expression | What it gives |
|---|---|
app.plugins.plugins["<id>"] | Your plugin instance (settings, methods, private state) |
app.plugins.enabledPlugins | Set of enabled plugin ids |
app.workspace.activeEditor.editor | Current CodeMirror Editor |
app.workspace.getActiveFile() | Current TFile |
app.metadataCache.getFileCache(tFile) | Parsed frontmatter + headings + links |
app.commands.listCommands() | Every registered command |
app.commands.executeCommandById(id) | Run any command |
Every item here cost real time on jira-bases. Treat it as a preflight.
Illegal invocation on setTimeout/setIntervalElectron's renderer rejects window.setTimeout calls that arrive without the native this:
// ❌ Silently dies inside async paths
const deps = { setTimeout, clearTimeout };
deps.setTimeout(fn, 1000); // "Illegal invocation"
// ✅ Wrap to preserve the binding
const deps = {
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (t) => clearTimeout(t),
};
Same trap for requestAnimationFrame, queueMicrotask, fetch, addEventListener when extracted onto an object. Symptom: handler logs, timer property exists, callback never runs. Error often swallows into an unawaited promise rejection.
When the plugin writes [text](url) into a note:
[, ], \, <, > inside the anchor text.(, ), and spaces in URLs to %28, %29, %20.Any feature that scans a markdown file and rewrites matched substrings (auto-linking, auto-tagging, inline replacement) must skip the leading --- block, or you'll corrupt frontmatter and form a feedback loop:
jira_issues: [KEY-1] to frontmatter.KEY-1 inside frontmatter to [KEY-1](url).metadataCache returns null frontmatter.Also skip: code fences ``` ... ```, inline code `...`, existing link URLs, [[wikilinks]], and [text](url) payloads. A simple frontmatter-fence walker + "don't touch text already inside [](...)/[[...]]" catches 95% of cases without a full markdown parser.
obsidian search query="prefix: FOO" — fails. The CLI parses <word>: as a search operator. Use filesystem scans for frontmatter lookups.obsidian search can crash with ENOENT on a stale index entry. Restart Obsidian or reindex. Don't rely on it for correctness.obsidian move path=<src> to=<dst> — to=, not dest=.obsidian create forces .md. For .base / .canvas / .css, write via the filesystem.obsidian plugin:reload id=<id> fails with "Plugin not found" on first install (folder not yet scanned). Use the loadManifests() + enablePluginAndSave() eval from §2.property:add / property:append. To append to a list property: read → append in memory → property:set name=... value='[...]' type=list ....obsidian <sub> --help — writes an Untitled N.md. Use obsidian help at top level only.If manifest.json has "isDesktopOnly": false, your plugin will load on iOS and Android — where Node and Electron APIs do not exist. Guard any require("fs"), require("path"), require("electron"), child_process, etc. behind Platform.isDesktopApp from obsidian, or bail out early on mobile:
import { Platform } from "obsidian";
if (Platform.isDesktopApp) {
const fs = require("fs");
// ...
}
Regex lookbehind ((?<=...)) still crashes on older iOS WebViews. If you need to match "X preceded by Y", consume Y and back off instead of using lookbehind. Lookahead is fine everywhere.
No hardcoded element.style.* for colors, sizes, or backgrounds — themes and snippets can't override it. Ship a styles.css alongside manifest.json / main.js and use Obsidian's CSS variables for anything that should track the theme:
.my-plugin-warning {
color: var(--text-normal);
background-color: var(--background-modifier-error);
border: 1px solid var(--interactive-accent);
}
Common variables: --text-normal, --text-muted, --text-faint, --background-primary, --background-secondary, --background-modifier-{border,error,success,hover}, --interactive-accent, --interactive-hover. Full list in the Obsidian CSS variables reference.
Default Obsidian only surfaces console.error to users. Don't ship console.log / console.debug / console.info for normal operation — strip diagnostic logs before release or gate them behind a settings.debug flag. console.error is fine for actual errors.
setInterval in onload without registerInterval — leaks on reload..bind(this) or an arrow wrapper — this is lost.app / window.app — always use this.app from your Plugin subclass. The global is a debugging convenience and may be removed.onunload starts — plugin is gone, writes are void. Guard with a disposed flag set in onunload.fetch() against third-party APIs — CORS. Use requestUrl.processFrontMatter.data.json — use safeStorage.setValue callbacks.If you'll submit to the Obsidian Community Plugins directory, the Developer policies gate acceptance. The ones most likely to sink a submission if missed:
LICENSE file and keep manifest.json + versions.json accurate — the updater uses versions.json to gate installs on older Obsidian versions.Full checklist (every prohibition, every disclosure, trademark rules): references/developer-policies.md.
Under references/:
runbook.md — the full narrative this skill distills from.plugin-guidelines.md — cheat sheet of the official Obsidian plugin review rules.developer-policies.md — cheat sheet of the community-directory policies (what's banned, what needs disclosure, repo hygiene).scaffold/manifest.json.tmpl — manifest.json with <ID> / <NAME> / <DESCRIPTION> placeholders.scaffold/package.json.tmpl — dev-deps + build/dev/test scripts.scaffold/tsconfig.json — strict, esnext, es2020.scaffold/esbuild.config.mjs — CJS bundle, external: obsidian.scaffold/vitest.config.ts.scaffold/main.ts — lifecycle skeleton.scaffold/settings.ts — settings + tab skeleton.scaffold/.gitignore.settings-coupled-controls.ts — §4 pattern.adapter-pattern.ts — §8 pattern.main.ts behind the smallest possible glue (event handler → pure function → vault/editor API).obsidian eval (§9).obsidian dev:console. If silent, add console.log checkpoints and repeat.obsidian eval code='app.plugins.plugins["<id>"]...' when logs run out.obsidian dev:debug on + obsidian eval + obsidian dev:console turns the plugin into a REPL-able surface. Use it before you guess.
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
npx claudepluginhub earchibald/obsidian-plugin-development --plugin obsidian-plugin-creator