# 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`, ``, `