WrengleWrengle
Manifest and permissions

Manifest and permissions

Beta

This 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

text
my-plugin/
  plugin.toml
  plugin.wasm

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

text
my-plugin/
  README.md
  Cargo.toml
  plugin.toml
  src/
    lib.rs

Build the scaffold from the Wrengle repository root, not from inside the plugin folder:

sh
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

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

json
{
  "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:

rust
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

json
{
  "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:

toml
[[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 functionCapabilityInputOutput
wrengle_httphttpHosts for the exact destination hostHttpRequest JSON: { "method": "POST", "url": "https://api.example.com/path", "headers": [], "body": "{}" }HostCallEnvelope<HttpResponse> JSON
wrengle_secretsecret with the requested keySecret key stringHostCallEnvelope<string> JSON
wrengle_read_notereadVault or noteReadVault-relative Markdown note path stringHostCallEnvelope<string> JSON
wrengle_write_notenoteWriteNoteWriteRequest JSON with a Markdown note pathHostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff
wrengle_create_notenoteWriteNoteCreateRequest JSON for a path that must not exist yetHostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff
wrengle_move_notenoteWriteNoteMoveRequest JSON with a source path and a destination that must not existHostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff
wrengle_delete_notenoteWriteNoteDeleteRequest JSON with a Markdown note pathHostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval" and a diff that removes every line
wrengle_write_notes_batchnoteWrite; blocks items also require blockWriteNoteBatchWriteRequest JSON with one summary and up to 50 per-note itemsHostCallEnvelope<NoteBatchWriteResult> JSON: one proposal id covering every note, plus a per-note result list
wrengle_read_blocksblockReadBlockReadRequest JSON with a Markdown note pathHostCallEnvelope<BlockReadResult> JSON with the current block snapshot, content hash, and optional revision
wrengle_write_blocksblockWriteBlockWriteRequest JSON with a Markdown note pathHostCallEnvelope<BlockWriteResult> JSON with status: "pendingUserApproval", the queued block ops, and preview blocks
wrengle_plugin_state_getpluginStateState key stringHostCallEnvelope<PluginStateGetResult> JSON, for example { "found": false, "value": null } when missing
wrengle_plugin_state_setpluginStatePluginStateSetRequest JSON: { "key": "count", "value": 1 }HostCallEnvelope<null> JSON
wrengle_plugin_state_deletepluginStateState key stringHostCallEnvelope<null> JSON
wrengle_plugin_state_listpluginStatePluginStateListRequest JSON: { "prefix": "habits.", "limit": 50 }HostCallEnvelope<PluginStateListResult> JSON with entries, truncated, and totalBytes
wrengle_plugin_state_clearpluginStateIgnored; the call takes no argumentsHostCallEnvelope<number> JSON with the count of values removed
wrengle_list_notesreadVault or noteReadNoteListRequest JSON: { "prefix": "projects/", "limit": 100 }HostCallEnvelope<NoteListResult> JSON with entries, nextCursor, and totalMatched
wrengle_search_notesreadVault or noteReadNoteSearchRequest JSON: { "query": "quarterly review", "k": 8 }HostCallEnvelope<NoteSearchResult> JSON with bounded hits (path, title, snippet, score)
wrengle_insert_note_markdownnoteWriteNoteInsertRequest JSON: a note path, an optional anchor block id, before/after/replaceBlock, and MarkdownHostCallEnvelope<NoteWriteResult> JSON with status: "pendingUserApproval", a proposal id, and a line diff
wrengle_request_host_commandhostCommandHostCommandRequest 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

  1. Build or copy plugin.wasm next to plugin.toml.
  2. Open Settings -> Plugins.
  3. Add the plugin folder as a developer plugin path. Prefer an absolute path.
  4. Click Reload.
  5. Confirm the action appears in Loaded plugins.
  6. Open a note, select text, and choose the action from the editor toolbar's Actions menu.
  7. Click Confirm in the preview dialog.

For the repository echo example:

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

For generated Settings scaffolds, use the generic commands with the generated folder path:

sh
just plugin-build "<plugin_dir>"
just plugin-smoke "<plugin_dir>"

Troubleshooting

SymptomWhat to check
missing plugin.wasm or missing runtime.entry fileRun just plugin-build "<plugin_dir>", then reload the local path.
Short-scheme collisionChange short_scheme in plugin.toml; every registered action needs a unique scheme.
Runtime failureRun just plugin-smoke "<plugin_dir>" and inspect the action error reported by the host.
Action not appearingConfirm the folder is listed in Settings -> Plugins, click Reload, then select text again to reopen Actions.
docs / plugins/manifest-actions-capabilitiesAll documentation