Writing one by hand
BetaThis is a reference for developers writing a plugin by hand. Most readers don't need it — see Build one with the Builder instead.
Wrengle's plugin SDK and in-app creator are for local developer-preview plugins. They do not imply marketplace availability, remote installation, or public API stability. Generated folders are trusted local code. Host access works only through grants made in this app install.
Write your first local WASM action plugin
Use the generated WASM action scaffold when you want to start from the app instead of copying the echo example:
-
Open Settings -> Plugins.
-
Enter a plugin name and click Create plugin.
-
From the repository root, build and smoke-test the generated folder:
sh just plugin-build "<generated_plugin_dir>" just plugin-smoke "<generated_plugin_dir>" -
Return to Settings -> Plugins and click Reload.
-
Select text in a note.
-
Open Actions, choose the generated action, review the local plugin disclosure, and click Confirm. If the action requests capabilities you have not decided yet, Wrengle opens a first-use permissions dialog before the action runs.
You don't have to hand-write a plugin at all. See Build one with the Builder for the no-code path, or Build one by describing it to hand the work to an LLM you already use.
For palette rows, slash menu items, context menus, keybindings, status-bar items, settings pages, panels, host commands, and policy controls a hand-written manifest can declare, see UI contributions.
Quick start with the echo plugin
From a source checkout:
rustup target add wasm32-unknown-unknown
just plugin-echo-build
just plugin-echo-smokeThen try it in the app:
- Start Wrengle with
just dev. - Open Settings → Plugins → Developer → Dev plugin paths.
- Add the absolute path to
examples/plugins/echo-action. - Click Reload.
- Confirm Loaded plugins lists
Echo selection. - Open a note and select text.
- Open the floating toolbar's Actions menu.
- Choose Echo selection and click Confirm.
The echo plugin returns a summary toast such as Echoed 10 characters. It does
not insert a link because the example returns reference: null.
Creator templates
Open Settings → Plugins → Developer → Plugin creator to generate a local plugin folder.
The creator derives a plugin.<slug> id from the name, previews the manifest,
and shows the capability grants the template will request before files are
created.
| Template | Type | Generated files | Capability grants |
|---|---|---|---|
| Echo action | WASM action | Cargo.toml, plugin.toml, src/lib.rs, README.md | None by default |
| Note writer proposal | WASM action | Rust action scaffold using wrengle_plugin_sdk::host | noteRead, noteWrite |
| HTTP secret action | WASM action | Rust action scaffold using host::secret and host::http | secret:<alias>, httpHosts:<host> |
| View | View-only plugin | plugin.toml, ui/index.html, README.md | None by default |
You can also add explicit grant keys such as pluginState, blockRead, or
blockWrite before creating the scaffold. The creator writes those keys as
manifest [[capabilities]] declarations. The user still grants or denies them
later in Wrengle. Declarations are not grants. Plugin Builder is separate from
Plugin Creator. It currently creates only static custom views and offline
feature apps with plugin-scoped JSON state. Builder-created apps do not receive
dashboardRead, dashboard snapshots, workspace readers, workspace writers,
network access, secrets, shell access, or generated WASM actions. Workspace-aware
generated plugins are future work.
SDK import example
Generated WASM action templates use the repository-local Rust SDK:
[dependencies]
extism-pdk = "1.4"
serde_json = "1"
wrengle-plugin-sdk = { path = "../../../crates/plugin-sdk" }The exact relative path depends on where the generated folder lives. If you move
the plugin folder, edit the wrengle-plugin-sdk path to point at
crates/plugin-sdk in your Wrengle checkout.
Use SDK DTOs and host helpers from wrengle_plugin_sdk:
use extism_pdk::{plugin_fn, FnResult, Json};
use serde_json::json;
use wrengle_plugin_sdk::{host, ActionResult, Error, NoteWriteRequest, RunEnvelope};
#[plugin_fn]
pub fn run_action(Json(envelope): Json<RunEnvelope>) -> FnResult<Json<ActionResult>> {
let RunEnvelope { action_id, request } = envelope;
let path = "notes/inbox/plugin-output.md";
let existing = match host::read_note(path) {
Ok(markdown) => markdown,
Err(Error::NotFound(_)) => String::new(),
Err(error) => return Err(extism_pdk::Error::msg(error.to_string())),
};
let result = host::write_note(NoteWriteRequest {
path: path.into(),
expected_revision: None,
base_content_hash: None,
new_markdown: format!("{}\n\n{}\n", existing.trim_end(), request.selection),
summary: "Append selected text from plugin".into(),
})
.map_err(|error| extism_pdk::Error::msg(error.to_string()))?;
Ok(Json(ActionResult {
summary: "Prepared note write proposal".into(),
reference: None,
data: json!({
"actionId": action_id,
"proposalId": result.proposal_id,
"status": result.status,
}),
}))
}Exact grant examples
Parameterized grants include their exact scope:
[[capabilities]]
scope = "httpHosts"
hosts = ["api.github.com", "api.example.com"]
[[capabilities]]
scope = "secret"
keys = ["github.token", "example.token"]Settings shows those as httpHosts:api.github.com,
httpHosts:api.example.com, secret:github.token, and
secret:example.token. The host bridge accepts only the exact granted HTTP
host. Secret calls can read only the exact granted alias.
Unparameterized grant keys are written directly:
[[capabilities]]
scope = "noteRead"
[[capabilities]]
scope = "noteWrite"
[[capabilities]]
scope = "blockWrite"
[[capabilities]]
scope = "pluginState"noteRead gates note reads. noteWrite gates note write proposals.
blockWrite gates block write proposals. pluginState gates plugin-scoped
JSON state. dashboardRead is reserved for future workspace-aware plugin
surfaces. Current Builder-created apps are offline and state-only.
readVault is accepted as a note-read capability for current host functions.
New local plugins should prefer the narrower noteRead grant when they
only read note content.
Note and block host helpers accept vault-relative Markdown note paths. Read
helpers accept any Markdown note path in the vault. Write helpers additionally
require the path to sit under notes/, projects/, or people/. Both reject
absolute paths, whitespace padding, ./.. traversal, hidden path segments,
backslashes, empty segments, and non-.md files before reading or proposing a
write. Reading still requires a grant — readVault or noteRead for note
reads, blockRead for block reads — so a wider path surface never means an
ungated one.
The SDK also exposes host::list_notes, host::search_notes,
host::insert_note_markdown, host::plugin_state_list,
host::plugin_state_clear, and host::request_host_command.
host::list_notes and host::search_notes share the read-side readVault or
noteRead gate above. host::insert_note_markdown shares the noteWrite
write gate and returns a pendingUserApproval proposal the user must approve
before anything is written, exactly like host::write_note.
host::request_host_command needs its own hostCommand grant and is
fire-and-forget: the plugin learns only that Wrengle accepted the request, not
whether it ran.
Host error handling
Host helpers return Result<T, wrengle_plugin_sdk::Error>. Missing grants,
denied proposals, invalid requests, stale write bases, and host failures are
not panics. Handle them in plugin code.
fn update_counter() -> Result<ActionResult, String> {
match host::state_get("counter") {
Ok(result) => {
let value = result.value.unwrap_or_else(|| serde_json::json!(0));
host::state_set("counter", value).map_err(|error| error.to_string())?;
Ok(ActionResult::summary("Updated plugin state"))
}
Err(Error::CapabilityDenied(message)) => Ok(ActionResult::summary(format!(
"Missing pluginState grant: {message}"
))),
Err(error) => Err(error.to_string()),
}
}When a grant is missing, the host returns a capabilityDenied envelope and the
SDK maps it to Error::CapabilityDenied. Running without a grant does not
bypass the host API.
Write proposal flow
noteWrite and blockWrite do not let plugin code write directly. They let the
plugin ask Wrengle to prepare app-mediated write proposals:
- The plugin declares
noteWriteorblockWrite. - The user grants that capability for this local plugin install.
- The action calls
host::write_noteorhost::write_blocks. - The host returns
pendingUserApprovalwith a proposal id and preview data. - Wrengle shows the proposal in the action dialog.
- The user approves or denies the proposal.
- Approved note proposals re-check the current note before saving. Conflicts are reported instead of overwriting silently.
host::write_note sends a complete raw Markdown replacement candidate. Wrengle
previews and conflict-checks that candidate. It does not reinterpret plugin
noteWrite payloads as structured block ops. Prefer host::write_blocks for
list, checklist, table, or other block-precise edits.
Block write proposals use the same approval shape. Approval re-reads the current note, checks the proposal hash and any expected revision, then applies the block edit through the vault save and commit path. Conflicts are reported instead of silently overwriting current note content. Use insert, update, and remove block operations for proposals. Move operations currently return a structured plugin error until native block reordering is wired into the shared block editor.
View network boundary
Manual/developer view-only plugins use local HTML and JavaScript inside a
sandboxed iframe. The view bridge currently sends theme and selected-text
messages only. A mandatory host-owned offline Content Security Policy blocks
direct view networking and external resources for both manual and
Builder-created views. httpHosts gates only Wrengle host-bridge HTTP made by
WASM actions and does not relax the view policy.