WrengleWrengle
Writing one by hand

Writing one by hand

Beta

This 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:

  1. Open Settings -> Plugins.

  2. Enter a plugin name and click Create plugin.

  3. From the repository root, build and smoke-test the generated folder:

    sh
    just plugin-build "<generated_plugin_dir>"
    just plugin-smoke "<generated_plugin_dir>"
  4. Return to Settings -> Plugins and click Reload.

  5. Select text in a note.

  6. 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:

sh
rustup target add wasm32-unknown-unknown
just plugin-echo-build
just plugin-echo-smoke

Then try it in the app:

  1. Start Wrengle with just dev.
  2. Open Settings → Plugins → Developer → Dev plugin paths.
  3. Add the absolute path to examples/plugins/echo-action.
  4. Click Reload.
  5. Confirm Loaded plugins lists Echo selection.
  6. Open a note and select text.
  7. Open the floating toolbar's Actions menu.
  8. 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.

TemplateTypeGenerated filesCapability grants
Echo actionWASM actionCargo.toml, plugin.toml, src/lib.rs, README.mdNone by default
Note writer proposalWASM actionRust action scaffold using wrengle_plugin_sdk::hostnoteRead, noteWrite
HTTP secret actionWASM actionRust action scaffold using host::secret and host::httpsecret:<alias>, httpHosts:<host>
ViewView-only pluginplugin.toml, ui/index.html, README.mdNone 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:

toml
[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:

rust
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:

toml
[[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:

toml
[[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.

rust
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:

  1. The plugin declares noteWrite or blockWrite.
  2. The user grants that capability for this local plugin install.
  3. The action calls host::write_note or host::write_blocks.
  4. The host returns pendingUserApproval with a proposal id and preview data.
  5. Wrengle shows the proposal in the action dialog.
  6. The user approves or denies the proposal.
  7. 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.

docs / plugins/sdk-and-creatorAll documentation