WrengleWrengle
Custom views

Custom views

Beta

Give your plugin its own screen. A view lets a local plugin ship its own user interface. Wrengle renders it as a tab in the editor area — it can sit in a split next to your notes — inside a sandboxed iframe.

Views are part of the local developer preview. The bridge protocol below may change between releases.

Declare a view

Add a [[views]] block to your plugin.toml:

toml
[[views]]
id = "plugin.example.my-view"
label = "My View"
entry = "ui/index.html"
accepts_selection = true
selection_label = "Send to My View"
FieldRequiredDescription
idYesUnique within the manifest across actions and views. Must start with the plugin id followed by a dot. Lowercase ASCII letters, digits, dots, and hyphens only; no ...
labelYesDisplay name shown in the command palette and Actions menu.
entryYesRelative path to the HTML file inside the plugin folder. The file must exist at reload time.
accepts_selectionNoIf true, an item appears in the editor Actions menu to send selected text to this view. Defaults to false.
selection_labelNoLabel for the Actions menu item. Defaults to Send selection to <label>. Only meaningful when accepts_selection = true.

A plugin can be view-only: no [runtime], no plugin.wasm, and no build step are required. The manifest only needs at least one action or one view to be valid.

One self-contained HTML file

Your entry file must be one self-contained HTML file. Wrengle validates and captures it during plugin reload. While developing, edit the file, click Reload, then reopen the tab to use the new immutable snapshot.

Constraints:

  • All CSS and JavaScript must be inlined in the single file. No external stylesheets, no src attributes pointing to other files.
  • If you use a build tool, configure it to output a single bundled file.
  • The file must be no larger than 5 MB.

The bridge protocol

Wrengle installs a host-owned, per-document message channel before plugin code runs. Incoming messages are also delivered as message events for compatibility. Except for the declared state helpers on Builder-created apps, the iframe has no access to Wrengle's internal APIs.

DirectionMessage typePayloadWhen
host → viewwrengle:init{ theme: "light" | "dark", themeTokens: Record<string, string> }Sent once after the host-owned channel is ready.
host → viewwrengle:theme{ theme: "light" | "dark", themeTokens: Record<string, string> }Sent when the user changes the app theme or active theme tokens.
host → viewwrengle:selection{ text: string }Sent when the user invokes the view's selection action.

Wrengle injects the active theme variables into the view before first paint and sends the same values as themeTokens whenever the active theme changes. themeTokens keys are CSS custom property names with the leading --, such as --background, --foreground, --border, --primary, and --primary-foreground. Use those variables and the rest of the Wrengle theme tokens instead of maintaining a separate plugin color palette.

Add this block inside a script element in your HTML to implement the bridge:

javascript
function applyWrengleTheme(message) {
  if (message.theme) {
    document.documentElement.dataset.theme = message.theme;
  }
 
  if (!message.themeTokens || typeof message.themeTokens !== "object") return;
 
  for (const [name, value] of Object.entries(message.themeTokens)) {
    if (
      /^--[A-Za-z0-9_-]+$/.test(name) &&
      typeof value === "string" &&
      value.length > 0
    ) {
      document.documentElement.style.setProperty(name, value);
    }
  }
}
 
window.addEventListener("message", (event) => {
  if (event.source !== window.parent) return;
  const msg = event.data;
  if (!msg || typeof msg !== "object") return;
  if (msg.type === "wrengle:init" || msg.type === "wrengle:theme") {
    applyWrengleTheme(msg);
  } else if (msg.type === "wrengle:selection") {
    // msg.text is the text the user selected in the editor
  }
});

Wrengle performs the ready handshake automatically. Direct parent-window messages are ignored. Navigating the iframe revokes the initial document's channel, so its authority cannot follow the stable iframe window.

Builder-created offline state helpers

Plugin Builder currently creates static custom views and offline feature apps. An offline feature app's draft declares the state helpers it expects to use, and Wrengle checks the generated code against that declaration before you apply it. Installed app state is plugin-scoped local JSON state that Wrengle manages. Manual local plugin views cannot use these generated window.wrengle helpers yet.

Plugin Builder injects these helpers into offline feature apps so generated code never needs direct parent-window access:

HelperAvailabilityBehavior
window.wrengle.state.get(key)Builder-created offline feature appsReads plugin-scoped local JSON state.
window.wrengle.state.set(key, value)Builder-created offline feature appsWrites plugin-scoped local JSON state.
window.wrengle.state.delete(key)Builder-created offline feature appsDeletes plugin-scoped local JSON state.
window.wrengle.state.list(options)Builder-created offline feature appsLists only the keys this app declared, with their values.
window.wrengle.state.clear()Builder-created offline feature appsDeletes only the values the app itself saved.

A draft declares its expected state keys, and Wrengle checks the generated code against those keys before you apply it. Once installed, an app's calls stay scoped to its own local JSON state and limited to the keys it declared. Builder-created offline apps can use state.list and state.clear, but only over the keys they declared and the values they saved. Wrengle's own reserved keys are never listed and never cleared, so an app can reset the data it saved without touching anything Wrengle keeps for it. Clearing removes only the app's saved data; its plugin files, prompt history, and conversations stay put. You can still reset an app's state yourself from Plugin Builder.

A Builder-created app reaches your workspace only through the reads and write proposals its generated manifest declares. An app that declares none cannot read or write your notes, projects, blocks, meetings, workflows, files, or settings. No Builder-created app can use external network access, secrets, shell commands, generated WASM actions, or direct access to the desktop process outside the window.wrengle bridge.

A prompt is refused up front only when it needs external network access, credentials, secrets, or an unsupported platform. A prompt that needs your notes is admitted: Plugin Builder designs against the shape of your workspace, asks before reading note content, and declares the reads the app will need. An app-mediated write is always a proposal — nothing changes until you approve it in Wrengle.

Workspace listing helper

A plugin view that holds the "Note read" permission can also ask Wrengle for a page of note paths and titles:

HelperAvailabilityBehavior
window.wrengle.listNotes(options)Plugin views granted note readLists note paths and titles under an optional folder prefix.
window.wrengle.searchNotes(options)Plugin views granted note readFinds notes by relevance and returns bounded titles and excerpts.

options accepts prefix (a folder such as "projects/"), limit (at most 500 entries, default 100), and cursor (the nextCursor from a previous page). The result carries entries, nextCursor, and totalMatched. Listing returns paths and titles only; it never returns note text.

searchNotes takes query (up to 4096 characters) and k (at most 20 hits, default 8). Each hit carries path, title, a bounded snippet, and a relevance score used only for ordering. Search returns excerpts, never a whole note.

Workspace read and write requests

A plugin view holding the matching grant can ask Wrengle to read a single note or its blocks, or to propose a write. Note and block paths must be vault-relative Markdown files:

HelperAvailabilityBehavior
window.wrengle.readNote(path)Plugin views granted note readReads one note's raw Markdown, anywhere in your vault.
window.wrengle.readBlocks(options)Plugin views granted block readReads one note's structured block snapshot.
window.wrengle.writeNote(request)Plugin views granted note writeProposes replacing a note's whole Markdown content.
window.wrengle.writeNotes(request)Plugin views granted note writeProposes one batch covering changes to 1–50 notes. Block-edit items also require block write.
window.wrengle.writeBlocks(request)Plugin views granted block writeProposes structured block edits to one note.
window.wrengle.insertNote(request)Plugin views granted note writeProposes inserting Markdown before, after, or in place of one block.
window.wrengle.createNote(request)Plugin views granted note writeProposes a new note at a path that does not exist yet.
window.wrengle.moveNote(request)Plugin views granted note writeProposes moving a note to a path that does not exist yet.
window.wrengle.deleteNote(request)Plugin views granted note writeProposes deleting a note.

readNote and readBlocks accept any Markdown note path in the vault, the same widened rule as the listing helpers above. writeNote, writeBlocks, and insertNote still require the note to sit under notes/, projects/, or people/. Every write request creates a proposal you approve or decline, the same approval flow a plugin action uses; nothing is written until you approve it, and approving re-checks the note before saving so a stale write is reported as a conflict instead of silently overwriting your changes.

readBlocks({ path, source: "openDocumentPreferred" }) uses the latest native-admitted serializable editor projection when it is still based on the unchanged saved note, then falls back to disk when no safe live projection exists. 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. source: "diskSnapshot" always reads the saved file. The live result's blocks, content hash, and revision are one snapshot; echo its hash and revision into a single-note writeNote, writeBlocks, or insertNote request so approval conflicts if the editor or saved-file baseline changes. Live revisions are not supported for move, delete, or batch writes. Close a source note before approving its move or deletion.

createNote, moveNote, and deleteNote follow the same write-path rule as writeNote: the note must sit under notes/, projects/, or people/. createNote and the destination of moveNote must target a path that does not exist yet, and moveNote also checks its source path.

writeNotes({ summary, items }) creates one approval proposal covering 1–50 items. Each item has an op of write, create, move, delete, insert, or blocks, followed by the same fields used by that single-note helper. The batch itself requires note write; a blocks item additionally requires block write. Approval checks every item's preconditions before changing any note, so a detected conflict leaves the whole batch unapplied. The result has one proposalId plus an items result for every requested note.

Where views appear

  • Command palette: an "Open <label>" entry appears for every loaded view, with the plugin name shown as attribution. Use ⌘K (macOS) or Ctrl+K to open the palette.
  • Editor Actions menu: when accepts_selection = true, an item labelled selection_label appears in the floating toolbar's Actions menu when text is selected in a note.

The palette command always opens a new tab, so you can run several instances of the same view side by side. The selection action instead reuses the most recently used open tab of that view — it focuses that tab and sends the text there, opening a new tab only when none is open.

For a runnable reference, follow the JSON Compare tutorial, which loads examples/plugins/json-compare with no build step.

Sandbox and trust

The iframe is created with sandbox="allow-scripts" and no allow-same-origin. This means:

  • Except for the declared state helpers on Builder-created apps, the view has no access to the app, your vault, or Wrengle's APIs.
  • The view cannot read cookies, localStorage, or sessionStorage from the app origin.
  • The view cannot call the host app's IPC or access the parent DOM.

Every view receives a mandatory host-owned offline Content Security Policy. Wrengle applies the policy to every view. Direct network requests, WebSockets, WebRTC, navigation, forms, nested frames, and external scripts, styles, fonts, and media are blocked. Embedded WebKit and WebView2 do not implement every CSP3 rule uniformly, so Wrengle also replaces peer-network constructors in its host-owned bootstrap before plugin code runs. The httpHosts capability gates only Wrengle host-bridge HTTP calls made by WASM actions; it never relaxes a view's offline policy.

View HTML, its exact loaded code identity, and the offline policy come from the same immutable snapshot captured at reload. Changing the manifest, WASM, or view bytes makes positive grants pending, so you must approve the changed code again. Plugin state and keychain values remain in their stable plugin-and-path namespace. Load only local plugin code you trust.

Troubleshooting

SymptomWhat to check
View missing from paletteOpen Settings → Plugins and click Reload. Check Diagnostics for manifest errors.
Blank tabVerify the entry path is correct relative to the plugin folder and that the file is under 5 MB.
Selection item missingSet accepts_selection = true in your [[views]] block and reload.
Stale UI while developingClick Reload, then reopen the tab so Wrengle captures and uses the changed HTML.
docs / plugins/viewsAll documentation