Manifest and permissions
BetaThis is a reference for developers writing a plugin manifest by hand. Most readers don't need it — see Build one with the Builder instead.
Write a local WASM action plugin's manifest with [runtime],
[[actions]], capabilities, and host functions. The rules below cover what
Wrengle currently validates.
If your plugin needs custom UI instead of a WASM action, use
Views (custom plugin UI). View-only plugins do not need
plugin.wasm.
Folder layout
my-plugin/
plugin.toml
plugin.wasmplugin.wasm must be built or copied next to plugin.toml before the plugin can
load.
Generated scaffold layout
Settings -> Plugins -> Create plugin creates a standalone WASM action plugin project:
my-plugin/
README.md
Cargo.toml
plugin.toml
src/
lib.rsBuild the scaffold from the Wrengle repository root, not from inside the plugin folder:
just plugin-build "<plugin_dir>"
just plugin-smoke "<plugin_dir>"The build command writes plugin.wasm next to plugin.toml. Reload the local
path after that file exists. Generated Rust action scaffolds use
wrengle-plugin-sdk from this repository through a local Cargo path dependency;
if the generated folder moves, update that dependency to point at
crates/plugin-sdk in your Wrengle checkout.
Manifest example
id = "plugin.example.echo"
name = "Echo"
version = "0.1.0"
schema_version = 1
description = "Echoes selected text."
[runtime]
kind = "wasm"
entry = "plugin.wasm"
[[actions]]
id = "plugin.example.echo.run"
label = "Echo selection"
short_scheme = "echo"
description = "Echo selected text."Validation rules
Wrengle rejects the whole plugin for plugin-level manifest failures: invalid
plugin id, unsupported schema_version or runtime.kind, missing or invalid
runtime.entry, no declared actions for a WASM action plugin, duplicate action
ids, malformed TOML, or manifest size/shape limit violations.
Action-level validation failures are recorded as diagnostics. They skip only
the affected action. Valid actions must have ids that start with the plugin id
plus a dot. short_scheme must start with a lowercase ASCII letter followed
by only lowercase ASCII letters, digits, and hyphens.
label appears in the editor Actions menu. short_scheme is used for
action:// links when an action returns a reference.
Action request
Wrengle calls the WASM export run_action with this JSON envelope:
{
"actionId": "plugin.example.echo.run",
"request": {
"selection": "selected text",
"context": {
"notePath": "notes/daily.md",
"blockId": "block-1",
"surface": "editorToolbar",
"contextVersion": 1
},
"config": {},
"selectionDetail": {
"notePath": "notes/daily.md",
"blockId": "block-1",
"blockIds": ["block-1"],
"text": "selected text",
"anchor": 0,
"head": 13,
"docRevision": "9f1c2b…",
"contentHash": "sha256:…"
}
}
}Minimal action export
A minimal action export can be:
use extism_pdk::{plugin_fn, FnResult, Json};
use serde_json::json;
use wrengle_plugin_sdk::{ActionResult, RunEnvelope};
#[plugin_fn]
pub fn run_action(Json(envelope): Json<RunEnvelope>) -> FnResult<Json<ActionResult>> {
let RunEnvelope { action_id, request } = envelope;
let selection_length = request.selection.chars().count();
Ok(Json(ActionResult {
summary: format!("Echoed {selection_length} characters"),
reference: None,
data: json!({
"actionId": action_id,
"selection": request.selection,
"selectionLength": selection_length,
}),
}))
}Action result
{
"summary": "Echoed selected text",
"reference": null,
"data": {}
}If reference is non-null, Wrengle can create an inline action:// link using
the action's short_scheme. If reference is null, Wrengle still shows the
summary and records the action, but no link is inserted into the note.
Capability examples
Plugins are denied host access by default. Declare the host access the plugin needs:
[[capabilities]]
scope = "httpHosts"
hosts = ["api.example.com"]
[[capabilities]]
scope = "secret"
keys = ["example.token"]
[[capabilities]]
scope = "readVault"
[[capabilities]]
scope = "noteRead"
[[capabilities]]
scope = "noteWrite"
[[capabilities]]
scope = "blockRead"
[[capabilities]]
scope = "blockWrite"
[[capabilities]]
scope = "dashboardRead"
[[capabilities]]
scope = "pluginState"
[[capabilities]]
scope = "hostCommand"Declarations are not grants. Capability declarations describe the host APIs a plugin may ask for; they do not grant host access by themselves. Grants are local to this app install and are keyed to the plugin id, canonical local path identity, currently declared grant keys, and exact loaded code fingerprint. That fingerprint covers the manifest, WASM runtime, and view HTML bytes captured at reload. Editing any of them and reloading makes positive grants pending until you approve that code again; denials and revocations remain fail-closed.
Grant keys shown in Settings include exact parameterized keys such as
httpHosts:api.example.com and secret:github.token, plus unparameterized keys
such as noteRead, noteWrite, blockRead, blockWrite, dashboardRead,
pluginState, and hostCommand.
These grants gate host functions. A missing grant returns a host error instead
of giving the plugin direct filesystem, process, network, or keychain access.
dashboardRead is reserved for future workspace-aware plugin surfaces. Current
Plugin Builder output is limited to static custom views and offline feature apps
with plugin-scoped JSON state, so Builder-created apps do not receive
dashboardRead, dashboard.snapshot, note or block reads, note or block write
proposals, hostCommand, network access, secrets, shell access, or generated
WASM actions. Manual local plugin views may declare the scope, but cannot use
dashboard.snapshot yet.
Plugin state and secrets
Secret aliases must be lowercase ASCII letters, digits, ., -, or _.
Wrengle stores plugin secret values in the OS keychain under a namespace scoped
to the plugin id and the resolved plugin path identity. If the same plugin id
is loaded from a different resolved path, it cannot read the old path's secret
values. The namespace remains stable across approved code updates, but every
new code fingerprint needs its own positive secret grant. Startup preloads only
aliases for the current installed identity and exact code that already have a
matching secret grant. The renderer and plugin receive no raw inventory or
physical Keychain name. Successful values stay in the zeroizing native session
cache. An unavailable item leaves secret-dependent host calls disabled for that
session. Runtime secret changes fail closed rather than showing a Keychain
prompt during plugin use.
pluginState grants access to plugin-scoped JSON state through the host API.
State is isolated by plugin id and resolved path identity. Keys may be up to
256 bytes. Keys starting with wrengle. are reserved. Each JSON value is
capped at 32 KiB. One plugin subject is capped at 1 MiB.
Builder-created offline apps receive plugin-scoped JSON state automatically. Wrengle keeps that local app data with the installed Builder app until the user resets app state or deletes the app. Builder drafts declare expected state keys before Apply. Installed Builder app calls are limited to those declared state keys. Manual local plugins use the same isolated plugin state host API when their manifest requests it and the user grants it. Plugin state is local to this device and is not a sync or backup boundary.
Terminal access
There is no plugin terminal capability. Local WASM plugins cannot create
terminal tabs, write shell input, read terminal output, or receive selected
terminal output. Manifest scopes such as terminal are invalid. The plugin
will not load.
External ACP agents can request terminal commands only through visible proposal cards that the user must run explicitly. Terminal output can reach an agent only when the user selects output and attaches the size-limited snippet.
Host functions
The plugin runtime exposes these host imports:
| Host function | Capability | Input | Output |
|---|---|---|---|
wrengle_http | httpHosts for the exact destination host | HttpRequest JSON: { "method": "POST", "url": "https://api.example.com/path", "headers": [], "body": "{}" } | HostCallEnvelope<HttpResponse> JSON |
wrengle_secret | secret with the requested key | Secret key string | HostCallEnvelope<string> JSON |
wrengle_read_note | readVault or noteRead | Vault-relative Markdown note path string | HostCallEnvelope<string> JSON |
wrengle_write_note | noteWrite | NoteWriteRequest JSON with a Markdown note path | HostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff |
wrengle_create_note | noteWrite | NoteCreateRequest JSON for a path that must not exist yet | HostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff |
wrengle_move_note | noteWrite | NoteMoveRequest JSON with a source path and a destination that must not exist | HostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff |
wrengle_delete_note | noteWrite | NoteDeleteRequest JSON with a Markdown note path | HostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval" and a diff that removes every line |
wrengle_write_notes_batch | noteWrite; blocks items also require blockWrite | NoteBatchWriteRequest JSON with one summary and up to 50 per-note items | HostCallEnvelope<NoteBatchWriteResult> JSON: one proposal id covering every note, plus a per-note result list |
wrengle_read_blocks | blockRead | BlockReadRequest JSON with a Markdown note path | HostCallEnvelope<BlockReadResult> JSON with the current block snapshot, content hash, and optional revision |
wrengle_write_blocks | blockWrite | BlockWriteRequest JSON with a Markdown note path | HostCallEnvelope<BlockWriteResult> JSON with status: "pendingUserApproval", the queued block ops, and preview blocks |
wrengle_plugin_state_get | pluginState | State key string | HostCallEnvelope<PluginStateGetResult> JSON, for example { "found": false, "value": null } when missing |
wrengle_plugin_state_set | pluginState | PluginStateSetRequest JSON: { "key": "count", "value": 1 } | HostCallEnvelope<null> JSON |
wrengle_plugin_state_delete | pluginState | State key string | HostCallEnvelope<null> JSON |
wrengle_plugin_state_list | pluginState | PluginStateListRequest JSON: { "prefix": "habits.", "limit": 50 } | HostCallEnvelope<PluginStateListResult> JSON with entries, truncated, and totalBytes |
wrengle_plugin_state_clear | pluginState | Ignored; the call takes no arguments | HostCallEnvelope<number> JSON with the count of values removed |
wrengle_list_notes | readVault or noteRead | NoteListRequest JSON: { "prefix": "projects/", "limit": 100 } | HostCallEnvelope<NoteListResult> JSON with entries, nextCursor, and totalMatched |
wrengle_search_notes | readVault or noteRead | NoteSearchRequest JSON: { "query": "quarterly review", "k": 8 } | HostCallEnvelope<NoteSearchResult> JSON with bounded hits (path, title, snippet, score) |
wrengle_insert_note_markdown | noteWrite | NoteInsertRequest JSON: a note path, an optional anchor block id, before/after/replaceBlock, and Markdown | HostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff |
wrengle_request_host_command | hostCommand | HostCommandRequest JSON: { "command": "openNote", "args": { "path": "notes/a.md" } } | HostCallEnvelope<null> JSON. Accepting a request is not a promise that it ran |
Note path inputs must be vault-relative Markdown files. wrengle_read_note and
wrengle_read_blocks accept any Markdown note path in the vault, gated on the
capability shown for each function in the table above. wrengle_write_note
and wrengle_write_blocks additionally require the path to sit under
notes/, projects/, or people/, so a plugin can propose a write only
where Wrengle already keeps notes. Paths with absolute prefixes, whitespace
padding, ./.. segments, hidden path segments, backslashes, empty segments,
or non-.md suffixes are rejected before the host reads or writes the vault.
Read host functions — reading a note, listing notes, searching notes, and
reading blocks — accept a vault-relative Markdown path anywhere in your vault,
because listing and search cannot usefully be confined to three fixed folders.
Write host functions still require that path to sit under
notes/, projects/, or people/. wrengle_list_notes accepts an optional
folder prefix and clamps limit to at most 500 entries (default 100).
wrengle_search_notes clamps k to at most 20 hits (default 8) and returns
bounded excerpts, never a whole note. wrengle_insert_note_markdown follows
the same write path rule as wrengle_write_note: it creates a proposal that
inserts Markdown before, after, or in place of one block, and nothing is
written until the user approves it.
wrengle_create_note, wrengle_move_note, and wrengle_delete_note follow
the same write path rule as wrengle_write_note. wrengle_create_note and
the destination of wrengle_move_note must target a path that does not exist
yet, and wrengle_move_note checks its source path too.
wrengle_write_notes_batch covers up to 50 notes in a single proposal, using
the same per-item path rules as the single-note functions above; the whole
batch is approved or rejected together. The batch requires noteWrite, and
every tagged blocks item additionally requires blockWrite.
wrengle_read_blocks accepts a source field (openDocumentPreferred or
diskSnapshot) on its request. openDocumentPreferred reads the latest
native-admitted serializable editor projection when it is still based on the
unchanged saved note, and otherwise safely falls back to disk. Publication is
asynchronous, so a just-typed edit can appear on a subsequent read rather than
the first call made in the same UI turn. diskSnapshot always reads the saved
file. The live result's blocks, content hash, and revision come from one
snapshot; echo the hash and revision into a single-note write, block write, or
insert request so approval conflicts if the editor or its saved-file baseline
changes. Live revisions are not accepted for move, delete, or batch requests;
save or reload the note first. Move and delete approval also requires the
source note to be closed, preventing an open editor from recreating a removed
path.
wrengle_request_host_command requires hostCommand. It is fire-and-forget:
Wrengle validates and queues the request, but accepting it only means the
request was queued, not that the command ran. A single action may queue at
most four host commands. openNote opens a note at a path with an optional
block id to scroll to and highlight; openPluginView can only open a view the
requesting plugin itself owns; openSettings accepts only Wrengle's five
built-in settings pages (general, plugins, integrations, appearance, privacy),
never a plugin's own settings page.
Write host functions never apply changes directly from plugin code. They create
app-mediated proposals visible in the action dialog. Denying a proposal marks it
denied. Approving a note proposal re-checks the current note hash and any
expected revision guard against the proposal base. If either check no longer
matches, Wrengle marks the proposal conflict and does not write. Approved note
proposals use the same vault save and commit path as normal note saves.
NoteWriteRequest.newMarkdown is the complete raw note file content, matching
wrengle_read_note output. Include YAML frontmatter in newMarkdown when the
approved replacement should have frontmatter; omit it when the replacement should
have none.
Plugin noteWrite proposals are distinct from ACP agent edit approvals.
Wrengle previews and conflict-checks the plugin-supplied full Markdown
replacement. It does not reinterpret that payload as structured block ops.
For list, checklist, table, or other block-precise changes, prefer
wrengle_write_blocks so the proposal is expressed structurally. Wrengle
preserves the current note sidecar metadata during note proposal approval
until plugins have a structured sidecar/block write contract.
Block write approval re-reads the current note, checks the proposal hash and any expected revision, then applies the approved block edit through the same vault save and commit path as note proposals. Conflicts are reported instead of silently overwriting current note content. Block proposals currently support insert, update, and remove operations. Move operations return a structured plugin error until native block reordering is wired into the shared block editor.
Each host-function result is envelope JSON. Success is shaped like
{ "status": "ok", "value": ... }; errors are shaped like
{ "status": "err", "error": { "kind": "capabilityDenied", "message": "..." } }.
The Rust SDK maps those envelopes to typed Result values such as
Error::CapabilityDenied, Error::UserDenied, Error::InvalidRequest, and
Error::Conflict.
HTTP redirects are not followed. Request hosts must exactly match the
allow-list. HTTP methods are limited to POST, PUT, and PATCH. The
plugin runtime enforces a limited execution window and memory budget.
wrengle_http also requires HTTPS URLs with no credentials or fragments. It
rejects localhost and private or special IP targets after DNS resolution. It
caps request bodies at 64 KiB and response bodies at 32 KiB. It applies 15 s
total / 5 s connect HTTP timeouts.
Load and run checklist
- Build or copy
plugin.wasmnext toplugin.toml. - Open Settings -> Plugins.
- Add the plugin folder as a developer plugin path. Prefer an absolute path.
- Click Reload.
- Confirm the action appears in Loaded plugins.
- Open a note, select text, and choose the action from the editor toolbar's Actions menu.
- Click Confirm in the preview dialog.
For the repository echo example:
rustup target add wasm32-unknown-unknown
just plugin-echo-build
just plugin-echo-smokeFor generated Settings scaffolds, use the generic commands with the generated folder path:
just plugin-build "<plugin_dir>"
just plugin-smoke "<plugin_dir>"Troubleshooting
| Symptom | What to check |
|---|---|
missing plugin.wasm or missing runtime.entry file | Run just plugin-build "<plugin_dir>", then reload the local path. |
| Short-scheme collision | Change short_scheme in plugin.toml; every registered action needs a unique scheme. |
| Runtime failure | Run just plugin-smoke "<plugin_dir>" and inspect the action error reported by the host. |
| Action not appearing | Confirm the folder is listed in Settings -> Plugins, click Reload, then select text again to reopen Actions. |