| name | obsidian-plugin-creator |
| description | 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>/`. |
Obsidian plugin creator
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.
The canonical long-form narrative lives in references/runbook.md โ read it when this skill's summary is not enough.
0. Prerequisites
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
- The vault has Community plugins enabled (
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.
1. Scaffold
Args: <id> <name> <description>.
- id: lowercase-with-hyphens, must match
manifest.json.id AND the folder name under .obsidian/plugins/. Never change it โ settings are keyed on it.
- name: human-readable, shown in Settings.
- description: one sentence for the marketplace/settings row.
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.
2. Deploy loop (build โ copy โ install-or-reload)
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
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:
Subsequent reloads (hot path)
obsidian plugin:reload id="$PLUGIN_ID"
Fast, preserves the running app. Use this every iteration once the plugin is already installed and enabled.
First install (and idempotent fallback)
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.
3. Commands and events
addCommand
this.addCommand({
id: "do-thing",
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.
Command surfaces โ palette, CLI, URI
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() {
this.addCommand({
id: "do-thing",
name: "My plugin: do the thing",
callback: () => this.doThing(this.app.workspace.getActiveFile()?.path ?? ""),
});
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),
);
this.registerObsidianProtocolHandler(, {
.(params. ?? );
});
}
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".
Events โ always use registerEvent
this.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>'.
4. Settings tab
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.
5. Vault I/O
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:
- Editor >
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.
- Prefer the Vault API over the Adapter API (
app.vault.adapter.*). The Vault API has a read cache and serializes writes; the Adapter API is raw FS and bypasses both.
- Never iterate
getFiles() / getMarkdownFiles() to find a path. Use getFileByPath / getFolderByPath โ O(1) vs O(n).
- Always
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.
- Prefer
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.
6. Network
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,
});
if (r.status >= 400) { }
const data = r.json;
Wrap it behind a narrow adapter interface (HttpRequest) so the network layer is testable without Obsidian present โ see ยง8.
6.5 DOM construction (safety)
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:
container.innerHTML = `<div class="hit"><b>${name}</b></div>`;
const hit = container.createDiv({ cls: "hit" });
hit.createEl("b", { text: name });
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.
6.6 Workspace, views, and the Editor
- Active view:
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).
- Active editor:
app.workspace.activeEditor?.editor. Works for Markdown editors across main/sidebar/popover surfaces.
- Custom views โ register the factory, don't store the instance:
this.registerView(MY_VIEW, () => (this.view = new MyView()));
this.registerView(MY_VIEW, () => new MyView());
for (const leaf of this.app.workspace.getLeavesOfType(MY_VIEW)) {
const v = leaf.view;
if (v instanceof MyView) { }
}
- Don't
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.
- Editor extensions โ to hot-swap a registered extension, mutate the same array reference and call
app.workspace.updateOptions():
this.. = ;
..(.());
...();
Creating a new array breaks the registration; is what flushes the change to every open editor.
6.7 UI text and settings layout
The four rules reviewers flag on submission:
- Sentence case ("Template folder location", not "Template Folder Location").
- No top-level heading in the settings tab (no "General", no "Settings", no plugin name).
- No "settings" inside section headings ("Advanced", not "Advanced settings").
- Use
new Setting(el).setName("โฆ").setHeading(), not raw <h1>/<h2>.
Full checklist (Notice vs toasts, section grouping, etc.): references/plugin-guidelines.md.
7. Secrets (safeStorage)
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");
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.
8. Pure-core testing
Obsidian's runtime classes (Editor, TFile, Vault) are hostile to unit testing. The pattern that works:
- Pure modules (no
obsidian imports): all non-trivial logic โ parsers, scanners, templaters, schedulers. Covered by vitest.
- Adapter interfaces (
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.
- Thin Obsidian-aware glue (
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.
9. Debugging with obsidian-cli
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.
Eval return-value gotchas
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":
- Missing
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()=>{ โฆ })().
- Returning
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."
- If you genuinely need a large/circular object round-tripped for a smoke test, stash it on
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.
Standard debug cycle
obsidian dev:debug on
obsidian plugin:reload id=<id>
obsidian dev:console clear
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
Live-state inspection
obsidian eval code='(()=>{
const p = app.plugins.plugins["<id>"];
return { enabled: p.settings.autoLookupEnabled, pending: p.scheduler?.pending };
})()'
Simulating user input (no click/type possible, but eval covers ~95%)
- Type at cursor:
ed.replaceRange(text, ed.getCursor(), ed.getCursor())
- Append to end: compute
{line: ed.lineCount()-1, ch: <len>} and replaceRange there
- Select:
ed.setSelection(from, to)
- Run command:
app.commands.executeCommandById("<id>:<command-id>")
- Fire event:
app.workspace.trigger("file-open", file)
- Read frontmatter:
app.metadataCache.getFileCache(tFile)?.frontmatter
For DOM-clicky flows (modals, settings tabs, hover popovers), open Obsidian manually โ don't script clicks through eval.
Useful globals
| 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 |
10. Gotchas โ check before you ship
Every item here cost real time on jira-bases. Treat it as a preflight.
10.1 Illegal invocation on setTimeout/setInterval
Electron's renderer rejects window.setTimeout calls that arrive without the native this:
const deps = { setTimeout, clearTimeout };
deps.setTimeout(fn, 1000);
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.
10.2 Link/URL escaping in generated markdown
When the plugin writes [text](url) into a note:
- Backslash-escape
[, ], \, <, > inside the anchor text.
- Percent-encode
(, ), and spaces in URLs to %28, %29, %20.
- Escape backslashes first so you don't double-escape.
10.3 Text-transform features must skip the YAML frontmatter
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:
- Indexer writes
jira_issues: [KEY-1] to frontmatter.
- Auto-link scans the whole file, rewrites
KEY-1 inside frontmatter to [KEY-1](url).
- YAML is now invalid.
metadataCache returns null frontmatter.
- Indexer re-writes the list. Loop.
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.
10.4 obsidian-cli footguns
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.
- There is no
property:add / property:append. To append to a list property: read โ append in memory โ property:set name=... value='[...]' type=list ....
- Never
obsidian <sub> --help โ writes an Untitled N.md. Use obsidian help at top level only.
10.5 Mobile compatibility
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.
10.6 Styling
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.
10.7 Console noise
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.
10.8 General anti-patterns
setInterval in onload without registerInterval โ leaks on reload.
- Class methods passed as handlers without
.bind(this) or an arrow wrapper โ this is lost.
- Module-scope state instead of plugin-instance fields โ survives reload, causes ghost bugs.
- Using the global
app / window.app โ always use this.app from your Plugin subclass. The global is a debugging convenience and may be removed.
- Async work after
onunload starts โ plugin is gone, writes are void. Guard with a disposed flag set in onunload.
- Raw
fetch() against third-party APIs โ CORS. Use requestUrl.
- Regex over frontmatter โ use
processFrontMatter.
- Plaintext secrets in
data.json โ use safeStorage.
- Re-rendering the whole settings tab on change โ use captured
setValue callbacks.
- Hardcoded theme colors / inline styles โ see ยง10.6.
10.9 Community directory compliance
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:
- No client-side telemetry, obfuscation, dynamic ads, or self-update mechanism. These are hard rejections โ they can't be disclosed away.
- Any network use must be disclosed in the README with the services called and why. Same for reading files outside the vault.
- Ship a
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.
11. References in this skill
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.
12. Typical flow for a new feature
- Scope + plan in issue/task notes. Identify what can live in a standalone pure module.
- Write the pure module + vitest tests. No obsidian imports. Prove correctness in isolation.
- Wire into
main.ts behind the smallest possible glue (event handler โ pure function โ vault/editor API).
- Build + copy + reload (ยง2).
- Simulate usage via
obsidian eval (ยง9).
- Read logs via
obsidian dev:console. If silent, add console.log checkpoints and repeat.
- Inspect live state with
obsidian eval code='app.plugins.plugins["<id>"]...' when logs run out.
- Only when the feature works in the live vault, remove diagnostic logs and commit.
obsidian dev:debug on + obsidian eval + obsidian dev:console turns the plugin into a REPL-able surface. Use it before you guess.