# PI WEBUI plugin API

PI WEBUI plugins are trusted browser-side ES modules that extend the PI WEBUI UI. They are intended for personal, team, and project-local customization, and simple enough for an LLM to create or modify directly.

Plugins can currently:

- add action-palette commands;
- add workspace tools/panels next to Files, Git, and Terminal;
- add Activity Rail activities opened from dedicated Rail controls;
- add compact workspace-label items in the workspace list, panel header, and status bar;
- call browser APIs and documented PI WEBUI plugin context helpers;
- read workspace files and start workspace terminal commands through documented helpers;
- 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.

## Pi packages vs PI WEBUI plugins

**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEBUI browser plugins. Many Pi packages do not include a PI WEBUI plugin.

**PI WEBUI plugins** are browser-side PI WEBUI UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEBUI plugin is a PI WEBUI config task; installing, removing, or updating a Pi package is a Pi package-manager task.

Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEBUI uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.

With a workspace selected, the navigation underbar's **Plugins** button opens the workspace-aware package view. It shows global and project packages, their resolved extensions, skills, prompts, and themes, plus package diagnostics. From there you can install into either scope, update or remove a package, or disable and re-enable all of a package's resolved resources. Reload the current session from the dialog after a package change to refresh its Pi runtime resources.

When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEBUI machine. If an older or unavailable remote PI WEBUI server does not expose package-management routes, PI WEBUI reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway.

Use **Settings → PI WEBUI plugins** to enable or disable discovered PI WEBUI browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEBUI server does not advertise selected-machine settings support, PI WEBUI reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEBUI session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEBUI browser plugins. A routine session daemon restart is not required.

## Theme tokens

Theme contributions may omit `--pi-hierarchy-border` for compatibility with legacy custom themes. When present, this optional token controls the hierarchy frames and guide rails around nested session and project rows. If it is missing or empty, PI WEBUI falls back to that theme's non-empty `--pi-border`; an explicit non-empty `--pi-hierarchy-border` value takes precedence.

## Trust model

Plugins run as JavaScript in the browser app. Treat them as trusted code:

- they can call browser APIs;
- they can read workspace files and start terminal commands through documented plugin helpers;
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
- they should not be installed from untrusted sources.

PI WEBUI's `/api/...` HTTP and WebSocket endpoints are internal implementation details. Plugin code should use the documented context helpers instead. Daring plugins can still reach private routes or runtime objects because they run in the browser, but those private surfaces are experimental: they may graduate into stable helpers, change shape, or disappear.

## What to ask AI to build

Humans should not need to hand-code plugins. Give an AI agent a concrete UI goal and ask it to create or modify a local plugin.

Good plugin requests:

- "Show a workspace badge with the dev server URL from `.env`."
- "Add a workspace panel with links to logs, dashboards, and local services for this repo."
- "Add an action-palette command that starts a standard code-review prompt."
- "Show whether the current workspace is a git worktree, main checkout, staging env, or feature branch."
- "Add a compact status badge based on a project health file or command output saved in the repo."

Copy-paste prompt for creating a plugin:

```text
Build a PI WEBUI plugin for this project.
Goal: <describe the UI behavior>.
Before coding, read the PI WEBUI plugin docs:
https://pi-webui.dev/plugins
Full API reference:
https://pi-webui.dev/plugins.md
Create it as a local plugin under ~/.pi-webui/plugins/<plugin-id>.
Use the appropriate extension points from the docs.
Validate by checking /pi-webui-plugins/manifest.json and explain how to reload/debug it.
Do not modify PI WEBUI itself.
```

Copy-paste prompt for modifying a plugin:

```text
Improve the PI WEBUI plugin at <path>.
Before coding, read the PI WEBUI plugin docs:
https://pi-webui.dev/plugins
Full API reference:
https://pi-webui.dev/plugins.md
Keep the plugin compatible with the documented v1 API.
After editing, check the manifest endpoint and browser-console failure cases.
```

## Canonical example: bundled Info plugin

PI WEBUI ships a real bundled `info` plugin. Use it as the reference example because it is intentionally small while still exercising three UI contribution types: an action, a workspace label, and a workspace panel. Activity Rail activities are an additional v1 UI contribution type documented below.

Bundled PI WEBUI plugins are developed as TypeScript in the repository, but their `package.json` metadata still points at built JavaScript because plugins are loaded by the browser as JS ES modules. `npm run dev:web` watches and rebuilds bundled plugin TS into `dist/pi-webui-plugins/` during development, and `npm run build` emits the JS before packaging a release.

Source files:

```text
pi-webui-plugins/info/package.json
pi-webui-plugins/info/pi-webui-plugin.ts
```

Built module:

```text
dist/pi-webui-plugins/info/pi-webui-plugin.js
```

Package metadata:

```json
{
  "name": "@pi-webui/info-plugin",
  "private": true,
  "piWebUi": {
    "plugins": [
      { "id": "info", "module": "pi-webui-plugin.js" }
    ]
  }
}
```

Module shape excerpt:

```js
export default {
  apiVersion: 1,
  name: "Info Plugin",
  activate: ({ html, svg }) => ({
    contributions: {
      actions: [/* action definitions */],
      workspaceLabels: [/* compact label definitions */],
      workspacePanels: [/* panel definitions using html, optional icons using svg */],
    },
  }),
};
```

When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin.

PI WEBUI also ships an `updates` plugin that demonstrates dynamic `visible` and `badge` callbacks for tabs that only appear when the host has status messages or needs extra install visibility.

## Local plugin usage

This works with the production native-service install. PI WEBUI discovers plugins from `~/.pi-webui/plugins/<plugin-package>/` on the web/API side; no PI WEBUI rebuild or session-daemon restart is required. If `PI_WEBUI_DATA_DIR` is set, use `$PI_WEBUI_DATA_DIR/plugins` instead.

Symlink a plugin folder into PI WEBUI's local plugin directory:

```bash
mkdir -p ~/.pi-webui/plugins
ln -s /path/to/plugin-folder ~/.pi-webui/plugins/plugin-id
```

Reload the PI WEBUI browser tab. PI WEBUI serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes.

## Remote machine plugins

When [machine federation](https://pi-webui.dev/machines) is enabled, PI WEBUI also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:

- actions, workspace panels, Activity Rail activities, and workspace labels only appear while that machine is selected;
- plugin file and terminal helpers run against that machine;
- plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime;
- if the gateway and remote machine both have an enabled plugin with the same original id, `machineSpecific` metadata decides whether the gateway copy is reused or only the selected machine's copy can appear;
- remote theme contributions are ignored for now because themes are app-wide;
- mixed PI WEBUI versions across federated machines are best-effort and not guaranteed compatible.

Remote plugin enablement is controlled by the remote machine's PI WEBUI plugin config. To edit or disable a remote machine plugin, select that machine and use **Settings → PI WEBUI plugins** when the remote server exposes selected-machine settings, or open that machine directly/update its config file.

Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEBUI machine:

- Omitted or `false`: use the gateway copy when the same plugin id is also present on a remote machine. This is best for portable UI plugins whose helpers already route through the selected machine.
- `true`: the gateway copy only appears for the local machine. When a remote machine is selected, only that remote machine's copy can appear; if the remote machine does not expose the plugin, the plugin is hidden. This is best for plugins that report machine-local PI WEBUI status or depend on machine-local plugin code.

For portable plugin assets, prefer URLs relative to the plugin module, for example:

```js
const url = new URL("./asset.json", import.meta.url);
```

If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEBUI gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-webui-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine.

## Manage PI WEBUI plugins

Open **Settings → PI WEBUI plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEBUI machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEBUI server and includes disabled discovered plugins it exposes. PI WEBUI can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.

This surface is only for PI WEBUI plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEBUI plugins panel target the selected machine; plugin enablement still writes the PI WEBUI `plugins` config key rather than changing Pi package-manager settings.

Plugin preferences are stored under the top-level `plugins` config key in the PI WEBUI config file:

```json
{
  "plugins": {
    "workspace-tasks": {
      "enabled": true,
      "settings": {}
    },
    "info": {
      "enabled": false
    }
  }
}
```

Plugins are enabled by default. Set `enabled` to `false` to remove a plugin from `/pi-webui-plugins/manifest.json` so the browser will not import or activate it on the next page load. The optional `settings` object is reserved for plugin-specific settings.

After changing plugin enablement, reload the PI WEBUI browser tab. Already-loaded plugin JavaScript is not unloaded from the current page.

## Built-in plugins

PI WEBUI ships core, discoverable plugins in the main `@hyperdreamer/pi-webui` npm package. No separate `pi install` step is required: update PI WEBUI, reload the browser tab, and the bundled plugins appear in `/pi-webui-plugins/manifest.json`.

Built-in plugins can be managed from **Settings → PI WEBUI plugins** or with the top-level `plugins` config key.

### Memory

**Plugin id:** `workspace-memory`
**What it does:** adds a read-only **Memory** Activity Rail activity. It is **Rail-only**: it does not contribute a workspace panel. It reads `pi-hermes-memory`-compatible data through PI WEBUI and never writes, creates, edits, or deletes memories.

Select the circular **Memory** Rail control to open a host-owned detail dialog. The dialog shows independently collapsible **Global memory** and **Project-specific memory** groups. Project-specific memory contains entries only for the selected workspace project. Project scope resolves by repository root, so linked Git worktrees share the repository-root hermes identity instead of being treated as separate projects. Empty or missing memory data appears as a scoped empty state. An unavailable project-specific request displays a scoped **Project-specific unavailable** state while **Global memory** remains available. A failed global-memory request is presented as a retryable error in the detail view.

With a workspace selected, the Memory activity remains visible while memory loads, when data is available, and if a memory request fails. It is hidden only after the provider confirms that the whole memory capability is unavailable. A project-specific unavailable response is a separate scoped detail state; it does not mean the whole provider is unavailable. The count badge appears only for data with one or more entries, so it is omitted in all non-data states and for zero data; when shown, it totals global and project-specific memory entries. PI WEBUI refreshes the badge immediately after a selected project or workspace change and then checks approximately every 30 seconds. This is polling; PI WEBUI does not promise instant or realtime updates.

Memory is bundled with PI WEBUI and enabled by default. To disable it, use **Settings → PI WEBUI plugins** or set:

```json
{
  "plugins": {
    "workspace-memory": { "enabled": false }
  }
}
```

### Learned Skills

**Plugin id:** `workspace-learned-skills`
**What it does:** adds a read-only **Learned Skills** Activity Rail activity. It is **Rail-only**: it does not contribute a workspace panel. It reviews global and project learned skills generated by compatible Pi packages through PI WEBUI and cannot add, edit, delete, install, update, enable, or disable skills.

Select the circular **Learned Skills** Rail control to open a host-owned detail dialog with a resizable list/detail view. The list groups **Project skills** and **Global skills**; selecting a skill shows its description, file path, version, and dates in the right-hand pane. Drag the separator between the list and the details to resize the list, or use the separator's arrow, Home, and End keys. Below 760px width the panel switches to single-column list/detail navigation with a back control.

The Learned Skills Rail control appears while a compatible provider loads, when data is available, and if a request fails. It is hidden only after every learned-skill provider reports the capability unavailable. The count badge appears only when data has one or more skills; when shown, it totals project and global skills. PI WEBUI refreshes the list immediately after a selected workspace or machine change and then checks approximately every 30 seconds. This is polling; PI WEBUI does not promise instant or realtime updates. After polling stops because every provider is unavailable, a later skill install or change is detected only after a browser reload.

Learned Skills is bundled with PI WEBUI and enabled by default. To disable it, use **Settings → PI WEBUI plugins** or set:

```json
{
  "plugins": {
    "workspace-learned-skills": { "enabled": false }
  }
}
```

### Updates

**Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEBUI update, restart, and installed-service guidance, plus a **Check for PI WEBUI Updates** action for the selected machine.

While a browser tab is connected, PI WEBUI refreshes the selected machine's status every 15 minutes. npm release lookups are cached on that machine for six hours, so the automatic refresh normally contacts npm at most once in that window. Run **Check for PI WEBUI Updates** from the action palette to bypass both caches and check immediately. Operator settings that skip remote version checks, such as `PI_WEBUI_OFFLINE`, are still respected.

Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab and action only appear for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → PI WEBUI plugins** or set:

```json
{
  "plugins": {
    "updates": { "enabled": false }
  }
}
```

### Workspace Tasks

**Plugin id:** `workspace-tasks`
**Project catalog file:** `.pi-webui/tasks.json`
**Global catalog setting:** `plugins.workspace-tasks.settings.globalTasks`
**What it does:** adds a **Tasks** workspace tab for browsing, editing, and running configured shell commands in dedicated PI WEBUI terminals.

Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → PI WEBUI plugins** or set:

```json
{
  "plugins": {
    "workspace-tasks": { "enabled": false }
  }
}
```

Configure Project tasks in `.pi-webui/tasks.json`:

```json
{
  "version": 1,
  "tasks": [
    {
      "id": "app.start",
      "title": "Start app",
      "group": "Development",
      "description": "Start the local development server.",
      "command": "npm run dev"
    },
    {
      "id": "db.reset",
      "title": "Reset DB",
      "group": "Database",
      "command": "go -C klingit-go run ./cli db reset",
      "confirm": true
    }
  ]
}
```

Task fields:

- `version`: must be `1`.
- `tasks`: array of task definitions.
- `id`: stable task id, matching `^[a-z][a-z0-9.-]*$`.
- `title`: button label.
- `command`: literal shell command sent to the terminal.
- `description`: optional explanatory text.
- `group`: optional group heading.
- `confirm`: optional boolean. When true, the browser asks before dispatching the command.

The Tasks panel has **All**, **Global**, and **Project** filters. **All** shows separate Global and Project headings, while the other filters show one catalog. Counts reflect the tasks currently loaded in each scope. Equal task IDs are valid in both scopes: a Global task and a Project task with the same ID remain separate rows with independent edit, delete, and run actions.

Tasks with a `group` use native collapsible disclosure groups. Groups keep their first-seen order within each catalog, start collapsed, and retain their open state while the panel remains mounted. The **All** view keeps the scopes and their groups distinct even when group names or task IDs match.

The UI calls the workspace-rooted catalog **Project**. A Project task is stored at the selected workspace's `.pi-webui/tasks.json`, so the main checkout and each linked Git worktree have separate catalogs. A Global task is stored once in the selected machine's PI WEBUI configuration and is independently editable from every workspace on that machine; it is not linked to the workspace where it was created.

The editor uses the **Available in all projects on this machine** checkbox. It is unchecked for a new Project task and checked for a new Global task. Creating a new task in either scope writes directly to that catalog. Changing the checkbox for an existing task asks for confirmation, names the source and destination scope, and then performs a guarded promotion or demotion that removes the source definition only after the destination definition is written and verified.

- **Add** creates a task and appends it to the selected catalog.
- **Edit** preserves its position in that catalog.
- **Delete** removes the selected task without reordering the remaining tasks.
- **Repair invalid Project files manually.** The panel does not offer a reset action because guarded writes require a valid source snapshot. Open `.pi-webui/tasks.json` in the file explorer, correct the invalid text, then click **Refresh**. Binary, truncated, or otherwise unavailable files must be repaired at the workspace or machine configuration source.

Browser writes canonicalize whitespace and key order, preserve supported task values and array order, and drop unknown fields; original JSON formatting and unsupported fields are not preserved.

The global catalog uses the same version-one task shape as a Project catalog. A missing global catalog is treated as an empty version-one catalog. If the stored global value is malformed, the Tasks panel reports the global catalog as invalid and does not replace it with an empty catalog. Repair malformed global configuration through normal PI WEBUI configuration administration; the panel's manual Project-file repair does not repair global configuration.

For a multiline command, encode line feeds as `\n` in JSON:

```json
{
  "version": 1,
  "tasks": [
    {
      "id": "verify",
      "title": "Build and test",
      "group": "Quality",
      "command": "set -e\nnpm run build\nnpm test",
      "confirm": true
    }
  ]
}
```

Click **Run** next to a task to send one terminal request. Both Project and Global tasks run in the currently selected workspace root, so a Global task adapts to the workspace in which it is launched. The server starts one dedicated terminal with `$SHELL -lc`; all lines in a multiline command share shell state such as variables and the current directory, and the shell's final exit status determines whether the run succeeds or fails. This is one terminal request, not one request per line. For POSIX-compatible fail-fast behavior, use syntax such as:

```sh
set -e
npm run build
npm test
```

or:

```sh
npm run build && npm test
```

These are POSIX-compatible examples, not shell-neutral guarantees.

A stale catalog save or destination collision leaves the affected catalogs unchanged. Use **Refresh** to load authoritative data, review the draft, and try again. A partial or uncertain promotion/demotion remains in guarded recovery: **Retry move** is available only after Refresh confirms the exact destination-written state, and it is still checked by the server before the source is removed. There is no automatic merge, retry, compensation, or overwrite of an unrecognized intermediate state. A server restart loses process-local move ownership, so any intermediate state left after restart requires manual resolution rather than an automatic retry.

Review task definitions before running them, especially in shared repositories. Workspace Tasks runs trusted shell commands from your repositories, and both Global and Project tasks execute in the selected workspace root.

## Discovery and packaging

PI WEBUI builds the gateway `/pi-webui-plugins/manifest.json` from these sources:

1. Bundled plugins in the PI WEBUI package:

   ```text
   pi-webui-plugins/<plugin-package>/
   ```

2. User-local plugins:

   ```text
   ~/.pi-webui/plugins/<plugin-package>/
   ```

   Entries may be real directories or symlinks. This is the recommended development workflow.

3. Installed Pi packages that expose PI WEBUI plugin metadata. Pi packages may be user or project scoped. Installing/removing/updating Pi packages is done from **Settings → Pi packages** (or Pi's package manager), not from the PI WEBUI plugin enable/disable list.

Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-webui-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide.

Plugin package directory names and plugin ids must be valid identifiers:

```text
^[a-z][a-z0-9.-]*$
```

A package can expose one or more PI WEBUI plugin modules. There is exactly one supported `package.json` metadata shape:

```json
{
  "private": true,
  "piWebUi": {
    "plugins": [
      { "id": "review", "module": "dist/review.js" },
      { "id": "dashboard", "module": "dist/dashboard.js", "machineSpecific": true }
    ]
  }
}
```

Rules:

- `piWebUi.plugins` must be an array of objects.
- Each entry must have an explicit `id` and `module`.
- `id` must match `^[a-z][a-z0-9.-]*$`.
- `module` must be a safe relative path inside the plugin package root.
- `machineSpecific` is optional and must be a boolean; omit it for the default portable gateway behavior.
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
- Legacy shortcuts such as `piWebUi.plugin`, string entries in `piWebUi.plugins`, `piWebUi.id` fallback ids, and no-`package.json` fallbacks are not supported.

### Manifest and assets

The manifest contains each discovered plugin module. Current PI WEBUI releases emit `module` as a leading application-root reference:

```json
{
  "plugins": [
    {
      "id": "my-plugin",
      "module": "/pi-webui-plugins/my-plugin/pi-webui-plugin.js?v=1234567890",
      "source": "local",
      "scope": "local",
      "machineSpecific": false
    }
  ]
}
```

The browser maps leading application-root references into the current application base, so the same manifest works at the origin root or under a reverse-proxy path prefix. Keeping this output format also lets gateways from existing PI WEBUI releases consume plugins from an upgraded remote machine. For compatibility, federated gateways additionally accept explicit manifest-relative references such as `./my-plugin/pi-webui-plugin.js` and legacy plugin-root-relative references such as `nested/pi-webui-plugin.js`; all accepted forms are rewritten to deployment-portable, gateway-relative references.

`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.

At an origin-root deployment, a plugin's static assets are available under:

```text
/pi-webui-plugins/<plugin-id>/<path-inside-plugin-root>
```

Prefer module-relative asset URLs so they also work for remote machine plugins. For example, a built plugin module can reference an SVG shipped beside it:

```js
const iconUrl = new URL("./assets/icon.svg", import.meta.url);
```

The final installed plugin package must contain `assets/icon.svg` at that path relative to the final built module. PI WEBUI serves files that already exist in the package; it does not copy a source `public/` directory or apply Vite-style public-directory semantics. Configure the plugin build and package contents to emit or copy the asset into its final module-relative location.

PI WEBUI prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, HTML, and SVG files get appropriate content types; unknown file types are served as octet-stream.

## Plugin module shape

The entry module must default-export a plugin object:

```ts
interface PiWebUiPlugin {
  apiVersion: 1;
  name: string;
  activate: (context: PluginActivationContext) => PluginActivationResult;
}

interface PluginHostCapabilities {
  activityRailItems?: true;
}

interface PluginActivationContext {
  apiVersion: 1;
  pluginId: string;
  html: typeof import("lit").html;
  svg: typeof import("lit").svg;
  capabilities?: PluginHostCapabilities;
}

interface PluginActivationResult {
  contributions: PluginContributions;
}
```

Example:

```js
export default {
  apiVersion: 1,
  name: "My Plugin",
  activate: ({ pluginId, html }) => ({
    contributions: {
      actions: [],
      workspacePanels: [],
      workspaceLabels: [],
    },
  }),
};
```

`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.

The plugin id comes from `package.json`, not from the JavaScript module. Contribution ids are local to the plugin and PI WEBUI qualifies them internally as:

```text
<plugin-id>:<local-contribution-id>
```

For example, plugin `info` with action `workspace.show-path` becomes `info:workspace.show-path`.

## Contributions

`activate()` returns a `contributions` object with any combination of these arrays:

```ts
interface PluginContributions {
  actions?: PluginAction[];
  workspacePanels?: WorkspacePanelContribution[];
  activityRailItems?: ActivityRailContribution[];
  workspaceLabels?: 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?: (context: PluginRuntimeContext) => boolean;
  disabledReason?: (context: PluginRuntimeContext) => string | undefined;
  run: (context: PluginRuntimeContext) => void | Promise<void>;
}
```

If an action is disabled and returns `disabledReason`, PI WEBUI can keep it visible in the action palette with that explanation instead of hiding it.

Stable runtime context fields:

```ts
interface PluginRuntimeContext {
  state: {
    selectedWorkspace?: Workspace;
    selectedSession?: unknown;
    piWebUiStatus?: PiWebUiStatusResponse;
  };
  prompt: PluginPromptEditor;
  openActionPalette: () => void;
  focusPrompt: () => void;
  addProject: () => void | Promise<void>;
  configureAuth: () => void | Promise<void>;
  logoutAuth: () => void | Promise<void>;
  selectWorkspaceTool: (tool: QualifiedContributionId) => void;
  openTerminal: (options?: { terminalId?: string }) => void;
  refreshFiles: () => void | Promise<void>;
  refreshGit: () => void | Promise<void>;
  checkForPiWebUiUpdates?: () => 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.
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebUiStatus`. `state.piWebUiStatus` describes the currently selected machine's PI WEBUI runtime, or the gateway/local runtime when the local machine is selected.
- Other `state` fields may exist at runtime, but they are private PI WEBUI internals that may graduate into stable helpers, change shape, or disappear.
- `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
- `checkForPiWebUiUpdates()` forces a fresh update check on the selected machine and refreshes `state.piWebUiStatus`. It is optional so plugins remain compatible with older PI WEBUI hosts.
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.

### Prompt editor API

The `prompt` helper on `PluginRuntimeContext` and `WorkspacePanelContext` provides stable access to the chat prompt editor:

| Method | Description |
| --- | --- |
| `insertText(text)` | Insert text at cursor position. When text is selected, replaces the selection. Focuses the editor first if not focused. |
| `getText()` | Returns the full prompt text. |
| `getSelection()` | Returns `{ start, end, text }` if text is selected, or `null`. |

Usage:

```js
// Insert text at the cursor (e.g. a file mention)
context.prompt.insertText("@file.txt");

// Read the current prompt and selection
const text = context.prompt.getText();
const selection = context.prompt.getSelection(); // { start, end, text } | null
```

Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor. Workspace panels can call `context.prompt.insertText()` from explicit user interactions such as button clicks; panel contexts target the currently selected session's mounted prompt editor.

#### Keyboard shortcuts

- App-level keyboard shortcuts must be attached to actions. PI WEBUI does not support standalone plugin keyboard commands; contribute an action first, then add a `shortcut` if it needs a keybinding.
- `shortcut` is the action's default keybinding. It is displayed in the action palette and handled by the global shortcut dispatcher when the action is enabled.
- Use modified shortcuts such as `mod+shift+p`; plain letter shortcuts are intentionally ignored so normal typing is never captured.
- Future PI WEBUI versions may allow users to override or disable action shortcuts by action id, so plugins should treat `shortcut` as a default rather than a guaranteed final binding.
- Choose shortcuts carefully to avoid conflicts. There is no user-facing shortcut override or conflict resolver yet.
- Local text input, terminal input, list navigation, and dialog keys such as Enter, Escape, and arrow keys do not need to be plugin actions unless they are app-level commands.

### 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
workspacePanels: [
  {
    id: "workspace.info",
    title: "Info",
    icon: svg`
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <circle cx="12" cy="12" r="9"></circle>
        <path d="M12 10v6"></path>
        <path d="M12 7h.01"></path>
      </svg>
    `,
    order: 100,
    visible: ({ workspace }) => workspace.isGitRepo,
    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>
    `,
  },
]
```

Panel type:

```ts
interface WorkspacePanelContribution {
  id: string;
  title: string;
  icon?: TemplateResult;
  order?: number;
  visible?: (context: WorkspacePanelContext) => boolean;
  badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
  render: (context: WorkspacePanelContext) => TemplateResult;
}

interface WorkspacePanelContext {
  machine: PluginMachine;
  workspace: Workspace;
  state?: PluginRuntimeState;
  files: {
    readFile(path: string): Promise<FileContentResponse>;
    writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
    deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
    moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
  };
  prompt: PluginPromptEditor;
  terminal: {
    open(options?: { terminalId?: string }): void;
    runCommand(input: {
      title: string;
      command: string;
      metadata?: Record<string, string>;
      open?: boolean;
    }): Promise<TerminalCommandRunHandle>;
  };
  host: {
    requestRender(): void;
  };
}
```

`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEBUI themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide.

`machine`, `workspace`, `files`, `prompt`, `terminal`, and `host` are documented as stable for panel callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). The `prompt` helper supports panel interactions that insert workspace context into the current prompt — see [Prompt editor API](#prompt-editor-api). Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEBUI re-evaluate panel callbacks such as `badge`, `visible`, or `render`.

For compatibility, PI WEBUI still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`.

Useful workspace and machine shapes:

```ts
interface PluginMachine {
  id: string;
  name: string;
  kind: "local" | "remote";
}

interface Workspace {
  id: string;
  projectId: string;
  path: string;
  label: string;
  branch?: string;
  isMain: boolean;
  isGitRepo: boolean;
  isGitWorktree: boolean;
}
```

`machine.id` is included in panel contexts so plugins can keep caches machine-scoped. Do not infer the selected machine from global browser state.

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.

### Activity Rail activities

Activity Rail activities add a focused, dialog-backed surface. Each activity must supply an `id`, `title`, `icon`, and `render`. The host uses `title` for the icon control's accessible name and dialog heading, `icon` for the Rail control, and `render` for the dialog body. Prefer an SVG created by `svg` with `currentColor` so themes can style the required icon.

Activity Rail support is an additive v1 capability. A supporting host passes `capabilities.activityRailItems === true`; if the capability is missing, select the old-host branch:

```js
activate: ({ capabilities, html, svg }) => ({
  contributions: capabilities?.activityRailItems === true
    ? {
      activityRailItems: [{
        id: "workspace.dashboard",
        title: "Dashboard",
        icon: svg`<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"></path></svg>`,
        render: () => html`<p>Dashboard</p>`,
      }],
    }
    : {
      workspacePanels: [{
        id: "workspace.dashboard",
        title: "Dashboard",
        render: () => html`<p>Dashboard</p>`,
      }],
    },
});
```

Activity type and context:

```ts
interface ActivityRailContribution {
  id: string;
  title: string;
  icon: TemplateResult;
  order?: number;
  visible?: (context: ActivityRailContext) => boolean;
  badge?: (context: ActivityRailContext) => string | number | TemplateResult | undefined;
  render: (context: ActivityRailContext) => TemplateResult;
}

interface ActivityRailHost {
  requestRender(): void;
  close(): void;
}

interface ActivityRailWorkspaceScope {
  workspace: Workspace;
  files: WorkspaceFiles;
  terminal: WorkspacePanelTerminal;
}

interface ActivityRailContext extends PluginRuntimeContext {
  machine: PluginMachine;
  workspaceScope?: ActivityRailWorkspaceScope;
  host: ActivityRailHost;
}
```

The host renders visible activities as neutral, circular icon controls in a dedicated, host-owned section after its reorderable built-in controls and before Settings. Activities are not draggable and do not enter the user-reorderable core Rail order. `order` controls only the activity section: items sort by ascending `order` (default `1000`), then `title`, then id. Do not use `order` to place an activity among built-in controls.

`visible` and `badge` are synchronous, lightweight callbacks. `visible` defaults to shown; return `false` to hide an activity. `badge` runs only for visible activities and may return a string, number, `TemplateResult`, or `undefined`; `undefined` omits the badge. Do not return promises. Keep asynchronous work and cached state inside the plugin, then call `host.requestRender()` when that state changes so the host can re-evaluate `visible`, `badge`, and an open activity body.

`ActivityRailContext` extends the documented runtime context. `workspaceScope` is optional because an activity can be available without a selected workspace; check it before using its `workspace`, `files`, or `terminal` helpers. `host.close()` closes the currently open instance of that activity. Calls from stale or no-longer-open activity contexts safely do nothing.

The host owns the dialog frame, title, icon, close controls, Escape/backdrop dismissal, focus restoration, and error handling. An activity's `render()` returns only the dialog body. The host reports callback failures: a throwing `visible` callback hides the activity, a throwing `badge` callback omits the badge, and a throwing `render` callback shows a host-owned failure message.

Below 1181px, the persistent desktop Rail is replaced by a compact Activity Rail drawer that includes the same visible controls. Selecting an activity there still opens the host-owned dialog.

### Workspace labels

Workspace labels add compact inline metadata wherever PI WEBUI 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
workspaceLabels: [
  {
    id: "dev-url",
    order: 10,
    visible: ({ workspace }) => workspace.path.includes("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[];
}

interface WorkspaceLabelContext {
  machine: PluginMachine;
  workspace: Workspace;
  state?: PluginRuntimeState;
  files: {
    readFile(path: string): Promise<FileContentResponse>;
    writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise<WriteWorkspaceFileResponse>;
    deleteFile(path: string): Promise<DeleteWorkspaceFileResponse>;
    moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise<MoveWorkspaceFileResponse>;
  };
  host: {
    requestRender(): void;
  };
}
```

`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEBUI re-evaluate label `visible` or `items` callbacks.

Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes.

#### 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 WEBUI 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
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 {
  apiVersion: 1,
  name: "My Plugin",
  activate: ({ html }) => ({
    contributions: {
      workspaceLabels: [
        {
          id: "badge",
          order: 10,
          items: ({ workspace }) => [{
            type: "render",
            render: () => html`<my-workspace-badge .workspace=${workspace}></my-workspace-badge>`,
          }],
        },
      ],
    },
  }),
};
```

## Reading workspace files

Workspace panels and workspace labels can read files through the documented `files` helper. PI WEBUI binds this helper to the callback's machine and workspace, so it works the same for local and federated machines.

```js
workspacePanels: [
  {
    id: "workspace.env",
    title: "Env",
    render: ({ files }) => html`
      <my-env-viewer .files=${files}></my-env-viewer>
    `,
  },
]

class MyEnvViewer extends HTMLElement {
  set files(value) {
    this._files = value;
    void this.load();
  }

  async load() {
    try {
      const file = await this._files.readFile(".env.example");
      this.textContent = file.binary ? "Binary file" : file.content;
    } catch (error) {
      this.textContent = error instanceof Error ? error.message : String(error);
    }
  }
}
```

Labels should use the same helper through a plugin-owned cache because `items()` itself must return synchronously:

```js
const envCache = new Map();

function envKey(machine, workspace) {
  return `${machine.id}:${workspace.id}:.env.local`;
}

function loadEnvLabel(context) {
  const key = envKey(context.machine, context.workspace);
  const cached = envCache.get(key);
  if (cached !== undefined) return cached;

  const pending = { status: "loading", label: undefined };
  envCache.set(key, pending);
  context.files.readFile(".env.local")
    .then((file) => {
      pending.status = "ready";
      pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1];
      context.host.requestRender();
    })
    .catch(() => {
      pending.status = "missing";
      context.host.requestRender();
    });
  return pending;
}

workspaceLabels: [
  {
    id: "dev-url",
    items: (context) => {
      const cached = loadEnvLabel(context);
      return cached.label === undefined ? [] : [{
        type: "link",
        text: cached.label,
        href: cached.label,
        target: "_blank",
      }];
    },
  },
]
```

The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.

## Writing, deleting, and moving workspace files

Workspace panels and workspace labels can write, delete, and move files through the documented `files` helper. Like `readFile`, PI WEBUI binds these helpers to the callback's machine and workspace, so they work the same for local and federated machines.

### Writing files

```js
workspacePanels: [
  {
    id: "workspace.generate",
    title: "Generate",
    render: ({ files }) => html`
      <button @click=${async () => {
        const result = await files.writeFile("output/result.txt", "Generated content\n");
        console.log("Wrote", result.path, result.size, "bytes");
      }}>Generate</button>
    `,
  },
]
```

### Binary writes

Pass a `Uint8Array` for binary content such as images:

```js
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
await files.writeFile("screenshots/thumb.png", png);
```

### Options

`files.writeFile` accepts an optional third argument:

- `createDirs` (default `true`): create intermediate directories, like `mkdir -p`.
- `overwrite` (default `true`): overwrite existing files. Set to `false` to throw if the file already exists.

```js
// Create only — throw if the file already exists
await files.writeFile("config/new-config.json", jsonContent, { overwrite: false });
```

### Deleting files

`files.deleteFile` removes a workspace file. It is idempotent: deleting a file that does not exist returns `{ existed: false }` instead of throwing.

```js
const result = await files.deleteFile("temp/cache.json");
console.log(result.existed ? "File deleted" : "File did not exist");
```

### Moving files

`files.moveFile` renames or moves a file within the workspace, like `mv`. The default is safe: it will not overwrite an existing target file.

```js
// Rename a file
await files.moveFile("old-name.txt", "new-name.txt");

// Move into a subdirectory (creates intermediate dirs by default)
await files.moveFile("file.txt", "archive/file.txt");

// Overwrite an existing target
await files.moveFile("incoming.txt", "current.txt", { overwrite: true });

// Move without creating intermediate directories
await files.moveFile("file.txt", "deep/nested/file.txt", { createDirs: false }); // throws if dirs don't exist
```

`files.moveFile` accepts an optional third argument:

- `createDirs` (default `true`): create intermediate directories for the target path.
- `overwrite` (default `false`): overwrite the target file if it exists. The default is safer than `writeFile` because moving is a more destructive operation.

### Error handling

All file mutations share the same safety layer:

- `overwrite: false` on `writeFile` or existing target on `moveFile` (default) throws if the file already exists.
- Path traversal (e.g., `../../etc/passwd`) is blocked by the workspace safety layer.
- Writing to or moving to a path that is a directory returns an error.
- Deleting a directory returns an error.
- Intermediate directory creation with `createDirs: false` fails if the parent directory does not exist.

After any mutation (`writeFile`, `deleteFile`, or `moveFile`), the File Explorer updates automatically. No explicit `refreshFiles()` call is needed from plugin code. For label and badge updates, call `context.host.requestRender()` if the UI should reflect the change.

### Security

Plugins are trusted browser code. File writes go through the same path safety validation as reads — paths are resolved and checked to stay inside the workspace root.

## Running workspace terminal commands

Workspace panels can start terminal commands through the documented `terminal` helper. Commands run in the current workspace on the panel's machine.

```js
render: ({ terminal }) => html`
  <button @click=${() => terminal.runCommand({
    title: "Build",
    command: "npm run build",
    open: true,
    metadata: { "my-plugin.task": "build" },
  })}>Build</button>
`
```

Review command strings carefully. They are trusted shell commands executed in the workspace terminal.

## Private and experimental PI WEBUI APIs

PI WEBUI's `/api/...` HTTP and WebSocket routes and runtime-only fields are private implementation details. They exist because plugins are trusted browser code, and because some capabilities may be evaluated there before they are designed as stable helpers.

That is allowed, but outside the v1 compatibility promise: URLs, response shapes, runtime fields, and machine-federation routing may graduate into stable APIs, change shape, or disappear. The stable public plugin API is only the documented helpers and declarations in `plugin-api.d.ts`. Prefer those whenever they exist; if you rely on private surfaces, keep the dependency local to the plugin and expect to revisit it after PI WEBUI upgrades.

## Async data and caching

PI WEBUI 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, workspace panels, or Activity Rail dialog bodies are a good place to own async loading;
- dedupe async reads/commands and avoid unbounded polling;
- clean up intervals/event listeners in custom elements' `disconnectedCallback()`.

## Agent implementation checklist

If you are an AI agent building or editing a PI WEBUI plugin, follow this checklist:

1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-webui-plugin.js`.
2. Use the single supported package metadata shape: `piWebUi.plugins` array with `{ id, module, machineSpecific? }` entries.
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
4. Return `{ contributions: { actions, workspacePanels, activityRailItems, workspaceLabels } }` from `activate()` as needed.
5. Use ids matching `^[a-z][a-z0-9.-]*$`.
6. Use the activation context's `html` function for Lit templates.
7. Keep `activate()` synchronous and cheap; return contribution definitions only.
8. Feature-detect `capabilities.activityRailItems === true` and retain a fallback contribution when supporting older v1 hosts.
9. Add actions for command-palette operations.
10. Add workspace panels for larger workspace UI.
11. Add Activity Rail activities for focused host-owned dialogs; require an icon and title, and check `workspaceScope` before using it.
12. Add workspace labels for compact inline metadata.
13. Return arrays from workspace label `items()`; return an empty array to render nothing.
14. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `host.close`, `workspaceScope`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, `state.piWebUiStatus`, and `prompt`.
15. Do not fetch PI WEBUI `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
16. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
17. After local edits, tell the user to hard reload the browser and check the console for plugin errors.

## Troubleshooting

Check discovery:

```bash
curl http://127.0.0.1:8808/pi-webui-plugins/manifest.json
```

Check a plugin module:

```bash
curl http://127.0.0.1:8808/pi-webui-plugins/my-plugin/pi-webui-plugin.js
```

Common issues:

- invalid plugin id or contribution id;
- missing default export;
- missing `apiVersion: 1`, `name`, or `activate` function;
- missing `package.json` or incorrect `piWebUi.plugins` metadata;
- legacy shortcuts such as `piWebUi.plugin`, string plugin entries, or no-`package.json` fallback;
- duplicate plugin ids; later duplicates are skipped rather than renamed;
- 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-webui/plugins` or symlinked there;
- plugin throws during module import, `activate()`, `visible()`, `badge()`, `enabled()`, `items()`, or `render()`; check the browser console.
