Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Plugin System

The plugin runtime preserves Obsidian’s on-disk plugin format while hardening boot and failure handling. Community plugins continue to live under /.obsidian/plugins/<id>/ with manifest.json, main.js, and optional styles.css; robustness comes from manager policy, not from inventing a new bundle format.

Sources

All plugins are represented by PluginManager, regardless of source.

SourceRegistrationDefault activation
coreRegistered by the workspace shell before plugin discoveryActivated during boot unless listed in core-plugins.json; plugins registered with enabledByDefault: false stay off until explicitly enabled; required bundled plugins always activate
officialDiscovered from /.obsidian/plugins with verified official installed stateActivated if listed in community-plugins.json and optional-core Safe Mode is not active; grouped with core plugins in settings
communityDiscovered from /.obsidian/plugins without verified official provenanceActivated if listed in community-plugins.json and community-plugin Safe Mode is not active

The manager keeps runtime metadata alongside each plugin instance:

  • source (core, official, community, or system)
  • provenance (bundled, official, community, or manual)
  • base path
  • runtime state
  • last activation error

External plugins keep the Obsidian-style folder contract under /.obsidian/plugins/<id>/, but compatibility loadData() and saveData() now use the app configuration service as the canonical store. Opaque plugin payloads live under app.json pluginData, and the runtime mirrors that canonical state back into plugins/<id>/data.json for official/community plugins and /.obsidian/<id>.json for bundled/system plugins. When both copies exist, app.json wins; the legacy file is used only to backfill missing canonical data during migration.

Bundled plugin folders are therefore created lazily only when a bundled plugin ships non-CSS assets that are actually present. Missing bundled plugin folders must be treated as “no bundled plugin assets yet”, not as an enable failure.

This shared manager contract matters because bundled plugins must obey the same reversible lifecycle as community plugins.

The current runtime also exposes two narrow compatibility surfaces for plugin interop without adopting the full upstream internal plugin API: Plugin.registerBasesView() contributes external Bases views through a manager-owned registry that is cleaned up on plugin unload, and app.internalPlugins.getEnabledPluginById(id) resolves only enabled bundled plugins for compatibility checks.

CSS Ownership

Plugin-owned presentation should stay with the owning plugin package rather than leaking into workspace-global styles. The two-artifact first-party package contract and migration tracker are documented in Plugin CSS Contract.

  • community plugins may continue to ship optional styles.css beside main.js
  • bundled plugins may provide package-owned CSS during core-plugin registration; PluginManager injects that CSS through the same plugin-css-<id> lifecycle element it uses for community plugin styles
  • first-party packages that ship Tailwind-backed UI should expose workspace app.css and standalone styles.css artifacts; workspace core-plugin registration consumes app.css, while standalone/native plugin installs use styles.css
  • new plugin-specific selectors should use the plugin id as the namespace prefix, for example notebook-... for plugin-notebook
  • plugin-specific variables should use the plugin namespace prefix and default to core theme variables when possible
  • shared UI components may still use Tailwind internally, but plugin-specific wrapper classes should provide the stable override surface for plugin chrome
  • bundled plugin CSS must be reversible with plugin enable/disable; global workspace stylesheet imports must not be the only way first-party plugin chrome reaches the renderer

Lifecycle

A plugin moves through these runtime states:

  1. disabled — discovered or registered but inactive.
  2. enablingonload() is running.
  3. enabled — plugin registrations are live.
  4. failed — load or enable failed after rollback.

Activation and deactivation are transactional:

  • enable() awaits plugin startup before the plugin is considered enabled.
  • If onload() throws, registered disposers run immediately and the plugin enters failed.
  • disable() removes commands, views, editor extensions, post processors, event handlers, and DOM nodes registered through the plugin helper methods.

The runtime now traces plugin discovery, enable, disable, and failure reporting through app.telemetry, so plugin overhead and boot-time failures can be attributed per plugin ID, source, and provenance.

Boot Contract

Boot treats plugins as one runtime phase with internal substeps:

  1. The workspace registers bundled plugin constructors with PluginManager.
  2. The dependency bag is registered for Obsidian-compatible require() calls.
  3. PluginManager reads /.obsidian/installed-plugins.json and discovers external plugin manifests from disk.
  4. Manifest preflight runs for every plugin before evaluation.
  5. Activation is awaited for all required bundled plugins, all bundled plugins that resolve to enabled after applying core-plugins.json plus any default-disabled bundled-plugin overrides, configured official plugins whose installed record has official provenance, and configured community plugins.
  6. Only after activation completes does the workspace restore layout.

The current implementation records plugins.load_all, plugin.load, plugin.enable, and plugin.disable spans around these phases. Slow plugin spans inherit the same workspace snapshot enrichment as other slow app-owned spans.

The telemetry diagnostics surface itself is delivered by the external official @lapis-notes/telemetry package with manifest ID lapis-telemetry after install; the workspace shell still owns the base browser telemetry controller.

This preserves the ordering requirement from the boot sequence: views must exist before layout restoration.

Manifest Preflight

Before evaluation or activation, the manager validates:

  • manifest.json parses successfully.
  • Plugin IDs are unique across bundled and community plugins.
  • minAppVersion is compatible with the running app.
  • Required entry files exist, especially main.js or the selected Lapis runtime entry for external plugins.
  • Platform constraints such as isDesktopOnly are compatible with the host.

Preflight failures mark the plugin as failed, emit plugin-error, and keep boot moving.

Failure Containment

No single plugin failure should prevent the shell from rendering.

  • Bundled plugin failures degrade only that feature; startup continues.
  • Official and community plugin failures leave the plugin in failed state and surface diagnostics.
  • External plugin module-evaluation failures must preserve the original exception message in surfaced diagnostics rather than being rewritten into a generic invalid-plugin-class error.
  • plugins-loaded is emitted only after activation attempts finish.
  • Layout restore must tolerate missing view types by leaving the leaf unopened and surfacing a notice rather than aborting the boot process.

Runtime failure state is in-memory only. community-plugins.json remains the desired external enablement list, including official plugins for Obsidian-compatible persistence, not a cache of last-known-good runtime state.

The one exception is stale external plugin entries whose manifest.json is gone. Boot prunes those IDs from community-plugins.json, ignores leftover asset or data folders that no longer contain a manifest, and surfaces a user notice that the missing plugin was removed from the enabled list.

plugin-error emissions also generate telemetry events carrying the plugin ID and failure message so exporter-side dashboards can track failure rates without scraping console output.

Bundled plugin state is persisted separately in core-plugins.json. Default-enabled bundled plugins continue to use the legacy disabled-ID list shape. When a bundled plugin is registered with enabledByDefault: false, explicit user enablement is persisted as an enabled override in the same file so the core plugin settings UI can round-trip that choice across restarts. Missing or failed bundled plugins do not rewrite that file unless the user changes enablement from the settings UI. Installed official plugins appear in the same Core plugins settings group, but their enable/disable state remains in community-plugins.json; optional-core Safe Mode disables their activation while community-plugin Safe Mode applies only to source community.

Host Model

The runtime still loads Obsidian-style CommonJS plugin bundles so the same vault layout works in browser and native hosts. Community plugin evaluation is routed through a CommunityPluginExecutionHost; the default renderer CommonJS host still performs in-process CommonJS evaluation, but bare dependency resolution now goes through a PluginDependencyResolver instead of reaching directly into the workspace dependency bag. The workspace supplies a WorkspaceCommonJsDependencyResolver backed by generated host-module metadata and generated CommonJS provider values; deps.ts is limited to explicit legacy/private compatibility overrides and browser fallbacks that are not public host modules. When the active host provides a PluginAssetServer, PluginManager wraps the configured CommonJS or sidecar host with a hybrid renderer host that imports structured ESM entries from verified module URLs and delegates CommonJS and Electron sidecar entries to the existing host. Stronger isolation boundaries, such as separate renderer or process sandboxes, are host concerns layered on top of this contract; the browser runtime guarantees exception containment and rollback, not CPU preemption. The approved community-plugin host boundary for browser fallback and Electron sidecar execution is documented in Community Plugin Host Boundary.

Runtime entry selection is centralized in the API package. Lapis manifests may keep legacy lapis.runtime.workspace, desktop, and trustedDesktop string entries, and may also declare structured lapis.runtime.entries descriptors with path, format, optional fallbackPath, shared dependency declarations, and reload requirements. The selector prefers structured entries, then legacy Lapis runtime entries, then Obsidian-compatible main.js fallback when present. Diagnostics record the selected host, entry path, module format, fallback path, whether fallback was used, reload-on-update policy, declared shared dependencies, used shared dependencies, undeclared shared dependencies, missing shared dependencies, deprecated shared dependencies, private shared dependencies, and the host asset URL mode plus URL used for renderer ESM imports.

Community plugin authors should treat structured renderer ESM as the preferred Lapis v1 entry format while retaining main.js only when Obsidian compatibility or an explicit hybrid fallback is required:

{
  "id": "example-plugin",
  "main": "main.js",
  "lapis": {
    "runtime": {
      "entries": {
        "workspace": {
          "path": "main.mjs",
          "format": "esm",
          "fallbackPath": "main.js",
          "sharedDependencies": ["@lapis-notes/api", "obsidian"],
          "requiresReloadOnUpdate": false
        }
      }
    }
  }
}

sharedDependencies are host-module declarations, not a general package manager. They must list only public modules from the generated host-module catalogue that the app is allowed to provide at runtime. Normal npm dependencies, private Lapis internals, Svelte internals, local source files, and plugin-owned helper libraries should be bundled into the plugin artifact. The dependency scanner reports undeclared, missing, deprecated, and private host module usage so authors can remove unstable APIs before those warnings become hard failures.

The shared host-module catalogue is generated from scripts/plugin-host-modules.config.json. It produces renderer metadata, CommonJS provider metadata, generated CommonJS provider values, shared externals, registry-validation metadata, Electron sidecar host metadata, wrapper modules under packages/workspace/src/lib/plugin-host/, and an import map for public renderer host modules. The catalogue is an allowlist; package exports are expanded only for explicitly configured packages, and generated metadata carries platform, public/private, deprecated, replacement, and reason fields where applicable.

Electron sidecar CommonJS support deliberately uses a smaller catalogue slice than the renderer. Sidecar bundles may share only lapis and @lapis-notes/api; the Obsidian renderer compatibility facade, Svelte, DOM, CodeMirror view, and @lapis-notes/ui remain renderer-only. Sidecar v1 does not load local CommonJS module graphs from the vault. Relative, parent-relative, or absolute require() calls in sidecar code fail with a bundle-required diagnostic, so sidecar plugins must publish one CommonJS bundle for the selected entry until a future preloaded graph loader or Node ESM sidecar path is implemented.

Renderer host import maps are now injected at Vite HTML-transform time for the workspace dev shell, web/PWA host, and Electron renderer host. The import map is app-owned and maps public host specifiers such as obsidian, selected @lapis-notes/api/* exports, CodeMirror packages, Svelte, @dnd-kit/* packages used by shared table drag-and-drop, and selected utility packages to generated wrapper module URLs under /__lapis/host/ in dev and emitted host-wrapper assets in production. Plugin-defined import maps remain unsupported.

The API package also defines host-neutral plugin asset URL helpers and installed-asset verification primitives for the ESM module URL path. Those helpers cover the web route /__lapis/plugins/..., the Electron renderer lapis-plugin://... URL shape, path safety, supported module asset types, MIME types, size checks, URL hash checks, and SHA-256 verification against installed plugin metadata. The Web/PWA host mirrors verified installed plugin files into Cache Storage and the generated service worker serves only those version/hash /__lapis/plugins/... cache entries. The Electron host registers lapis-plugin as a secure fetch-capable custom protocol, accepts renderer IPC registration for active installed plugin asset contexts, and verifies plugin ID, version, URL hash, path, size, and SHA-256 before serving a file from the selected vault. The renderer ESM execution host prepares declared and scanned dependencies from the selected source file, imports the version/hash asset URL with bundler resolution disabled, and falls back to CommonJS only when the selected structured ESM descriptor explicitly declares fallbackPath; otherwise an ESM import failure is recorded as a plugin load failure.

Renderer ESM asset URLs include the installed plugin version and verified file hash. Web/PWA hosts therefore expose module entries under /__lapis/plugins/<vault-id>/<plugin-id>/<version>/<sha256>/<path>, while Electron renderer hosts use the equivalent lapis-plugin://<vault-id>/<plugin-id>/<version>/<sha256>/<path> custom-protocol URL. The hash segment is the SHA-256 recorded for the requested file in .obsidian/installed-plugins.json, and hosts reject mismatched URL hashes before serving the asset. The URL shape intentionally changes when installed bytes change, but browser module records can still live for the lifetime of the current document. Plugin authors should set requiresReloadOnUpdate when an update cannot be made safe by disable/enable alone and the app should tell the user a reload is required after the new version is installed.

Settings diagnostics expose this loader state in the plugin Features panel. Runtime badges summarize ESM, CommonJS compatibility, Hybrid, Sidecar, Fallback used, Missing dependency, Deprecated host API, and Reload required. The detail rows include selected host, module format, fallback entry, fallback-used status, reload policy, asset URL mode, version/hash URL, declared and scanned shared dependencies, undeclared dependencies, missing dependencies, deprecated public APIs, and private host APIs.

Official external Lapis plugins are ESM-only. Their release artifacts must include a structured workspace entry that points at main.mjs, must not declare fallbackPath, must not ship a stale main.js compatibility artifact, and must bundle UI subpaths, Svelte internals, and other normal npm dependencies instead of treating them as host modules. The shared official Vite build helper externalizes only generated public host modules and fails when unsupported bare imports remain.

Official distribution validates this runtime metadata before a verified install is staged. Signed release manifests mirror structured lapis.runtime.entries metadata from the packaged manifest.json; the installer rejects official releases when the signed metadata is missing or divergent, when the workspace entry is not ESM-only, when entry files are absent, when module formats do not match their file extensions, or when scanned bare imports are undeclared or unsupported by the generated host-module catalogue for the selected renderer or sidecar platform. Manual and community compatibility paths can still use CommonJS or explicit fallback metadata and can surface the same diagnostics as warnings instead of treating them as official-release failures.

The current platform direction distinguishes two plugin styles:

  • Obsidian-compatible plugins continue to use the trusted DOM compatibility path so existing plugins can keep working against the renderer-owned API surface
  • new Lapis extensions should prefer manifest-installed contributions, lazy activation, brokered capabilities, and isolated UI surfaces where the host can enforce clearer ownership

The compatibility path is therefore preserved, but new extension features should not assume arbitrary renderer DOM access by default.

Approved Direction

The approved target architecture is captured in ADR-001: Plugin Runtime Rework. That ADR does not change the current implementation described above; it defines the later host split that the codebase is expected to move toward.

The current-state summary for that rework now lives under Plugin System, and the forward-looking contract lives in Plugin System Design, including manifest classification, declarative contributions, scoped service providers, Electron host phases, permissions, install layout, extension state, and system-extension boundaries.

  • web/PWA keeps a renderer-hosted community-plugin path with capability gating and structured unsupported-API diagnostics
  • Electron desktop introduces a stronger host boundary for community plugins while preserving the same Obsidian-compatible disk layout
  • bundled plugins remain renderer-local in the first rollout of that rework

Any runtime or capability manifest declarations described in the ADR are planned extensions, not part of the current manifest contract documented on this page.

The Plugin base class now also exposes plugin-scoped telemetry helpers and automatically wraps plugin-owned view creators plus registered markdown post processors and code-block processors. This gives first-party and community plugins a consistent tracing surface without requiring each plugin to bootstrap its own telemetry client.

Plugin.registerObsidianProtocolHandler(action, handler) is backed by AppUrlService. Handlers receive the Obsidian-compatible decoded parameter map including action, are invoked for matching Lapis app URL actions, and are removed through the normal plugin unload lifecycle. Lapis does not claim the global obsidian:// OS scheme; the compatibility surface is for in-app dispatch and plugins that expect Obsidian-style protocol handler semantics.

The next plugin-runtime slices tracked in the backlog build on this foundation instead of replacing it: output channels for user-visible logging, quick pick and input prompt APIs owned by the renderer, Workspace Trust-aware capability gates, and broader host-kind support such as browser workers.