Document Pi Web plugin API

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 11:10:31 +02:00
parent 3600525775
commit c82486a2e5
2 changed files with 679 additions and 0 deletions
+678
View File
@@ -0,0 +1,678 @@
# Pi Web plugin API
Pi Web plugins are trusted browser-side ES modules that extend the Pi Web UI. They are intended to be simple enough that an LLM can create or modify them directly.
Plugins can currently:
- add actions to the action palette;
- add workspace tools/panels next to Files, Git, and Terminal;
- add compact items to workspace labels in the workspace list, panel header, and status bar;
- call browser APIs and Pi Web HTTP/WebSocket APIs available to the current browser session;
- serve their own static assets from the plugin directory.
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
## Trust model
Plugins run as JavaScript in the browser app. Treat them as trusted code:
- they can call browser APIs;
- they can `fetch()` Pi Web API endpoints using the current browser access;
- they can read workspace files through Pi Web's file endpoints if the UI can read them;
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
- they should not be installed from untrusted sources.
This is for personal, team, and project-local customization, not a sandboxed third-party marketplace.
## Quick start: local plugin
Create a folder with a `package.json` and a browser module:
```bash
mkdir -p /srv/dev/my-pi-web-plugin
cat > /srv/dev/my-pi-web-plugin/package.json <<'JSON'
{
"private": true,
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
JSON
cat > /srv/dev/my-pi-web-plugin/pi-web-plugin.js <<'JS'
const { html } = globalThis.piWebPluginApi;
export default {
id: "my-plugin",
name: "My Plugin",
activate: () => ({
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "My Plugin",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected");
},
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: ({ workspace }) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p class="muted">${workspace.label}</p>
<p class="muted">${workspace.path}</p>
</section>
`,
},
],
workspaceLabelContributions: [
{
id: "workspace.kind",
order: 10,
items: ({ workspace }) => ({
type: "text",
text: workspace.isGitRepo ? "git" : "folder",
title: workspace.path,
}),
},
],
}),
};
JS
```
Symlink it into Pi Web's local plugin directory:
```bash
mkdir -p ~/.pi-web/plugins
ln -s /srv/dev/my-pi-web-plugin ~/.pi-web/plugins/my-plugin
```
Reload the Pi Web browser tab. Pi Web serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes.
## Discovery and packaging
Pi Web builds `/pi-web-plugins/manifest.json` from these sources:
1. Bundled plugins in the Pi Web package:
```text
pi-web-plugins/<plugin-id>/
```
2. User-local plugins:
```text
~/.pi-web/plugins/<plugin-id>/
```
Entries may be real directories or symlinks. This is the recommended development workflow.
3. Installed Pi packages that expose Pi Web plugin metadata. Pi packages may be user or project scoped.
Plugin directory names and plugin ids should match:
```text
^[a-z][a-z0-9.-]*$
```
### `package.json` metadata
A plugin directory is normally configured with top-level `piWeb` metadata:
```json
{
"private": true,
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
```
For multiple plugin entries in one package, use `piWeb.plugins`:
```json
{
"private": true,
"piWeb": {
"id": "my-package",
"plugins": [
{ "id": "review", "module": "dist/review.js" },
{ "id": "dashboard", "module": "dist/dashboard.js" }
]
}
}
```
`piWeb.plugins` may also be an array of module paths:
```json
{
"piWeb": {
"id": "my-package",
"plugins": ["dist/review.js", "dist/dashboard.js"]
}
}
```
Pi packages may nest the same metadata under `pi.piWeb`:
```json
{
"pi": {
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
}
```
If a local plugin directory has no `package.json`, Pi Web falls back to `pi-web-plugin.js` in that directory.
Entry module paths must be safe relative paths inside the plugin root. Pi Web ignores empty, absolute, or `..` paths.
### Manifest and assets
The manifest contains each discovered plugin module:
```json
{
"plugins": [
{
"id": "my-plugin",
"module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890",
"source": "local",
"scope": "local"
}
]
}
```
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`.
A plugin can fetch its own static assets with URLs under:
```text
/pi-web-plugins/<plugin-id>/<path-inside-plugin-root>
```
Pi Web prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream.
If two discovered plugins use the same id, the first keeps the id and later ones are renamed to `<id>.2`, `<id>.3`, and so on. Avoid relying on this; prefer unique ids.
## Plugin module shape
The entry module must default-export a `PiWebPlugin` object:
```ts
interface PiWebPlugin {
id: string;
name: string;
activate: (context: PluginActivationContext) => PluginContributions;
}
interface PluginActivationContext {
apiVersion: 1;
}
```
Example:
```js
export default {
id: "my-plugin",
name: "My Plugin",
activate: ({ apiVersion }) => ({
actions: [],
workspacePanels: [],
workspaceLabelContributions: [],
}),
};
```
`activate()` is called once when the UI loads the plugin. Keep it cheap: define contributions there, but move expensive or async work into actions, custom elements, or explicit user interactions.
Plugin ids and contribution ids must match:
```text
^[a-z][a-z0-9.-]*$
```
Contribution ids are local to the plugin. Pi Web qualifies them internally as:
```text
<plugin-id>:<local-contribution-id>
```
For example, plugin `info` with action `workspace.show-path` becomes `info:workspace.show-path`.
## Browser global API
External plugins can access this global before they export their plugin:
```js
const { apiVersion, html } = globalThis.piWebPluginApi;
```
- `apiVersion`: currently `1`.
- `html`: Lit's `html` template tag. Use this instead of importing `lit` from an external plugin unless you bundle your own dependencies.
Pi Web does not currently expose typed helper clients to plugins. Use `fetch()` for Pi Web HTTP APIs and browser `WebSocket` for websocket endpoints if needed.
## Contributions
`activate()` returns any combination of these contribution arrays:
```ts
interface PluginContributions {
actions?: PluginAction[];
workspacePanels?: WorkspacePanelContribution[];
workspaceLabelContributions?: WorkspaceLabelContribution[];
}
```
### Actions
Actions appear in the action palette. They can inspect app state and call UI/runtime helpers.
```js
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
description: "Display the selected workspace path",
shortcut: "mod+shift+p",
group: "Info",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected");
},
},
]
```
Action type:
```ts
interface PluginAction {
id: string;
title: string;
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
run: (context: PluginRuntimeContext) => void | Promise<void>;
}
```
Runtime context:
```ts
interface PluginRuntimeContext {
state: AppState;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
selectMainView: (view: "navigation" | "chat" | QualifiedContributionId) => void;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
}
```
Notes:
- `state` is a snapshot of current UI state when actions are built.
- `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
- `shortcut` is displayed/handled the same way app actions are; choose shortcuts carefully to avoid conflicts.
### Workspace panels
Workspace panels add tools next to built-in workspace tools. They render inside the workspace side panel on desktop and as mobile tabs on smaller screens.
```js
const { html } = globalThis.piWebPluginApi;
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
visible: (workspace) => workspace.isGitRepo,
badge: ({ gitStatus }) => gitStatus?.files.length,
render: ({ workspace, gitStatus, onRefreshGit }) => html`
<section class="toolbar">
<strong>Info</strong>
<button @click=${onRefreshGit}>Refresh git</button>
</section>
<section class="viewer">
<p class="muted">${workspace.label}</p>
<p class="muted">${workspace.path}</p>
<p class="muted">Changed files: ${gitStatus?.files.length ?? 0}</p>
</section>
`,
},
]
```
Panel type:
```ts
interface WorkspacePanelContribution {
id: string;
title: string;
order?: number;
visible?: (workspace: Workspace) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
}
```
Panel context:
```ts
interface WorkspacePanelContext {
workspace: Workspace;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;
selectedFileContent: FileContentResponse | undefined;
fileTreeStale: boolean;
gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined;
selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean;
activeTerminalCount: number;
onRefreshFiles: () => void;
onExpandDir: (path: string) => void;
onSelectFile: (path: string) => void;
onRefreshGit: () => void;
onSelectDiff: (path: string) => void;
}
```
Useful workspace shape:
```ts
interface Workspace {
id: string;
projectId: string;
path: string;
label: string;
branch?: string;
isMain: boolean;
isGitRepo: boolean;
isGitWorktree: boolean;
}
```
Use existing classes such as `toolbar`, `viewer`, `empty`, and `muted` for panel content when possible. Do not assume a panel owns the whole page; keep layout contained.
### Workspace label contributions
Workspace label contributions add compact inline metadata wherever Pi Web displays a workspace label: workspace list, workspace panel header, and status bar.
Use them for short facts like project environment, local URL, branch status, container name, or health state.
```js
workspaceLabelContributions: [
{
id: "dev-url",
order: 10,
visible: ({ workspace, state }) => {
const project = state.projects.find((project) => project.id === workspace.projectId);
return project?.path === "/srv/dev/my-app";
},
items: () => ({
type: "link",
text: "web:5173",
href: "http://localhost:5173",
title: "Open dev server",
target: "_blank",
}),
},
]
```
Label contribution type:
```ts
interface WorkspaceLabelContribution {
id: string;
order?: number;
visible?: (context: WorkspaceLabelContext) => boolean;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
}
interface WorkspaceLabelContext {
workspace: Workspace;
state: AppState;
}
```
Items are sorted by `order` and then id. Return `undefined` to render nothing.
#### Text items
```js
{ type: "text", text: "staging", title: "Staging workspace" }
```
#### Link items
```js
{
type: "link",
text: "web:5173",
href: "http://localhost:5173",
title: "Open dev server",
target: "_blank"
}
```
Pi Web renders the anchor and adds safe defaults such as `rel="noopener noreferrer"` for `_blank` links. `javascript:` and `data:` links are rendered as plain text instead of links.
#### Render items
Use render items when a label contribution needs custom UI, async data, or caching. Render items should stay compact and inline.
```js
const { html } = globalThis.piWebPluginApi;
class MyWorkspaceBadge extends HTMLElement {
set workspace(value) {
this._workspace = value;
this.textContent = value?.branch === "main" ? "main" : "branch";
}
}
if (!customElements.get("my-workspace-badge")) {
customElements.define("my-workspace-badge", MyWorkspaceBadge);
}
export default {
id: "my-plugin",
name: "My Plugin",
activate: () => ({
workspaceLabelContributions: [
{
id: "badge",
order: 10,
items: ({ workspace }) => ({
type: "render",
render: () => html`<my-workspace-badge .workspace=${workspace}></my-workspace-badge>`,
}),
},
],
}),
};
```
## Reading workspace files
Plugins can use existing Pi Web endpoints. For example, to read a file in a workspace:
```js
async function readWorkspaceFile(workspace, path) {
const url =
`/api/projects/${encodeURIComponent(workspace.projectId)}` +
`/workspaces/${encodeURIComponent(workspace.id)}` +
`/file?path=${encodeURIComponent(path)}`;
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Failed to read ${path}: ${response.status}`);
return await response.json();
}
```
Response shape:
```ts
interface FileContentResponse {
path: string;
language?: string;
encoding: "utf8";
size: number;
modifiedAt: string;
content: string;
truncated: boolean;
binary: boolean;
}
```
Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
## Other useful Pi Web APIs
Plugins may call any endpoint available to the browser. Common read endpoints:
```text
GET /api/projects
GET /api/projects/:projectId/workspaces
GET /api/projects/:projectId/workspaces/:workspaceId/tree?path=<dir>
GET /api/projects/:projectId/workspaces/:workspaceId/file?path=<file>
GET /api/projects/:projectId/workspaces/:workspaceId/git/status
GET /api/projects/:projectId/workspaces/:workspaceId/git/diff?path=<file>&staged=true|false
GET /api/sessions?cwd=<workspace-path>
GET /api/sessions/:sessionId/status
GET /api/sessions/:sessionId/messages?before=<cursor>&limit=<n>
```
Common write/action endpoints:
```text
POST /api/sessions { "cwd": "/path/to/workspace" }
POST /api/sessions/:id/prompt { "text": "...", "streamingBehavior": "steer" | "followUp" }
POST /api/sessions/:id/shell { "text": "..." }
POST /api/sessions/:id/stop
POST /api/sessions/:id/archive
POST /api/sessions/:id/restore
```
Prefer runtime context helpers (`startSession`, `stopActiveWork`, `refreshFiles`, `refreshGit`, etc.) when they cover the interaction. Use direct HTTP calls for plugin-specific data or behavior.
## Async data and caching
Pi Web does not provide a plugin cache/invalidation framework. Keep host callbacks cheap:
- simple contributions should be synchronous and cheap;
- expensive or async work should live inside the plugin;
- custom elements in `type: "render"` label items or panels are a good place to own async loading;
- dedupe fetches and avoid unbounded polling;
- clean up intervals/event listeners in custom elements' `disconnectedCallback()`.
Example cache pattern:
```js
const cache = new Map();
const loading = new Set();
class DevUrlBadge extends HTMLElement {
set workspace(value) {
this.workspaceValue = value;
void this.load();
}
async load() {
const workspace = this.workspaceValue;
if (!workspace) return;
if (cache.has(workspace.id)) {
this.renderUrl(cache.get(workspace.id));
return;
}
if (loading.has(workspace.id)) return;
loading.add(workspace.id);
try {
const file = await readWorkspaceFile(workspace, "docker/development.local.env");
const url = parseEnv(file.content).BASE_URL;
cache.set(workspace.id, url);
this.renderUrl(url);
} finally {
loading.delete(workspace.id);
}
}
renderUrl(url) {
this.textContent = url ?? "";
}
}
```
## LLM checklist for building a plugin
When asking an LLM to build a Pi Web plugin, give it this checklist:
1. Create a plugin folder with `package.json` and `pi-web-plugin.js`.
2. Use top-level `piWeb` metadata with `id` and `plugin`, or `piWeb.plugins` for multiple modules.
3. Default-export `{ id, name, activate }` from the module.
4. Use ids matching `^[a-z][a-z0-9.-]*$`.
5. Use `globalThis.piWebPluginApi.html` for Lit templates.
6. Keep `activate()` synchronous and cheap; return contribution definitions only.
7. Add actions for command-palette operations.
8. Add workspace panels for larger workspace UI.
9. Add workspace label contributions for compact inline metadata.
10. Use structured text/link label items when possible; use render items/custom elements for async or cached UI.
11. Use `fetch()` against Pi Web APIs for workspace files, git state, sessions, or plugin-specific behavior not provided by runtime context helpers.
12. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
13. After local edits, hard reload the browser and check the console for plugin errors.
## Troubleshooting
Check discovery:
```bash
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
```
Check a plugin module:
```bash
curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js
```
Common issues:
- invalid plugin id or contribution id;
- missing default export;
- missing `name` or `activate` function;
- missing `package.json` or incorrect `piWeb.plugin` / `piWeb.plugins` metadata;
- entry module path points outside the plugin root or file does not exist;
- browser cache not refreshed after editing;
- plugin directory is not under `~/.pi-web/plugins` or symlinked there;
- duplicate plugin ids cause later plugins to be renamed in the manifest;
- plugin throws during module import, `activate()`, `visible()`, `enabled()`, `items()`, or `render()`; check the browser console.