| name | pi-extension-builder |
| description | Guidelines for creating or modifying pi.dev extensions in this repo (~/.pi). Load when the user asks to build, edit, debug, or refactor a pi.dev extension. |
Pi Extension Development Guide
Before writing any code
- Read root
README.md — documents extensions, file tree, setup conventions
- Read extension's
README.md if exists
- Read existing
index.ts to understand current patterns
- Source of truth: code, not README
Structure decision: simple vs complex
Judge scope first, then pick pattern:
Simple (spinner, env-loader, single tool):
agent/extensions/<name>/
├── README.md
└── index.ts
Complex (TUI components, user config, multiple tools, layered logic):
agent/extensions/<name>/
├── README.md
├── <name>.example.json # Example user config (with relevant _comment)
├── index.ts # Wiring only
├── config.ts # Dumb values (single source of truth)
├── utils.ts # Pure helpers
└── components/ # TUI components if needed
└── <name>.ts
Rules:
- Start simple — split only when needed
- If user config is needed,
config.ts and <name>.example.json are mandatory
index.ts always the entry point, always exports default function receiving ExtensionAPI. Pi auto-discovers ~/.pi/agent/extensions/*/index.ts — no manifest needed
- No per-extension
package.json — the whole kit is one npm package (pikit, root package.json). Extensions are just directories; the root pi.extensions glob (./agent/extensions/*/index.ts) is what ships them when pikit is installed as a package. See PUBLISHING.md
- If the extension imports a new third-party runtime dep, add it to root
package.json dependencies. If it imports a new @earendil-works/* package, add it to root peerDependencies (the pi loader provides @earendil-works/* at runtime — never list those in dependencies)
Code conventions
- Export single default function receiving
ExtensionAPI — no classes, no extra exports
- Reference existing extensions:
web-access/index.ts (tool registration), startup/index.ts (lifecycle), permission-gate/index.ts (interception)
- Match TypeScript style of file being edited — no new patterns
- Opening a URL in the default browser — use the
mcp/helpers.ts openBrowser pattern: spawn(cmd, args, { detached: true, stdio: "ignore" }).unref() with [cmd, args] = open [url] (darwin) / rundll32 ["url.dll,FileProtocolHandler", url] (win32 — start is a cmd.exe built-in and cannot be spawned directly) / xdg-open [url] (linux). No shell, no exec string concatenation. See agent/extensions/mcp/helpers.ts
- One concern per extension
Import ordering
Group imports with a blank line between externals and internals:
import type { Theme } from "@earendil-works/pi-coding-agent";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./config.js";
import type { Result } from "./types.js";
Rules:
- All external imports first (
@earendil-works/*, node:*, third-party)
- All internal imports after (
./, ../)
- Blank line between the two groups
- Within each group, match existing ordering (don't reorder beyond grouping)
- Applies to
import type lines too — they follow the same grouping
- Don't add comments to the import section — it's self-explanatory with the grouping
Package namespace
Pi's npm packages live under @earendil-works/ (the old @mariozechner/ scope is deprecated). The extension loader resolves both prefixes to the same bundled modules at runtime — no npm install needed in extension code.
| Deprecated (old) | Current |
|---|
@mariozechner/pi-coding-agent | @earendil-works/pi-coding-agent |
@mariozechner/pi-tui | @earendil-works/pi-tui |
@mariozechner/pi-agent-core | @earendil-works/pi-agent-core |
@mariozechner/pi-ai | @earendil-works/pi-ai |
Convention: Use @earendil-works/pi-coding-agent and @earendil-works/pi-tui for imports. Match existing extension code. Never put @earendil-works/* in dependencies — the loader provides them at runtime. List each @earendil-works/* package the extension imports in root package.json peerDependencies with a "*" range (@earendil-works/pi-coding-agent always; pi-tui, pi-ai only if imported). Genuine third-party runtime deps (e.g. typebox, marked) go in root package.json dependencies as usual. Use typebox (v1.x) — the same package pi-coding-agent depends on internally — for all TypeBox schema imports. The old @sinclair/typebox name is deprecated; the API surface used here (Type.Object/String/Array/Optional/Union/Literal/Boolean/Number/Unsafe) is identical between the two, and their JSON Schema output is semantically equivalent (only key order differs).
Complex extension patterns
Apply only when structure warrants it:
1. Single source of truth — Config defined once, derived values computed:
export const CONFIG = { DOT_PREFIX: "●" };
const WIDTH = getVisibleWidth(CONFIG.DOT_PREFIX) + 2;
const PADDING = " ".repeat(WIDTH);
2. Separation of concerns
index.ts — Wiring (patches, events, registrations)
config.ts — Dumb values only (no logic)
utils.ts — Pure helpers (parsers, validators, formatters)
types.ts — Type definitions (always extract when config exists). User config types mirror the config shape with all-optional fields. See styled-outputs/types.ts for pattern
components/ — TUI rendering (implement render(width) interface)
3. Clean porting — From reference implementations:
- Draw from functionality, not implementation
- Don't replicate convoluted code — reinterpret cleanly
- Extract in order: config → utils → components → wire in index.ts
4. User config — When extension supports user configuration:
- Config file:
~/.pi/agent/configs/<extension-name>.json
config.ts defines DEFAULT_CONFIG with all defaults (color fields use theme tokens like "text", "accent", "success", "muted", "dim", "separator")
loadUserConfig() reads user config (readFileSync + JSON.parse, catch → {})
- Merge via
??: userConfig.field ?? DEFAULT_CONFIG.FIELD — user overrides win, defaults fill gaps
- See
styled-outputs/config.ts for full pattern
5. Color support — All color config fields must accept both theme tokens and hex:
- Theme tokens:
"text", "accent", "success", "error", "muted", "dim", "separator", "toolTitle", etc.
- Hex values:
"#ff6600", "#00ff88", etc.
- Implementation in
utils.ts:
isHexColor(color) — checks color.startsWith("#")
applyColor(theme, color, text) — hex → ANSI truecolor (\x1b[38;2;r;g;bm), otherwise theme.fg(color, text)
applyBgColor(theme, color, text) — hex → ANSI truecolor bg (\x1b[48;2;r;g;bm), otherwise converts theme fg ANSI to bg ANSI (swap 38 → 48)
- See
styled-outputs/utils.ts for full implementation
6. Example config — Include <extension-name>.example.json at extension root:
_comment key with file placement instructions (e.g. "Place this file at ~/.pi/agent/configs/<name>.json")
- Default values for every field — users copy this as starting point
- See
styled-outputs/styled-outputs.example.json for pattern
7. Patching pattern — Modifying pi components:
import { PATCH_FLAG } from "./utils.js";
export default function(pi) {
const proto = TargetComponent.prototype as any;
if (proto[PATCH_FLAG]) return;
const original = proto.method;
proto.method = function(...args) {
const result = original.call(this, ...args);
return result;
};
proto[PATCH_FLAG] = true;
}
8. Keybindings — Matching keystrokes or displaying key labels.
For input handling inside handleInput(data), ctx.ui.custom(), or ctx.ui.setEditorComponent() — the framework passes a KeybindingsManager instance via callback:
ctx.ui.custom(tui, theme, keybindings, done) → use the keybindings parameter
ctx.ui.setEditorComponent(tui, theme, keybindings) → use the keybindings parameter
if (this.keybindings.matches(data, "app.tools.expand")) {
return true;
}
For displaying key labels (headers, tips, help text) where no callback provides the instance — use getKeybindings() from @earendil-works/pi-tui:
import { getKeybindings } from "@earendil-works/pi-tui";
const kb = getKeybindings();
const key = kb.getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
Why not KeybindingsManager.create()? KeybindingsManager is exported as export type from @earendil-works/pi-coding-agent — it cannot be used as a value. getKeybindings() from @earendil-works/pi-tui returns the global instance that pi has configured with user overrides and app keybinding definitions.
KeyId values are always lowercase — use them as-is, no title-casing needed.
Common action IDs: app.model.cycleForward, app.model.cycleBackward, app.thinking.cycle, app.tools.expand, app.interrupt, app.clear, app.exit, app.editor.external. Full list: AppKeybinding type export.
API:
matches(data, action) → keystroke matches action? (respects user overrides)
getKeys(action) → KeyId[] for that action (respects user overrides)
Don't over-engineer
- Register only tools/commands/events explicitly asked for
- No config abstraction unless extension already has one
- In doubt: mirror simplest existing extension (
spinners, env-loader)
Documentation
- Update extension's
README.md with new features, tools, commands
- If no
README.md exists, create one with description + usage
- Document new permissions or lifecycle hooks
Root README additions — under ## Extensions, ordered by category:
- UI — visible every session, zero/minimal config
- Security — intercepts or blocks actions
- Utils — tools, bridges, integrations
- Misc — cosmetic or low-priority
Format: one short paragraph (what it does, configurable?, slash command if primary interface), → README link. No command tables, no implementation detail.