Skip to content

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

CapabilityUse it forMain building blocks
CommandAn explicit action in global searchcontributes.commands, pi.commands.register
PanelA small isolated HTML interfaceui.panel, ui.panel permission, window.pluginBridge
Agent toolA function the Agent can callcontributes.agentTools, pi.agent.registerTool
SkillInstructions loaded by the Agent on demandcontributes.skills, agent.prompt.inject permission
ThemeDesign-token overridescontributes.themes, ui.theme permission
MCP serverTools discovered from a local or remote MCP servercontributes.mcpServers, an MCP permission
ServiceResident work supervised by the hostcontributes.services, background.service permission
Message busTyped-by-convention events between pluginscontributes.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.

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:

TemplateStarts withPermissions
panel-basicCommand and HTML panelui.panel
agent-tool-basicAgent-callable echo toolagent.tool.register
skill-packOne skill documentagent.prompt.inject
full-demoCommand, panel, tool, skill, and settingThe 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

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My First Plugin</title>
  </head>
  <body>
    <h1>My First Plugin</h1>
    <button id="hello">Show toast</button>
    <script>
      document.getElementById("hello").addEventListener("click", async () => {
        await window.pluginBridge.invoke("ui.showToast", {
          message: "Hello from the panel",
        });
      });
    </script>
  </body>
</html>

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. A dedicated generated settings UI is not implemented yet; a plugin must provide its own supported interaction for changes. Do not put credentials in manifest.json or source control.

6.4 Workspace files, clipboard, network, and notifications

These APIs require explicit permissions:

PermissionPlugin-process APIPanel bridge channel
fs.read.workspacepi.fs.readText, pi.fs.globfs.readText, fs.glob
fs.write.workspacepi.fs.writeTextfs.writeText
fs.delete.workspacepi.fs.removeNot exposed
clipboard.readpi.clipboard.readTextclipboard.readText
clipboard.writepi.clipboard.writeTextclipboard.writeText
net.fetchpi.net.fetchnet.fetch
shell.openExternalpi.shell.openExternalshell.openExternal
notifypi.ui.notify, pi.ui.getNotificationPermission, pi.ui.requestNotificationPermission, pi.ui.showNativeNotificationui.notify, ui.getNotificationPermission, ui.requestNotificationPermission, ui.showNativeNotification

Workspace paths are relative to the active workspace. Absolute paths and .. escapes are rejected. fs.remove is non-recursive and cannot remove the workspace root. openExternal accepts only HTTP(S) and mailto: URLs; net.fetch accepts HTTP(S).

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. It does not expose arbitrary custom channels. onPanelInvoke is currently reserved for the host-supported skill.* panel operations, not a general panel-to-plugin RPC mechanism.

6.5 Theme

Declare a CSS file and ui.theme:

json
{
  "contributes": {
    "themes": [
      {
        "id": "midnight",
        "label": "Midnight",
        "path": "themes/midnight.css",
        "base": "dark"
      }
    ]
  },
  "permissions": ["ui.theme"]
}

Override PI-Desktop design tokens in that CSS. The host sanitizes contributed CSS, refuses imports and non-data URLs, caps each file at 256 KiB, and allows up to eight themes per plugin. The user selects the theme in Settings.

6.6 MCP server

MCP servers are declarative. A local server requires mcp.server.local; a remote server requires mcp.server.remote:

json
{
  "contributes": {
    "mcpServers": [
      {
        "id": "docs",
        "label": "Documentation tools",
        "transport": "stdio",
        "command": "bin/docs-server",
        "args": ["--stdio"],
        "env": {
          "DOCS_TOKEN": { "setting": "docsToken" }
        }
      },
      {
        "id": "issues",
        "transport": "http",
        "url": "https://mcp.example.com/tools",
        "headers": {
          "Authorization": { "setting": "issuesAuthorization" }
        }
      }
    ]
  },
  "permissions": ["mcp.server.local", "mcp.server.remote"]
}

A stdio command must be a bare command found on PATH or a plugin-relative executable; absolute paths are rejected. Remote URLs must use HTTPS, except for loopback HTTP. Setting references read only this plugin's settings—the host environment and provider secrets are never forwarded. MCP tools follow the same Agent-only policy and namespacing as hand-written plugin tools.

6.7 Resident service and message bus

Declare service ids and allowed topics:

json
{
  "contributes": {
    "services": [
      { "id": "watcher", "label": "Workspace watcher" }
    ],
    "bus": {
      "publish": ["example.index.ready"],
      "subscribe": ["example.build.*"]
    }
  },
  "permissions": [
    "background.service",
    "bus.publish",
    "bus.subscribe"
  ]
}

Register matching handlers:

js
let unsubscribe;

pi.services.register({
  id: "watcher",
  start: ({ log }) => log("watcher started"),
  stop: () => {},
});

unsubscribe = await pi.bus.subscribe("example.build.*", async (message) => {
  await pi.bus.publish("example.index.ready", {
    source: message.from,
    at: message.at,
  });
});

Call unsubscribe() during unload. A plugin does not receive its own bus messages. Treat topics as public to any installed plugin with a matching subscription; never put secrets in the payload.

7. Permission design

Permissions are both declared in manifest.json and granted by the user. Undeclared or ungranted API calls fail with PERMISSION_DENIED.

RiskPermissions
Lowui.panel, ui.theme, notify
Mediumclipboard.read, clipboard.write, fs.read.workspace, shell.openExternal, background.service, bus.publish, bus.subscribe
Highfs.write.workspace, fs.delete.workspace, agent.tool.register, agent.prompt.inject, net.fetch, mcp.server.local, mcp.server.remote

Ask for the smallest set possible. Adding a permission to a loaded development plugin does not take effect through hot reload: PI-Desktop stops the reload and asks the user to load the folder again so the new grant can be reviewed. Removing permissions takes effect on reload.

The complete mapping and policy are in the permission matrix.

8. Develop and debug

Hot reload

Development plugins are watched after the first folder load and across app restarts. Changes reload after a 300 ms debounce. .git, node_modules, dist, target, and common editor scratch files are ignored.

A reload performs unload → validate → load. Panel memory is not preserved. A syntax or manifest error unloads the broken version but keeps the watcher active; save a fix to recover. At most 16 development plugins are watched at once.

Verify each contribution

  • Run a command from global search (Cmd/Ctrl+K or Cmd/Ctrl+Shift+P).
  • Open a panel from the command or the plugin row.
  • Ask the Agent to call the contributed tool while in Agent mode.
  • Ask for a task matching the skill description, then inspect whether the skill is selected.
  • Select a contributed theme in Settings.
  • Inspect the plugin row for service state and restart count.

Logs and failures

Load, crash, permission, tool, network, service, and bus activity is recorded in the application logs. Open Settings → Info → Logs, then search for the plugin id. User-facing load and hot-reload failures also appear as a toast and on the plugin row when a persisted load error is available.

Host API failures throw an Error with a code, commonly PERMISSION_DENIED, NOT_FOUND, INVALID_ARGUMENT, TIMEOUT, UNSUPPORTED, LIMIT_EXCEEDED, or RATE_LIMITED. Catch errors around optional operations and include the code in diagnostics without logging secrets.

9. Check, pack, and install

Run validation from the repository root:

bash
pnpm pi-plugin check ../my-first-plugin

check reports blocking errors and non-blocking warnings. It validates the manifest, referenced files, permissions, path containment, symlinks, package size, and file count using the same rules as installation. Review warnings too, especially unused and high-risk permissions.

Pack only with the devkit:

bash
pnpm pi-plugin pack ../my-first-plugin

The result is:

text
../my-first-plugin/dist/local.my-first-plugin-0.1.0.piplug

The command prints the package SHA-256. A .piplug is a store-only (uncompressed) ZIP; normal zip defaults usually produce an archive the installer rejects. The devkit excludes .git, node_modules, and dist, rejects symlinks, and enforces a maximum of 2,000 files and 50 MiB.

To test the exact artifact users receive:

  1. Open Plugins.
  2. Choose Install plugin package from the header overflow menu.
  3. Select the generated .piplug.
  4. Review the permissions and install it.
  5. Repeat the contribution checks from the previous section.
  6. Disable and re-enable it to verify cleanup and startup behavior.
  7. Uninstall it and confirm its contributions disappear.

The Agent can also run PluginCheck in every operating mode. PluginScaffold and PluginPack are Agent-mode tools and are restricted to the current workspace.

10. Prepare a release

Before sharing a package:

  1. Use a stable reverse-domain plugin id.
  2. Update version with semantic versioning.
  3. Set engines.piDesktop to the versions you actually support.
  4. Document every command, setting, tool input, permission, and external service in the plugin README.
  5. Add a changelog and a license.
  6. Build all generated JavaScript and assets into the plugin folder.
  7. Run pi-plugin check and resolve every error and unexpected warning.
  8. Run pi-plugin pack and install the resulting package in a clean app state.
  9. Record the printed SHA-256 next to the release artifact.

For the official marketplace, submit the package and catalog metadata to vastsa/pi-desktop-plugins and follow that repository's CONTRIBUTING.md. The marketplace catalog is a separate repository; adding a plugin here does not publish it.

Signatures are not the current trust primitive. Package SHA-256 and explicit permission review are the implemented baseline; follow the signing and updates specification for roadmap details.

11. Troubleshooting

SymptomLikely causeFix
manifest.json is missingWrong directory selectedSelect the directory whose root contains manifest.json
main entry missingmain points to source that was not builtCompile/bundle first or correct the relative path
Panel does not openMissing file, ui.panel, or permissionDeclare the panel path and ui.panel; reload for a new grant
pluginBridge is unavailableHTML opened in a normal browserTest bridge calls inside the PI-Desktop panel
Tool never appearsMissing contribution, registration, or grantAlign agentTools, registerTool, and agent.tool.register; use Agent mode
Skill never appliesMissing permission or weak metadataAdd agent.prompt.inject and specific name/description front matter
Save reports PERMISSION_DENIEDManifest widened permissionsLoad the development folder again and review the new grant
Hot reload stops after a syntax errorBroken plugin is unloadedSave the corrected file; the watcher remains active
Package install rejects compressionArchive was made with a generic ZIP toolRebuild it with pi-plugin pack
MCP server does not startInvalid transport fields, command, URL, setting, or permissionRun pi-plugin check, then inspect logs by plugin id
Service repeatedly restartsstart throws or the plugin process exitsMake start idempotent, clean up in stop, and inspect the restart log

12. Reference map

Built for local-first development.