# PI-Desktop Plugin Development: Zero to One This guide is the shortest complete path from an empty folder to a tested `.piplug` package. It describes the plugin runtime that PI-Desktop ships today. The files under [`docs/spec/07-plugins`](spec/07-plugins/README.md) remain the normative contract when this guide and a specification differ. ## 1. What a plugin can add A plugin can contribute one or more of these capabilities: | Capability | Use it for | Main building blocks | |---|---|---| | Command | An explicit action in global search | `contributes.commands`, `pi.commands.register` | | Panel | A small isolated HTML interface | `ui.panel`, `ui.panel` permission, `window.pluginBridge` | | Work panel view | An interface docked in the app's right work panel | `contributes.views`, `ui.view` permission, `window.pluginBridge` | | Agent tool | A function the Agent can call | `contributes.agentTools`, `pi.agent.registerTool` | | Reviewer completion | A host-owned one-shot against the user's models | `pi.models.list`, `pi.session.getLlmContext`, `pi.agent.complete` | | Skill | Instructions loaded by the Agent on demand | `contributes.skills`, `agent.prompt.inject` permission | | Theme | Design-token overrides | `contributes.themes`, `ui.theme` permission | | MCP server | Tools discovered from a local or remote MCP server | `contributes.mcpServers`, an MCP permission | | Service | Resident work supervised by the host | `contributes.services`, `background.service` permission | | Message bus | Typed-by-convention events between plugins | `contributes.bus`, bus permissions | Plugin entry code runs in a dedicated Node process. Panels run in sandboxed, context-isolated Electron windows with no Node integration. Calls from either surface cross a host-owned permission gateway. > **Trust boundary:** the permission model gates the `pi.*` host API and panel > bridge. It is not yet an operating-system sandbox for raw Node APIs used by a > plugin entry process. Load development plugins and third-party packages only > when you trust their source, and use the host API instead of direct Node file > or network access. See the [security specification](spec/07-plugins/04-plugin-security.md). ## 2. Prerequisites For the recommended app-first path, you need: - a running PI-Desktop build; - an empty folder for the plugin; and - a text editor. For the repository CLI path, you also need Node.js 22.19 or newer, pnpm 10 or newer, and a checkout of this repository. The devkit and SDK are currently private workspace packages, so do not assume that `npm install @pi-desktop/plugin-devkit` works outside this repository. ## 3. Create the first plugin ### Option A: create it in PI-Desktop 1. Open **Plugins** (the Extensions page). 2. Open the header overflow menu and choose **New plugin from template**. 3. Choose `panel-basic`. 4. Select an empty folder. PI-Desktop writes the starter files, loads the folder as a development plugin, and opens the folder as the active project. The plugin is live immediately. The four built-in templates are: | Template | Starts with | Permissions | |---|---|---| | `panel-basic` | Command and HTML panel | `ui.panel` | | `agent-tool-basic` | Agent-callable echo tool | `agent.tool.register` | | `skill-pack` | One skill document | `agent.prompt.inject` | | `full-demo` | Command, panel, tool, skill, and setting | The permissions used by those features | Scaffolding refuses a non-empty destination so it cannot silently overwrite an existing project. ### Option B: create it with the repository CLI From the PI-Desktop repository root: ```bash pnpm install pnpm --filter @pi-desktop/plugin-devkit... build pnpm pi-plugin init panel-basic ../my-first-plugin \ --id local.my-first-plugin \ --name "My First Plugin" ``` Then open PI-Desktop, go to **Plugins**, choose **Load development plugin**, and select `../my-first-plugin`. Use a reverse-domain id for a published plugin, for example `com.example.workspace-summary`. The `local.` prefix is a useful convention for private plugins. Keep the id stable: settings, data, grants, updates, and the package name are keyed by it. ## 4. Understand the generated files The `panel-basic` template produces: ```text my-first-plugin/ ├── manifest.json ├── main.js ├── README.md └── renderer/ └── index.html ``` - `manifest.json` declares identity, entry points, contributions, and requested permissions. - `main.js` runs in the plugin process and exports lifecycle hooks. - `renderer/index.html` runs in the isolated panel window. - `README.md` explains how to develop and package this particular plugin. Distribution packages must contain directly executable JavaScript, HTML, CSS, and assets. PI-Desktop does not install dependencies or compile TypeScript when it loads a plugin. If you use TypeScript or third-party packages, bundle or compile them into the plugin directory before checking and packing it. ## 5. Build the minimal plugin by hand The following three files show the complete command-to-panel path. ### `manifest.json` ```json { "schemaVersion": 1, "id": "local.my-first-plugin", "name": "My First Plugin", "version": "0.1.0", "description": "Opens a panel and shows a greeting.", "main": "main.js", "ui": { "panel": "renderer/index.html", "title": "My First Plugin", "width": 480, "height": 360 }, "contributes": { "commands": [ { "id": "my-first-plugin.open", "title": "My First Plugin: Open Panel", "keywords": ["hello", "panel"] } ] }, "permissions": ["ui.panel"], "engines": { "piDesktop": ">=0.1.0" }, "activationEvents": [ "onCommand:my-first-plugin.open", "onStartup" ] } ``` `schemaVersion`, `id`, `name`, `version`, and `main` are required. Every file path is relative to the plugin root and must stay inside it. Declare only the permissions the plugin actually needs. ### `main.js` ```js async function onLoad() { await pi.commands.register({ id: "my-first-plugin.open", title: "My First Plugin: Open Panel", keywords: ["hello", "panel"], run: async () => { await pi.ui.openPanel({ title: "My First Plugin" }); await pi.ui.showToast("Hello from My First Plugin"); }, }); } async function onUnload() { await pi.commands.unregister("my-first-plugin.open"); } module.exports = { onLoad, onUnload }; ``` The host injects `pi` as a global. `onLoad` and `onUnload` receive no arguments. CommonJS is the simplest entry format; ESM is also loaded when the entry is an ES module. Module evaluation plus `onLoad` has a 15-second budget. `onUnload` has a 5-second budget and is best-effort, so release timers and subscriptions promptly. Only `onLoad` and `onUnload` are fired today. Other lifecycle names in the manifest are reserved for the planned full lifecycle. ### `renderer/index.html` PI-Desktop hosts the panel in a frameless window on every platform. The host reserves exactly a transparent 46 CSS px drag band and renders a minimal fixed capsule in the top-right corner with minimize, maximize/restore, and close buttons. The panel title, toolbar, and all other visible UI belong to the plugin. Normal-flow content is automatically offset below the drag band, so do not add another 46px top padding to compensate. The drag band is not clickable outside the capsule; development panels show a reminder for this constraint. If a panel uses `position: fixed` or `position: sticky` for a top toolbar, anchor it below the host drag band instead of using `top: 0`: ```css .panel-toolbar { position: sticky; top: var(--pi-plugin-titlebar-height, 46px); -webkit-app-region: drag; } .panel-toolbar button { -webkit-app-region: no-drag; } ``` The host does not inject a panel title. Keep the 46px drag band in mind for viewport-height calculations as well: `height: calc(100dvh - var(--pi-plugin-titlebar-height, 46px))`. ```html My First Plugin

My First Plugin

``` The panel does not receive the global `pi` object. It receives only `window.pluginBridge`, and arbitrary Electron IPC channels are unavailable. ## 6. Add capabilities ### 6.1 Agent tool Declare the tool and its permission: ```json { "contributes": { "agentTools": [ { "name": "summarize_text", "description": "Summarize text supplied by the agent.", "risk": "low", "schema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } } ] }, "permissions": ["agent.tool.register"] } ``` Register the matching handler during `onLoad`: ```js await pi.agent.registerTool({ name: "summarize_text", description: "Summarize text supplied by the agent.", risk: "low", schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"], }, execute: async (args, context) => { context.log("summarize_text called"); const text = String(args?.text ?? ""); return { summary: text.slice(0, 120) }; }, }); ``` Unregister it in `onUnload`. The host exposes it to the model under a plugin-namespaced name, applies the normal Agent permission policy, audits execution, and enforces a 110-second plugin-side timeout. Plugin tools are not available in Plan mode. ### 6.2 Skill Add a file such as `skills/release-notes.md`: ```markdown --- name: Release notes description: Use when the user asks for release notes or a changelog entry. --- # Release notes Write one line per user-visible change. Use imperative mood and put the newest change first. ``` Declare it with the required permission: ```json { "contributes": { "skills": ["skills/release-notes.md"] }, "permissions": ["agent.prompt.inject"] } ``` The prompt receives a short skill catalog; the full body is read on demand. Each plugin may contribute up to 32 skills, each file may be at most 128 KiB, and descriptions are capped at 240 characters. A skill without `agent.prompt.inject` is ignored rather than loaded. ### 6.3 Settings and private data Declare defaults in the manifest: ```json { "contributes": { "settings": [ { "key": "greeting", "title": "Greeting", "type": "string", "default": "Hello" } ] } } ``` Read and update them from the plugin process: ```js const settings = await pi.plugin.getSettings(); await pi.plugin.setSettings({ greeting: "Welcome" }); const dataPath = await pi.plugin.getDataPath(); ``` Settings and the data path are private to the plugin id. The installed Plugins page generates controls for `string`, `number`, `boolean`, `select`, `json`, and `shortcut` fields. A shortcut field must name a declared command: ```json { "key": "openShortcut", "title": "Open panel shortcut", "type": "shortcut", "default": "Mod+Shift+H", "command": "hello.open", "scope": "plugin" } ``` Plugin shortcuts run only in the focused PI-Desktop window and are checked against the app shortcut map. Global registration is not supported yet. The plugin receives `plugin:settingsChanged` after a user edit. Do not put credentials in `manifest.json` or source control. ### 6.4 Workspace files, clipboard, network, and notifications These APIs require explicit permissions: | Permission | Plugin-process API | Panel bridge channel | |---|---|---| | `fs.read` | `pi.fs.readText`, `pi.fs.glob`, `pi.fs.list`, `pi.fs.requestDirectory` | `fs.readText`, `fs.glob`, `fs.list` | | `fs.write` | `pi.fs.writeText` | `fs.writeText` | | `fs.delete` | `pi.fs.remove` | Not exposed | | `clipboard.read` | `pi.clipboard.readText`, `pi.clipboard.getHistory` | `clipboard.readText`, `clipboard.getHistory` | | `clipboard.write` | `pi.clipboard.writeText` | `clipboard.writeText` | | `net.fetch` | `pi.net.fetch` | `net.fetch` | | `shell.openExternal` | `pi.shell.openExternal` | `shell.openExternal` | | `notify` | `pi.ui.notify`, `pi.ui.getNotificationPermission`, `pi.ui.requestNotificationPermission`, `pi.ui.showNativeNotification` | `ui.notify`, `ui.getNotificationPermission`, `ui.requestNotificationPermission`, `ui.showNativeNotification` | Use `fs.list` rather than `fs.glob` when you are showing a tree: it returns one directory at a time (name-sorted, directories included), so the user expands what they want instead of waiting on a whole-repo walk that is capped at 500 matches. Both obey the same read scope. A file permission is only half the declaration: `manifest.fs` says which paths each mode may touch (see §6.5). Paths are relative to the mode's root. Absolute paths and `..` escapes are rejected, as is a symlink that leaves the root. `fs.remove` is non-recursive, moves the path to the OS trash, and cannot remove the root itself. `net.fetch` accepts HTTP(S) and only reaches hosts listed in `manifest.net.domains`; `openExternal` accepts HTTP(S) and `mailto:` URLs. `pi.ui.notify` shows an in-app Toast. Native notifications are opt-in: call `pi.ui.requestNotificationPermission()` before `pi.ui.showNativeNotification(...)`. The returned permission is best-effort because Electron does not expose a cross-platform read-only OS permission API; `unknown` means the platform has not reported a result yet, and `unsupported` means desktop notifications are unavailable. Native plugin notifications are not added to PI-Desktop's durable task notification inbox. The panel bridge also exposes `ui.showToast`, `ui.closePanel`, `plugin.getSettings`, and `workspace.get`. A channel the host does not implement itself is forwarded to your `onPanelInvoke(channel, payload)`, so a panel can talk to its own plugin over channels you define; a plugin that exports no `onPanelInvoke` gets `UNSUPPORTED` back. ### 6.5 File scope `fs.read` / `fs.write` / `fs.delete` say whether your plugin may touch files. `manifest.fs` says which ones: ```json { "permissions": ["fs.read", "fs.write", "fs.delete"], "fs": { "read": { "scope": ["**/*"] }, "write": { "scope": ["docs/**", "*.md"] }, "delete": { "own": true, "scope": ["dist/**"] } } } ``` - `scope` globs are relative to the root. `*` matches one segment, `**` crosses separators. - **Reading** may declare the whole tree. **Writing and deleting may not** — a whole-tree pattern fails validation, because the egress allowlist is what makes a broad read safe and nothing makes a broad write safe. - An access outside the declared scope is not an error: PI-Desktop asks the user (Deny / Allow once / Allow this session). Declare the scope you need so your plugin does not interrupt them on every call, and expect a refusal to arrive as `PERMISSION_DENIED`. - Some paths are refused whatever you declare: `.env*`, SSH and cloud credentials, `*.pem`, `.git/**`, and PI-Desktop's own data directory. They do not appear in `fs.glob` results either. **Deleting.** `own: true` lets you remove files your plugin wrote itself, with no scope and no prompt — the right default for cleaning up your own output. (If the user has edited the file since you wrote it, it stops counting as yours.) Deleting anything else needs a `scope`. Every delete is non-recursive, goes to the OS trash, and is interrupted once past 50 removals a minute, so batch cleanups should be paced rather than run as a `glob` plus a loop. **Working outside the workspace.** Set `"root": "userSelected"` on a mode and call `pi.fs.requestDirectory()`: the user picks a directory and you get full reach inside it with no scope to declare. The handle lives in memory and is gone when the plugin process exits, so ask again each session. **Legacy names.** `fs.read.workspace`, `fs.write.workspace` and `fs.delete.workspace` still load, but are cut back — write reaches nothing and delete reaches only your own output — until the manifest declares `fs`. The Plugins page tells the user this happened. ### 6.6 Network access `net.fetch` lets your plugin make a request; `manifest.net.domains` says where to: ```json { "permissions": ["net.fetch"], "net": { "domains": ["api.example.com", "*.githubusercontent.com"] } } ``` Entries are bare hostnames — no scheme, no port, no path — and a leading `*.` covers the domain and its subdomains. A bare `*` is refused at install. The list is the single allowlist for **every** outbound path the host owns, not only `pi.net.fetch`: your panel's own `fetch`, ``, `