Build a Linvell plugin.

Protocol 1 · P3 source preview · Updated October 29, 2026

These APIs are implemented in unpublished source on main. Build from a checkout containing LinvellPluginKit; the current public app release does not include them. There is no separate dev branch.

Linvell Dev is the build script’s display name for the local debug app, used to distinguish it from the installed app. It is built from main, not a dev branch.

1. Build and load the example

Requirements: macOS 15.6 or newer, Xcode / a Swift 6 toolchain, and a source checkout of Linvell. The example is in Examples/HelloPlugin. Run the following from the repository root in Bash. Build output and caches stay under ~/dev/build.

bash script/build_and_run.sh --build-only
open "$HOME/dev/build/linvell/debug/Linvell.app"

PLUGIN_BUILD="$HOME/dev/build/linvell/hello-plugin"
mkdir -p "$PLUGIN_BUILD"/{package,tmp,cache,config,security}
export TMPDIR="$PLUGIN_BUILD/tmp"
export CLANG_MODULE_CACHE_PATH="$PLUGIN_BUILD/cache/clang"
export SWIFTPM_MODULECACHE_OVERRIDE="$PLUGIN_BUILD/cache/swift"
PLUGIN_ARGS=(--package-path Examples/HelloPlugin
  --scratch-path "$PLUGIN_BUILD/swiftpm"
  --cache-path "$PLUGIN_BUILD/cache"
  --config-path "$PLUGIN_BUILD/config"
  --security-path "$PLUGIN_BUILD/security")
swift build "${PLUGIN_ARGS[@]}" --product HelloPlugin
PLUGIN_BIN="$(swift build "${PLUGIN_ARGS[@]}" --show-bin-path)"
cp "$PLUGIN_BIN/HelloPlugin" "$PLUGIN_BUILD/package/HelloPlugin"
cp Examples/HelloPlugin/plugin.json "$PLUGIN_BUILD/package/plugin.json"

In Linvell Dev, choose Edit → Load Development Plugin… and select ~/dev/build/linvell/hello-plugin/package. Review the trust notice and editor permissions, then choose Load Plugin. Open the command palette with ⌘⇧P and run Hello: Greet. The status bar shows the configured greeting and a persistent invocation count.

Create a new text document, type a short line, then run Hello: Uppercase Document. The whole document is replaced and ⌘Z undoes the change. The command resets the selection to the beginning. It does not explicitly save the file; your existing auto-save policy still applies.

For P2 commands, run Plugins → Hello Swift → Hello: Insert Prefix or ⌘⌃9. Use Hello: Document Events to inspect the last event. The same menu opens user and workspace configuration files.

The P3 example adds Example Tree, Example List, Example Tools, a live status item, Plugin Settings…, Plugin Forest, file icons and a Greeting Function snippet. All appear under Plugins → Hello Swift. Expand the tree, select Developer Guide and press Run Selected; the status message reports the view and item IDs. Refresh Example List to see a runtime data update.

2. Describe the package

A code plugin contains plugin.json and a package-relative executable. P1/P2 fields remain compatible; P3 adds optional views, settings, themes, icons and snippets. The full example is in Examples/HelloPlugin.

{
  "id": "dev.linvell.hello",
  "name": "Hello Swift",
  "version": "0.3.0",
  "protocolVersion": 1,
  "executable": "HelloPlugin",
  "permissions": [
    "document.read",
    "document.write",
    "storage"
  ],
  "commands": [
    {
      "id": "hello.inspect",
      "title": "Inspect",
      "menu": "plugins"
    }
  ],
  "views": [
    {
      "id": "actions",
      "title": "Actions",
      "kind": "list",
      "items": [
        {
          "id": "inspect",
          "title": "Inspect selection",
          "command": "hello.inspect"
        }
      ]
    }
  ]
}

A resource-only package omits executable and uses commands: [], no document events, and permissions: []. It must declare at least one contribution. Load Examples/ResourcePack directly to try a theme, icons and snippet without compiling or running a plugin program.

Protocol is still 1. IDs use ASCII letters/digits/dots/underscores/hyphens, start with a letter or digit, and are at most 128 bytes. Manifest size is 64 KiB; at most 100 commands. Contributions reference command and icon IDs declared by the same package. Executables and PNG assets cannot escape the package via absolute paths, parent traversal or symlinks.

document.read allows snapshots/events; document.write additionally allows document edits; storage allows persistent state. Declarative UI/resource data needs no new OS permission. Code still runs as the current user outside a security sandbox. Configuration/state are non-secret storage.

3. Write Swift handlers

The P3 SDK adds PluginItem, PluginViewUpdate and PluginUIEvent. Return viewUpdates from command responses. For document-driven UI updates use onDocumentEvent, returning PluginCommandResult; the older onEvent callback returning only state patches is still supported. If both are supplied, onDocumentEvent takes precedence.

import LinvellPluginKit

try PluginServer.run(onDocumentEvent: { event in
    PluginCommandResult(viewUpdates: [
        PluginViewUpdate(id: "counter", text: "Revision: \(event.revision)")
    ])
}) { context in
    if context.command == "hello.inspect" {
        return PluginCommandResult(
            message: "Item: \(context.uiEvent?.itemID ?? "none")",
            viewUpdates: [PluginViewUpdate(id: "actions", items: [
                PluginItem(id: "new", title: "Updated row", command: "hello.inspect")
            ])]
        )
    }
    return PluginCommandResult()
}

Declare the referenced views and commands in plugin.json. This fragment demonstrates the API; the complete buildable example includes document permissions and subscriptions. Existing context.document/environment and state/range-edit APIs remain unchanged. Stdout is reserved for framed JSON-RPC, never logging.

4. Protocol 1 reference

Other languages can implement the same JSON-RPC 2.0 protocol over stdin/stdout: Content-Length: N\r\n\r\n, then exactly N UTF-8 bytes. N counts bytes. Echo the integer request ID; send exactly one response. The Swift SDK limits headers to 8 KiB; SDK and host limit JSON bodies to 32 MiB.

// Host → plugin
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}
// P3 SDK → host (P1 command-only SDK may omit capabilities)
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"capabilities":["documentEvents","rangeEdits","configuration","storage","nativeUI","resources"]}}

{"jsonrpc":"2.0","id":2,"method":"commands/execute","params":{"command":"hello.prefix","document":{"id":"document-uuid","text":"hello","revision":7},"environment":{"configuration":{"prefix":"Hi: "},"globalState":{},"workspaceState":{}}}}
{"jsonrpc":"2.0","id":2,"result":{"edits":[{"location":0,"length":0,"text":"Hi: "}],"documentRevision":7,"message":"Done"}}

// Error response (fails this session; reload to restart)
{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"Unable to perform command"}}

Initialization, commands and event acknowledgements each have a 10-second request timeout. Work is serialized per plugin per window; a second command while one is pending is rejected. The current text and final replacement each have a 2 MiB UTF-8 limit; status messages have a 2,000 Swift-character limit. Unknown optional context fields can be ignored.

5. Document events

Declare events: ["open", "change", "save", "close"] and document.read. Loading replays open events for current text documents and can start the process immediately. Open, typing, editor transactions, undo/redo, acknowledged saves (including auto-save) and close feed the same event stream. Events never authorize automatic document editing.

{"jsonrpc":"2.0","id":3,"method":"documents/event","params":{"kind":"change","documentID":"document-uuid","revision":8,"document":{"id":"document-uuid","text":"Hi: hello","revision":8},"environment":{"configuration":{},"globalState":{},"workspaceState":{}}}}
{"jsonrpc":"2.0","id":3,"result":{"stateUpdates":[{"scope":"global","key":"lastRevision","value":8}]}}

Reply with optional stateUpdates and viewUpdates. Document event results cannot contain replacementText or edits. The older onEvent callback returns state patches; onDocumentEvent returns a full result for state and UI updates. Custom event runtimes advertise documentEvents at initialization.

Pending changes for the same document are coalesced until a lifecycle boundary, so this is a snapshot stream, not a lossless keystroke log. Already queued events are handled before a command. At most 256 events may wait; overflow or handler failure stops the session. IME intermediate changes are deferred. Close, oversized documents and saves of an older revision carry metadata only (no document text). Closing a window stops the process without promising final close delivery.

6. Versioned range edits

Return edits with documentRevision matching the request. Each edit has zero-based location, length in UTF-16 code units and replacement text. A zero length inserts; empty text deletes. The host sorts edits and rejects overlaps, duplicate start positions, negative/out-of-bounds offsets and boundaries splitting Unicode characters. At most 1,000 edits and 2 MiB of inserted text are allowed.

Choose either edits or replacementText, never both. The originating document, pane, revision and text must still match, with no active IME composition. All ranges form one native undo transaction; range edits place cursors at edit ends. Existing auto-save policy still applies.

8. Configuration

The effective environment.configuration merges manifest configuration defaults, the plugin’s user configuration.json, then plugins[pluginID] in the current workspace’s .linvell/settings.json. This is a shallow merge by key: nested objects are replaced as a whole. Save edits before the next command/event to use them; unsaved settings are not applied.

// Workspace .linvell/settings.json (JSONC comments are supported)
{
  "plugins": {
    "dev.linvell.hello": {
      "greeting": "Hello from this workspace!",
      "prefix": "Project: "
    }
  }
}

Use Plugins → Hello Swift → User Plugin Configuration… or Workspace Plugin Configuration… to open the file in the native editor. The user file starts as {}; workspace initialization inserts only the plugin’s missing section and preserves comments and unrelated settings, with an external-change check. Local and SSH workspace settings use the existing settings transport; network/syntax errors are reported rather than silently replaced.

The user file lives under ~/Library/Application Support/Linvell/Plugins/<ID-digest>/configuration.json. Each override object is limited to 256 KiB; the shared workspace settings file retains its 1 MiB limit. Plugin commands cannot write configuration through the host API; configuration is explicitly edited and saved by the user.

9. Persistent state

With storage permission, command/event environments include this plugin’s globalState and workspaceState. Return stateUpdates patches instead of replacing snapshots. Use scope global or workspace, a nonempty key (at most 128 UTF-8 bytes), and a JSON value. Omit value to delete the key; explicit null stores null.

{"stateUpdates":[
  {"scope":"global","key":"runs","value":3},
  {"scope":"workspace","key":"lastAction","value":"prefix"},
  {"scope":"global","key":"obsolete"}
]}

State is stored locally in the plugin’s private namespace, including SSH workspace state. Workspace identity includes the canonical local project path, or SSH host alias plus root path. No-workspace contexts expose an empty workspace state and reject workspace updates. Different plugin IDs (including case differences) are isolated using a digest; no state is synced to iCloud or copied to the project.

At most 1,000 patches per response and 256 KiB per state file are accepted. File locking and read/merge/atomic-replace preserve unrelated keys from other windows; simultaneous updates to the same key are last-write-wins, not an atomic increment API. Corrupt/oversized files and symlinks are rejected without resetting state. Each file write is atomic, but global/workspace files and editor edits are separate operations. State may persist even if a later UI edit is rejected as stale. Stopping interrupts requests; a state write already committing may finish.

10. Native views, status and tool panels

Declare up to 16 views per package. kind is tree, list, status or panel. Each has id/title and optional text, icon, command, refreshCommand and items. Trees/lists open in the workspace sidebar; panels open in owned native tool windows. Status contributions appear in the workspace footer (first three, to preserve space); all status entries remain available from the plugin menu. Plain text only: no HTML, webview or third-party UI code on the editor’s main thread.

"views": [
  {"id":"tree","title":"Project Tree","kind":"tree","refreshCommand":"hello.refresh",
   "items":[{"id":"group","title":"Group","children":[
     {"id":"file","title":"Item","detail":"Description","icon":"spark","command":"hello.inspect"}
   ]}]},
  {"id":"actions","title":"Actions","kind":"list","items":[]},
  {"id":"counter","title":"Counter","kind":"status","text":"Ready","command":"hello.inspect"},
  {"id":"tools","title":"Tools","kind":"panel","text":"Plain text body.","items":[]}
]

Items have id/title and optional detail, icon, command and children. Children are allowed only in trees; list/panel rows are flat. Item IDs are unique within a view. Limits: 2,000 nodes per snapshot, depth 16, titles 200 characters, detail 1,000 characters, body text 64 KiB UTF-8 and status text 200 characters. Tree expansion and selected IDs survive data refreshes when the IDs still exist.

Double-click a row or press Run Selected to invoke its command (or the view’s command when the row has none). Status buttons invoke the view’s command; Refresh invokes refreshCommand. Requests include optional uiEvent with viewID, kind: "activate" | "refresh" and an optional itemID. The command uses the owning workspace’s current document snapshot. A command may update UI and return a guarded editor edit, but document event results still cannot edit text.

// Additional commands/execute parameter
"uiEvent": {"viewID":"tree","kind":"activate","itemID":"file"}

// Additional result field; id must name a declared view
"viewUpdates": [
  {"id":"actions","text":"Refreshed","items":[
    {"id":"new","title":"New row","command":"hello.inspect"}
  ]},
  {"id":"counter","text":"Items: 1"}
]

Each response can update up to 16 distinct declared views. Omitted text/items retain the previous value; empty strings/arrays clear them. Updates replace the item snapshot, not individual nodes. Unchanged snapshots are skipped; hidden views keep their latest data without redrawing. Updates come from command/event responses, not unsolicited plugin RPC calls. Stop/reload/failure removes runtime views, status controls and owned windows; replies from an old session are ignored.

11. Native settings pages

Add up to 32 settings fields. Kinds are string, boolean, number and choice; defaults live in the existing manifest configuration object. Open Plugins → Plugin Name → Plugin Settings…, or Settings → Plugins. The host renders text fields, checkboxes and pickers using AppKit.

"configuration": {"greeting":"Hello","enabled":true,"limit":10,"style":"calm"},
"settings": [
  {"key":"greeting","title":"Greeting","kind":"string","description":"A short greeting."},
  {"key":"enabled","title":"Enabled","kind":"boolean"},
  {"key":"limit","title":"Limit","kind":"number"},
  {"key":"style","title":"Style","kind":"choice","choices":["calm","bright"]}
]

User scope shows defaults plus user values; Workspace scope shows the full effective configuration. Save writes the displayed schema fields into the selected scope, preserving unrelated keys/comments. Workspace scope requires an open project and uses the existing conflict-checked local/SSH writer. External changes reject a stale save; Reload rereads disk and discards the draft. Strings are limited to 4,096 UTF-8 bytes, numbers must be finite, and choice values must match the declared options (up to 50). Invalid saved types are reported rather than silently coerced.

Save Plugin Settings (or Command-S in the form window) commits changes. Closing the form discards unsaved drafts. JSON configuration entry points remain available for advanced keys or repairing invalid values. No plugin executable is needed to render or save a declarative settings page.

12. Themes, icons and snippets

Themes

Declare up to 16 themes with id, title, isDark and exactly eight #RRGGBB colors: canvas, panel, foreground, secondary, accent, keyword, number, selection. Choose a theme from the plugin menu or command palette. The explicit choice applies to the app’s windows, editor and terminal; it is session-only and is not synced. Selecting a built-in theme, stopping its owning plugin or restarting restores the underlying built-in preference.

{
  "themes": [
    {
      "id": "forest",
      "title": "Plugin Forest",
      "isDark": true,
      "colors": {
        "canvas": "#10241E",
        "panel": "#18352B",
        "foreground": "#E2F4EB",
        "secondary": "#9BBEAC",
        "accent": "#88D9AD",
        "keyword": "#C4B3F4",
        "number": "#EEC380",
        "selection": "#315C49"
      }
    }
  ]
}

Icons

Declare up to 100 icons with id and exactly one of symbol (an SF Symbol available on the current macOS) or path (a package-local PNG). PNGs must be at most 1 MiB and 1,024 × 1,024. SVG, remote URLs and executable drawing callbacks are not accepted. Reference icon IDs from view/item declarations. Missing system symbols fall back to text-only presentation.

"icons": [
  {"id":"spark","symbol":"sparkles","fileExtensions":["txt","swift"]},
  {"id":"custom","path":"assets/custom.png"}
]

Optional lowercase fileExtensions declares file-icon overrides. Choose Use Plugin File Icons in the plugin menu to apply them globally to files/tabs; folders retain their native icons. The last explicit pack selection wins; stopping its owner restores bundled icons. UI icons do not require activating a file-icon pack.

Code snippets

Declare up to 100 snippets with id, title, body and optional prefix/scope. Scope is a list of file extensions; omitted/empty means any text file. Choose a snippet from the plugin menu or command palette with one active editor selection. Insertion replaces that selection, or the matching prefix immediately before the cursor. The insertion is one undo transaction.

{
  "snippets": [
    {
      "id": "greeting",
      "title": "Greeting Function",
      "prefix": "greetfn",
      "scope": [
        "swift"
      ],
      "body": "func ${1:greet}(_ name: String) {\n    print(\"${2:Hello}, \\(name)\")\n}\n$0"
    }
  ]
}

Supported tab stops: $1, ${1}, ${1:default} and final $0 (implicit at the end when omitted). Tab/Shift-Tab moves through numbered fields, and typing in a field shifts subsequent positions. Escape, editing outside the active field, switching the editor or stopping the owner ends navigation. Escape dollars/backslashes with a backslash. Limits: 64 KiB body, 32 distinct stops, indices 0–99. Repeated indices/mirrors, nested placeholders, choices, transforms, variables, automatic prefix completion and multi-cursor snippet sessions are not implemented.

13. Lifecycle and troubleshooting

Command-only packages start lazily on their first command. Subscribed document events can also start the process after explicit loading. The same process is reused. Loading the same plugin ID again stops and replaces its previous session. Packages are loaded only in the current window; they are not persisted across app restarts. Project folders are never scanned for executable plugins.

Use Edit → Stop Plugins to stop all plugin sessions in the window. Closing the window also stops them. Stop interrupts pending work; unresponsive processes are force-terminated after a grace period. Crash, invalid replies or timeouts fail the session. Rebuild, copy the executable again and load the folder again to restart. The host does not automatically restart a failing plugin.

If loading fails, check that plugin.json is valid, protocolVersion is 1, the binary is executable and stays inside the package, and permission names are supported. If a command fails, check framing and stdout, architecture and runtime dependencies, and the timeout. If an edit is rejected after typing or navigation, run the command again on the new document version.

14. Available and planned

Available in source: explicit local loading, process isolation, version handshake, command palette and native Plugins menu, shortcuts with conflict detection, document events, undoable versioned range/whole-document edits, layered configuration and persistent plugin/global-workspace state; native trees/lists/status/settings/tool windows, runtime UI snapshots, themes, file/UI icons and snippets.

Planned: language, task and debugger providers; remote plugin hosts. These APIs are not available yet. VSIX/Nova/Vim compatibility and a marketplace are outside the current scope.