Plugin System Design
This document defines the intended plugin-system design layered on top of the current Plugin Runtime and Community Plugin Host Boundary contracts. It is written as a spec for the target model, while noting where the current implementation already conforms and where later phases still remain.
Scope
The plugin system preserves the Obsidian-compatible community-plugin disk layout while adding Lapis-specific manifest metadata, contribution indexing, host selection, and capability-aware execution. PluginManager remains the lifecycle authority for discovery, preflight, activation, deactivation, rollback, diagnostics, and boot ordering.
The design covers:
- manifest shape and classification
- contribution indexing and activation
- service-provider registration
- runtime-host selection and runtime entries
- permissions and trust language
- install layout and extension state
- system extensions and isolation boundaries
Design Principles
- Preserve baseline Obsidian community-plugin compatibility.
- Keep one lifecycle owner for discovery, preflight, diagnostics, and cleanup.
- Prefer declarative contributions over eager code execution.
- Treat host permissions as enforceable only where access is brokered or otherwise restricted.
- Keep host and contribution models stable as desktop isolation improves.
The UI and host split is intentional:
- existing Obsidian-compatible community plugins continue to run through the trusted DOM compatibility host so the on-disk plugin format and runtime model remain usable
- new Lapis extensions should prefer manifest declarations, lazy activation, commands, context keys, brokered capabilities, and isolated UI or runtime hosts where possible instead of arbitrary renderer DOM mutation
This means compatibility APIs remain important, but they are not the design target for new extension surfaces.
Manifest Model
The baseline Obsidian manifest remains the compatibility contract. Lapis-specific metadata lives under an optional lapis namespace so upstream fields do not need to move or change meaning.
{
"id": "example",
"name": "Example",
"author": "Author",
"version": "1.0.0",
"minAppVersion": "1.12.3",
"description": "A Lapis extension",
"lapis": {
"manifestVersion": 1,
"extensionKind": ["workspace", "trustedDesktop"],
"contributes": {
"commands": [],
"configuration": [],
"languages": [],
"editorViews": [],
"views": [],
"services": []
},
"activationEvents": [],
"runtime": {
"workspace": "main.js",
"desktop": "desktop.js"
},
"permissions": []
}
}
The lapis namespace defines contribution declarations, activation events, runtime entries, and permission requests. Missing lapis metadata means the plugin is treated as an Obsidian-compatible community plugin.
Classification
Each discovered plugin is classified before runtime preflight decides which files are required.
| Class | Definition | Required entry | Intended behavior |
|---|---|---|---|
| Obsidian-compatible | Baseline manifest with no lapis namespace | main.js | Preserve current behavior |
| Lapis extension | Manifest uses only Lapis contribution and runtime metadata | Selected Lapis runtime entry, if any | Allow declarative and host-specific extensions |
| Hybrid | Baseline Obsidian plugin plus lapis metadata | main.js plus any selected Lapis runtime entry | Allow gradual adoption of the Lapis model |
Classification rules are:
- If
lapisis absent, classify as Obsidian-compatible. - If
lapisis present andmain.jsis still required for compatibility behavior, classify as hybrid. - If
lapisis present and the plugin can be expressed as declarative metadata plus optional Lapis runtime entries, classify as a Lapis extension. - If classification is ambiguous, preflight fails instead of guessing.
Current implementation status: manifest classification is implemented.
Contribution Model
PluginManager builds a contribution index from manifests before evaluating plugin code. The index is the canonical source for diagnostics, settings visibility, manifest-only installation, and later lazy activation.
Indexed contributions may include:
- commands
- configuration schemas
- status bar items
- menus and keybindings
- language IDs and file extensions
- language-service providers
- workspace and sidebar views
- tree views and view-scoped badges
- webviews and prompt surfaces
- markdown processors and code-block processors
- notebook renderers and kernels
- service-provider declarations
Each indexed contribution record carries plugin ID, contribution type, stable contribution ID, manifest source path, runtime host requirement, permission requirements, activation requirement when code is needed, and validation diagnostics.
Built-in contribution kinds are now defined through contribution-point descriptors instead of being expanded inline in one hardcoded index builder. A descriptor is responsible for validating the contribution shape, producing indexed entries, declaring whether the contribution is manifest-installable, and supplying default activation metadata when the contribution kind supports lazy activation. Unknown contribution keys and malformed contribution records remain in diagnostics as invalid indexed entries so plugin settings can explain what failed without silently dropping manifest data.
Declarative contributions now also share an API-owned context-key service. App.contextKeys is the single source of truth for built-in state such as view.id, view.focused, editor.active, editor.language, editor.hasSelection, workspace.trusted, runtime.host, runtime.desktop, runtime.browser, runtime.nativeHost, plugin.state.<pluginId>, and plugin.enabled.<pluginId>. Plugins can register scoped custom keys under plugin.<pluginId>.*, and plugin unload resets those scoped keys automatically.
Supported when grammar for declarative contribution gating currently includes:
- identifiers using dot or dash namespaces, such as
editor.activeorplugin.enabled.my-plugin &&,||, and unary!==and!=- parentheses for grouping
- boolean literals plus quoted string and number literals
The first declarative consumer is command visibility: manifest command contributions can declare when, and the command palette hides or shows those commands through the shared evaluator before callback-specific availability checks run.
Manifest contributions now also support statusBarItems, with records shaped as { id, text?, icon?, alignment?, priority?, tooltip?, command?, when? }. These items install without evaluating plugin code, render through the shared workspace shell on the declared alignment track, and execute referenced commands through the normal command registry so deferred command activation still works. The existing Plugin.addStatusBarItem() raw DOM API remains available as the compatibility escape hatch for Obsidian-style plugins.
Manifest contributions also support editorViews, with records shaped as { id, viewType?, label, description?, filenamePatterns, priority?, activationEvent? }. They install metadata into the workspace editor-view registry without evaluating plugin code, so settings can present selectable editor IDs and configured workspace.editorAssociations values can be preserved even when the backing runtime view is disabled. A code-backed plugin still registers the actual view constructor through Plugin.registerView(); manifest metadata only describes selectable file-backed editor ownership.
Current implementation status: contribution indexing is implemented. Manifest-only installation covers commands, configuration, status bar items, editor-view metadata, and bundled system-extension service bindings, starting with language services.
Follow-on contribution surfaces are intentionally staged behind the same model rather than being introduced as separate ad hoc registries. The next planned surfaces tracked in the backlog are:
- codicon- and registry-icon label text rendering for declarative contribution labels (command palette today; status bar, trees, and quick pick next)
- generalized contribution-point descriptors and validation
- context keys and
whenclauses - declarative status bar items, menus, and keybindings
- tree views, view badges, and view-scoped context keys
- output channels, quick pick or input prompts, and richer declarative configuration controls
- webviews and expanded host-kind support such as
browserWorker
When adding a new contribution point, extend the built-in descriptor registry with:
- shape validation for each contributed record
- stable contribution IDs and required permission metadata
- default activation-event metadata when the contribution can wake code lazily
- manifest-installation metadata so renderer-owned surfaces can install without eager plugin code
- diagnostics for malformed records and unsupported manifest use
Activation Model
Code execution is delayed until a contribution needs behavior that cannot be installed from manifest data alone. PluginManager remains the coordinator for all activation decisions.
Initial activation events are:
| Event | Meaning |
|---|---|
onStartupFinished | Activate after workspace boot finishes |
onCommand:<id> | Activate before running a contributed command |
onLanguage:<languageId> | Activate when a matching document is opened |
onView:<viewType> | Activate before creating a contributed view |
onFileSystem:<glob> | Activate when matching vault files are present or opened |
onService:<serviceId> | Activate when a service provider is selected |
workspaceContains:<glob> | Activate after discovery finds a matching file |
Default behavior is host- and class-dependent:
- Obsidian-compatible plugins activate during the normal plugin boot phase.
- Lapis extensions default to lazy activation unless they declare startup work or own contributions needed during boot.
- Hybrid plugins preserve
main.jscompatibility and may still require eager activation.
The boot invariant remains unchanged: layout restoration must not race ahead of required plugin activation when code-backed views or services are needed for layout recovery.
Current implementation status: activation-event lazy activation is implemented for indexed Lapis extensions, including command-triggered activation, layout-safe view activation before restore, language/service activation hooks, and vault-path activation for workspaceContains and onFileSystem patterns.
Future declarative UI contributions must keep the same activation rule: the app should be able to index and, when practical, install metadata without forcing plugin code evaluation, then activate code only when the contribution actually needs behavior.
Service Model
Service extensions register as scoped providers owned by the target service rather than replacing App services. Runtime registration returns a disposer and is tied to plugin lifecycle cleanup.
A service-provider declaration includes:
- provider ID
- owning plugin ID
- service ID
- runtime host
- required capabilities
- priority
- activation event
- permission requirements
Service IDs remain namespaced. Duplicate provider IDs for the same service are rejected unless the owning service explicitly allows replacement.
Current implementation status: language-service providers follow this model today.
Runtime Hosts
Community plugin code runs through a runtime-neutral execution-host seam.
- The renderer host is the compatibility fallback for browser/PWA and for community plugins that still run in process.
- The Electron sidecar host is the trusted desktop host for brokered desktop execution.
PluginManagerstays responsible for discovery, preflight, diagnostics, activation state, and renderer-installed contributions regardless of which host executes plugin code.
The MVP desktop topology is:
Workspace renderer
-> PluginManager
-> preload desktop_plugin_host_* IPC
-> Electron main PluginSidecarManager
-> forked Node child process
-> extension code and broker requests
Electron main owns process creation, timeout handling, crash recovery, shutdown, and brokered native capability access. The renderer owns policy and UI-visible state.
Current implementation status: renderer execution and trusted desktop sidecar execution are both implemented.
Runtime Entry Selection
Lapis runtime entries are separate from main.js. The selected entry depends on plugin class, active runtime, and the narrowest host that can satisfy the contribution.
{
"lapis": {
"runtime": {
"workspace": "main.js",
"browserWorker": "worker.js",
"desktop": "desktop.js"
}
}
}
Runtime-entry rules are:
- Obsidian-compatible plugins use
main.js. - Hybrid plugins keep
main.jsand may additionally declare Lapis runtime entries. - Renderer-hosted Lapis code uses
lapis.runtime.workspace. - Sidecar-hosted desktop code uses
lapis.runtime.trustedDesktoporlapis.runtime.desktop. - Only the selected runtime entry is required for a given host decision.
- Selected host mode and runtime entry are recorded in diagnostics.
All manifest-declared runtime entries must be relative to the plugin folder, must not escape the plugin root, and currently must use .js, .cjs, or .mjs extensions.
Current implementation status: workspace and trusted desktop runtime-entry selection is implemented. browserWorker and multi-host hybrid activation remain future work.
Permissions And Trust
Permissions are manifest-declared requests and are enforceable only for APIs that go through a broker or another restricted host surface.
| Permission | Brokered capability |
|---|---|
vault.read | vault:read |
vault.write | vault:write |
plugin.data | plugin:data |
commands | commands |
notices | notices |
settings.read | settings |
metadata.query | metadata:query |
events | events |
logging | logging |
Preflight evaluates declared permissions against the selected host. Missing or unavailable required permissions fail before code evaluation. Granted permissions and host mode are surfaced in diagnostics.
Trusted desktop code is not sandboxed by these app permissions. Once arbitrary Node code is allowed to run in a trusted sidecar, the permission UI must describe those grants as app API access, not as an OS-level sandbox.
Current implementation status: requested and granted permissions plus trusted-host warnings are surfaced in the settings UI.
Install Layout
The plugin system preserves the vault-local community-plugin install layout.
.obsidian/
community-plugins.json
plugins/
<plugin-id>/
manifest.json
main.js
desktop.js
worker.js
styles.css
data.json
assets/
Only the files required by the plugin class and selected runtime need to exist. Manifest-only Lapis extensions may contain only manifest.json and static assets. Mutable plugin state stays separate from shipped code and assets.
Extension State
The design separates installed package files, user-authored configuration, and generated runtime state.
| State | Current location | Purpose |
|---|---|---|
| Package files | .obsidian/plugins/<id>/ | Manifest, entries, CSS, static assets |
| Community plugin data | app.json pluginData (canonical), mirrored to .obsidian/plugins/<id>/data.json | Canonical plugin compatibility persistence plus Obsidian layout compatibility |
| Bundled plugin settings | app.json pluginData (canonical), mirrored to .obsidian/<id>.json | Canonical bundled/system plugin compatibility persistence plus legacy file compatibility |
| Generated app state | App database outside canonical notes when available | Search, metadata, notebook outputs, derived records |
Compatibility loadData() and saveData() behavior remains valid for community plugins. Rebuildable indexes, caches, and large derived artifacts should move to app-owned generated-state APIs rather than being written into shipped plugin folders.
Current implementation status: compatibility plugin data is canonicalized in app.json pluginData and mirrored back into the legacy Obsidian plugin files. Plugins also have generated-state helpers backed by AppDatabase metadata plus an idempotent migration helper for moving rebuildable subtrees out of compatibility settings payloads.
System Extensions
System extensions are app-shipped, app-versioned, trusted components that are not installed into a user’s vault as community plugins. They may use stronger privileges than community plugins, but those privileges remain explicit rather than implicit.
System extensions reuse the same contribution and service-registration model where possible so the application does not evolve two unrelated extension systems. Their settings surface remains distinct from the community plugin list.
Examples include native language-service providers, notebook kernels, file-watching providers, app-owned search backends, and trusted desktop integrations.
Current implementation status: bundled system extensions can now be registered at app boot as manifest-only extensions, renderer code-backed system plugins, or declarative services with app-owned implementations, all through explicit registration. Packaged discovery remains future work.
Security And Isolation
The desktop trusted host is powerful. A normal Electron child-process sidecar can access Node APIs unless the runtime deliberately restricts them. App-level permission checks do not make arbitrary Node code safe.
The design therefore distinguishes:
- declarative and manifest-only extensions, which can be validated before code execution
- renderer-hosted compatibility plugins, which preserve behavior but do not gain process isolation
- trusted desktop extensions, which receive brokered app APIs but still require honest trust language
Isolation hardening may add Node permission flags, per-plugin sidecars, trust-level grouping, watchdogs, and packaged-app verification across supported platforms. Those later phases must not change the manifest, contribution, or host-selection model.
MVP Phases
- Manifest classification and contribution indexing.
- Manifest-only commands and configuration.
- Scoped service registration, starting with language services.
- Trusted desktop Electron sidecar lifecycle and brokered capability facades.
- Permission and trust diagnostics in the settings UI.
- Bundled system extensions with manifest-only contributions and service bindings.
- Isolation hardening and the remaining runtime-model work.
Current implementation status: phases 1 through 7 are complete for the current scoped host model. Follow-on runtime expansion such as browserWorker support, richer manifest-only coverage, and multi-host hybrid activation remains future work without changing the completed MVP boundary.