Plugins¶
Stratum plugins are WebAssembly modules that extend the app without a rebuild.
They run in an embedded, sandboxed runtime and interact with your vault only
through the host functions they import (pkm.note_read, pkm.note_write,
pkm.http_request, pkm.log). The full contract — manifest schema, host ABI,
error codes, and the Tauri command surface — is the normative
Plugins API Specification.
Need a plugin? The community catalog lives in the
stratum-plugins repository —
a public repo hosting the plugin submission template, the
plugins/index.json catalog that powers the in-app Plugin Directory, and the
hello-world reference example. To publish your own plugin, follow
PLUGIN_TEMPLATE.md
and open a pull request.
A plugin is a single file, <vault>/.pkm/plugins/<id>/plugin.wasm, with an
embedded JSON manifest in the custom section named stratum:manifest. A legacy
sidecar manifest (plugin.wasm.manifest.json beside the .wasm) is still
accepted for reading, but new plugins must embed the manifest.
Plugin Directory¶
The in-app Plugin Directory (Plugins panel → Directory tab) lists plugins
from the community catalog at
https://raw.githubusercontent.com/short-circuit/stratum-plugins/main/plugins/index.json.
- Fetch + cache: the catalog is fetched on first open of the tab and cached in local storage. Later opens revalidate in the background (stale-while- revalidate).
- Offline fallback: when the direct repo fetch fails, the last successful cached catalog is shown with an Offline badge and a Retry action — the panel never breaks on network failure.
- Verified installs: each entry shows its version, description, declared
permissions, and verified
sha256digest. Install downloads the WASM bytes, verifies the digest (crypto.subtle + a backend re-check inplugins_install_url), stages, and installs into<vault>/.pkm/plugins/<id>/using the same pipeline as manual installs. - Status reflection: installed entries immediately show an Installed
badge; uninstall uses the same
plugins_uninstallcommand and removes the plugin dir + config entry. - Theme Hub: the Themes tab lists themes from
themes/index.jsonin the same repo. Installing downloads the theme document, verifies its sha256, persists it locally, and applies it viaapplyTheme+ the settings store. Uninstalling the active theme falls back to the built-in theme.
Catalog install requires the
https://raw.githubusercontent.comorigin in the Tauri CSPconnect-src(already configured). No other network origin is contacted unless a plugin declares thenetworkpermission.
Installing a Plugin¶
- Build your plugin as a
wasm32core module (see Writing a Plugin). - Create the directory
<vault>/.pkm/plugins/<id>/where<id>is the reverse-DNS id from the manifest (e.g.com.example.my-plugin). - Copy the
.wasmfile into that directory asplugin.wasm. - In the app, open Plugins and click Refresh. The plugin is discovered, loaded, and listed.
A newly discovered plugin is loaded but disabled unless it is enabled in
the vault config plugins list (see Configuring Plugins).
Enable it from the panel to arm its hooks.
Opening Plugins¶
Click Plugins in the sidebar or navigate to /plugins.
Viewing Installed Plugins¶
The panel lists every plugin discovered in your vault plugin directory
(<vault>/.pkm/plugins/). Each entry shows:
| Field | Description |
|---|---|
| Name / version | Display name and version from the plugin manifest |
| ID | The unique plugin identifier (manifest id) |
| Description | One-line description, when the manifest declares one |
| Author | Author or org, when the manifest declares one |
| Hooks | Declared-enabled hooks (onSave, onOpen, …), per spec §8 |
| Status | Ready, Disabled, or Error |
| Permissions | The capabilities the plugin requested (file:read, file:write, network, …) |
Status¶
| Status | Meaning |
|---|---|
| Ready | The plugin loaded and its hooks are armed. |
| Disabled | The plugin is installed but disabled — hooks and dispatch are skipped, state is retained. |
| Error | Loading or a runtime call failed. Click the status chip to expand the error detail. |
A plugin that fails to load never aborts the scan of the rest of the directory:
it is reported with status Error and the load error message, and the other
plugins still load.
Enabling, Disabling & Reloading¶
- Enable — re-arms a disabled plugin's hooks. The change is persisted to the vault config.
- Disable — stops a running plugin without uninstalling it. The change is persisted to the vault config.
- Reload — re-instantiates the plugin from disk (re-reads the manifest,
recompiles). Use after updating a
.wasmfile. If the new file is invalid, the plugin enters theErrorstate.
Testing Host Functions¶
Each entry has note_read and http_request buttons. These run the same host-function backend used by plugins, without needing a plugin, so you can verify path handling and network behavior (e.g. tune the SSRF guard). Results and errors are shown inline.
Writing a Plugin¶
1. Choose a toolchain¶
Any toolchain that produces a wasm32 core module works:
- Rust targeting
wasm32-wasip1(stablestd) — the documented way and the basis of the example plugin. The host provides a real WASI preview1 context (fd_write, environ_get, proc_exit, clocks, …), sostdbuilds instantiate and the Rust standard library's allocator, panic handler, and stdio all work. - AssemblyScript / C / Rust targeting
wasm32-unknown-unknown— works with a smaller runtime; a module that imports no WASI symbols loads identically.
For all toolchains, vault I/O flows through the pkm.* host imports, not
through the WASI filesystem: no host directory is preopened, so std::fs
inside a plugin sees an empty filesystem. Use pkm.note_read /
pkm.note_write to read and write vault notes.
2. Define the manifest¶
The manifest is a UTF-8 JSON object embedded in the stratum:manifest custom
section of the module. Its schema is version 1 and its fields are validated at
load time.
{
"schema_version": 1,
"id": "com.example.my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "Optional one-line description",
"author": "Optional author or org",
"entry": "plugin.wasm",
"permissions": ["file:read", "file:write", "network"],
"hooks": {
"onSave": true
}
}
| Field | Required | Rules |
|---|---|---|
schema_version |
yes | Must be 1. |
id |
yes | Unique reverse-DNS id; ^[A-Za-z0-9][A-Za-z0-9._-]*$, ≤128 chars. |
name |
yes | Display name, ≤64 chars. |
version |
yes | Semver, ≤32 chars. |
description |
no | ≤512 chars. |
author |
no | ≤128 chars. |
entry |
no | File name of the wasm module. Default plugin.wasm. |
permissions |
yes | See Permissions. Unknown entries make the manifest invalid. |
hooks |
no | Keys on* with boolean values. See Git hooks. |
A manifest that fails JSON parse, schema validation, or permission validation is rejected and the plugin does not load.
3. Import the host functions¶
The host linker provides one import namespace, pkm, with exactly four
functions. Every host function has the WASM type (param i32 i32) (result i32):
(i32, i32)— a pointer and byte length locating a UTF-8 JSON request in the plugin's linear memory.(result i32)— the byte length of the JSON response that the host wrote at offset 0 of the plugin's linear memory (the host grows the memory as needed).
| Import | Type | Needs permission |
|---|---|---|
pkm.log |
(i32, i32) -> i32 |
none (always allowed) |
pkm.note_read |
(i32, i32) -> i32 |
file:read |
pkm.note_write |
(i32, i32) -> i32 |
file:write |
pkm.http_request |
(i32, i32) -> i32 |
network |
A module that imports any other namespace or function fails instantiation and does not load.
4. Export hook functions¶
The host dispatches each hook declared in the manifest by calling the matching
guest export. The guest export has the same signature (param i32 i32)
(result i32) and receives the hook payload as a JSON string in linear memory.
| Hook | Host payload (JSON string) | Guest export |
|---|---|---|
onSave |
{"path": "…", "content": "…"} |
onSave |
onOpen |
{"path": "…"} |
onOpen |
onLink |
{"path": "…", "links": ["…"]} |
onLink |
onSearch |
{"query": "…", "limit": N} |
onSearch |
any other on* |
{"args": []} |
<name> |
A registered hook whose guest export is missing is a no-op (logged at debug).
onSave and onLink are delivered from the note save flow, onOpen from the
note open flow, and onSearch from the full-text search flow. A hook that
traps or fails logs the error and never aborts the surrounding operation.
5. Guest call pattern¶
Every host callback returns a JSON response envelope. Success:
Error (with one of the canonical codes from the API Specification):
pkm.http_request returns a dedicated envelope:
Request payloads must not exceed 1 MiB; responses are capped the same way.
pkm.log always succeeds and needs no permission.
Worked example (WAT)¶
The following minimal module imports all four host functions and exposes an
onSave and onOpen hook. It dispatches on a marker prefix in the payload
(READ:, WRITE:, HTTP:, LOG: followed by a JSON payload): it locates the
: separator at runtime and forwards only the JSON substring that follows it to
the matching host function, then returns the response length the host wrote at
memory offset 0. This is the exact module shape the plugin E2E suite
(crates/pkm-tests/tests/plugin_e2e.rs) builds, loads through the real
registry, and runs end-to-end:
(module
(import "pkm" "note_read" (func $note_read (param i32 i32) (result i32)))
(import "pkm" "note_write" (func $note_write (param i32 i32) (result i32)))
(import "pkm" "http_request" (func $http_request (param i32 i32) (result i32)))
(import "pkm" "log" (func $log (param i32 i32) (result i32)))
(memory (export "memory") 1)
;; Find the byte position of ':' in the first `len` bytes of `ptr`,
;; or 0 if none is found.
(func $find_colon (param $ptr i32) (param $len i32) (result i32)
(local $i i32)
(local $c i32)
(block $done
(loop $scan
(br_if $done (i32.ge_u (local.get $i) (local.get $len)))
(local.set $c
(i32.load8_u
(i32.add (local.get $ptr) (local.get $i))))
(if (i32.eq (local.get $c) (i32.const 58))
(then (return (local.get $i)))
)
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $scan)
)
)
(i32.const 0)
)
;; Pointer and length of the JSON that follows the first ':'.
(func $json_ptr (param $ptr i32) (param $len i32) (result i32)
(local $sep i32)
(local.set $sep (call $find_colon (local.get $ptr) (local.get $len)))
(i32.add (local.get $ptr) (i32.add (local.get $sep) (i32.const 1)))
)
(func $json_len (param $ptr i32) (param $len i32) (result i32)
(local $sep i32)
(local.set $sep (call $find_colon (local.get $ptr) (local.get $len)))
(i32.sub (i32.sub (local.get $len) (local.get $sep)) (i32.const 1))
)
(func $dispatch (param $ptr i32) (param $len i32) (result i32)
(local $c i32)
;; Empty payload -> no host call.
(if (i32.eqz (local.get $len))
(then (return (i32.const 0)))
)
;; Dispatch on the first byte of the marker.
(local.set $c (i32.load8_u (local.get $ptr)))
(block $done
(br_if $done (i32.ne (local.get $c) (i32.const 82))) ;; 'R' -> note_read
(return (call $note_read
(call $json_ptr (local.get $ptr) (local.get $len))
(call $json_len (local.get $ptr) (local.get $len))))
)
(block $done
(br_if $done (i32.ne (local.get $c) (i32.const 87))) ;; 'W' -> note_write
(return (call $note_write
(call $json_ptr (local.get $ptr) (local.get $len))
(call $json_len (local.get $ptr) (local.get $len))))
)
(block $done
(br_if $done (i32.ne (local.get $c) (i32.const 72))) ;; 'H' -> http_request
(return (call $http_request
(call $json_ptr (local.get $ptr) (local.get $len))
(call $json_len (local.get $ptr) (local.get $len))))
)
;; 'L' (or unknown) -> log
(call $log
(call $json_ptr (local.get $ptr) (local.get $len))
(call $json_len (local.get $ptr) (local.get $len)))
)
(func (export "onSave") (param i32 i32) (result i32)
(call $dispatch (local.get 0) (local.get 1))
)
(func (export "onOpen") (param i32 i32) (result i32)
(call $dispatch (local.get 0) (local.get 1))
)
)
Assemble with wat2wasm (from the wabt
toolkit) and append the stratum:manifest custom section containing the JSON
manifest above. The resulting file is the distributable plugin.wasm.
A Rust example: title-case plugin¶
A complete, real Rust plugin ships in the repository at
examples/plugins/titlecase/. It title-cases note content whenever a note is
saved. It is the canonical reference for the recommended toolchain and build
path.
Prerequisites
- Rust stable with the
wasm32-wasip1target:rustup target add wasm32-wasip1.
Target naming: the target was historically called
wasm32-wasi. Modern stable Rust renamed it towasm32-wasip1and removed the legacy alias, socargo build --target wasm32-wasip1is the correct command on current toolchains.
Build
The build produces target/wasm32-wasip1/release/stratum_plugin_titlecase.wasm.
The plugin's manifest is embedded in the artifact (as the stratum:manifest
custom section via #[link_section]), so the output is complete and
self-describing — there is no separate manifest injection step. Copy it to
plugin.wasm in the repo:
Install
- Create the vault plugin directory
<vault>/.pkm/plugins/com.example.titlecase/. - Copy
plugin.wasminto it. - In the app, open Plugins and click Refresh. The
plugin is discovered from its embedded manifest. Enable it to arm the
onSavehook.
What the code does
#[link_section = "stratum:manifest"]embeds the manifest (id, name, version,permissions: ["file:read", "file:write"],hooks: { "onSave": true }).extern "C"importspkm.logandpkm.note_writeunder thepkmmodule namespace — the wasm import name is the Rust identifier, so the externs are named exactlylog/note_write.- The
#[no_mangle] pub extern "C" fn onSave(i32, i32) -> i32hook decodes the{"path", "content"}payload, title-cases the content, persists it back throughpkm.note_write, and logs the outcome viapkm.log. - Linear-memory offset 0 is the ABI scratch area: the plugin copies the inbound payload into its own heap before any host call, and reads the host response from offset 0 after each call. See the in-tree comments.
The end-to-end acceptance test
crates/pkm-tests/tests/plugin_example_e2e.rs installs this exact artifact into
a temporary vault through the real PluginManager, dispatches onSave, and
asserts the note on disk was title-cased. Run it with:
Validating your plugin¶
The plugin E2E suite in crates/pkm-tests/tests/plugin_e2e.rs builds a real
plugin.wasm in-process, embeds the manifest, and exercises discovery, loading,
all four host functions against a real on-disk vault, permission denials, the
SSRF guard, and the PluginManager lifecycle. Use it as a runnable reference for
the exact wire contract.
Permissions¶
| Permission | Protects | Host functions |
|---|---|---|
file:read |
reading vault notes | pkm.note_read |
file:write |
writing vault notes | pkm.note_write |
network |
HTTP egress | pkm.http_request |
git |
Git operations | lifecycle-gated |
exec |
Running subprocesses | lifecycle-gated |
all |
every capability above | all host functions |
A host function invoked without its required permission is aborted and surfaces
{"kind":"err","code":"plugin_denied","message":"permission 'file:read' not
granted"} to the plugin. Grants come only from the plugin's embedded manifest;
the permissions field in the vault config is an enablement/override layer and
is not required for the manifest-derived grant to take effect.
Configuring Plugins¶
The vault config at <vault>/.pkm/config.toml contains a [[plugins]] list
that controls enablement. A plugin present on disk but absent from this list is
loaded disabled. Enabling/disabling from the panel rewrites this list.
[network]
# Host or CIDR entries the plugin HTTP SSRF guard permits.
allowlist = ["api.example.com", "10.0.0.0/8"]
[[plugins]]
name = "com.example.my-plugin"
enabled = true
wasm_path = ".pkm/plugins/com.example.my-plugin/plugin.wasm"
permissions = ["file:read", "file:write", "network"]
The network.allowlist field is the SSRF guard for pkm.http_request: a target
is allowed if its host matches an entry (host or CIDR), or if it resolves to a
private/loopback/link-local address. localhost and LAN addresses work by
default; public hosts must be allow-listed or they are rejected with
http_ssid before any network round-trip.
Hooks¶
Hooks are defined by the manifest hooks map (key on<Name>: boolean).
onSave and onLink are delivered from the note save flow, onOpen from the
note open flow, and onSearch from the full-text search flow. A hook that
traps logs the error and does not abort the surrounding operation. The
hook signature and payloads are defined in the
API Specification §8.
Troubleshooting¶
Plugin shows status Error¶
- Open the status chip for the error detail.
- A manifest that fails validation lists the exact rule violated (e.g. an
unknown permission string, a bad
schema_version, or an invalidid). - A module that imports an unknown namespace fails instantiation; import only
from the
pkmnamespace (the four host functions) and thewasi_snapshot_preview1namespace (real WASI preview1, supported). Anything else — e.g.env.foo,wasi_snapshot_preview1symbols outside the WASIp1 surface — is rejected. - Verify the file is a real wasm32 core module (
wasmtime/wasm2watcan validate it outside Stratum).
note_read / note_write return errors¶
note_not_found— the note does not exist under the vault root.note_write_failed— the write failed (I/O, or the path escapes the vault).plugin_denied— the plugin lacksfile:readorfile:write.
http_request returns errors¶
http_ssid— the hostname is not innetwork.allowlistand does not resolve to a private/loopback address. Add it to the allowlist.http_timeout— the request exceededtimeout_ms(default 10000 ms, max 60000 ms).http_status— the server returned HTTP 400..=599.http_transport— DNS, connect, or TLS failure.
Change to a plugin has no effect¶
Use Reload on the plugin entry — plugins are compiled at load time; editing
the .wasm on disk does not hot-reload automatically.
Further reading¶
- Plugins API Specification — the normative contract: manifest schema, full host ABI, permission model, error codes, SSRF guard, lifecycle, and the Tauri command surface.
- ADR-0004 — the accepted design and its stability guarantees.
examples/plugins/titlecase/— the real Rust example plugin (build, install, and end-to-end test documented above).crates/pkm-tests/tests/plugin_e2e.rs— the verified end-to-end test suite.crates/pkm-tests/tests/plugin_example_e2e.rs— acceptance test that installs and runs the realexamples/plugins/titlecaseartifact.