Pi Extension Capabilities
An annotated guide to extending pi — the terminal-based coding agent. Every section includes live code examples and interactive navigation.
Overview
Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
pi.registerTool()ctx.ui — select, confirm, input, notifyctx.ui.custom()/mycommand via pi.registerCommand()pi.appendEntry()Quick Start
Create ~/.pi/agent/extensions/my-extension.ts:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; export default function (pi: ExtensionAPI) { // React to events pi.on("session_start", async (_event, ctx) => { ctx.ui.notify("Extension loaded!", "info"); }); pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); if (!ok) return { block: true, reason: "Blocked by user" }; } }); // Register a custom tool pi.registerTool({ name: "greet", label: "Greet", description: "Greet someone by name", parameters: Type.Object({ name: Type.String({ description: "Name to greet" }), }), async execute(toolCallId, params, signal, onUpdate, ctx) { return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {}, }; }, }); // Register a command pi.registerCommand("hello", { description: "Say hello", handler: async (args, ctx) => { ctx.ui.notify(`Hello ${args ?? "world"}!`, "info"); }, }); }
Test with pi -e ./my-extension.ts.
Extension Locations
| Location | Scope |
|---|---|
~/.pi/agent/extensions/*.ts | Global (all projects) |
~/.pi/agent/extensions/*/index.ts | Global (subdirectory) |
.pi/extensions/*.ts | Project-local |
.pi/extensions/*/index.ts | Project-local (subdirectory) |
Additional paths via settings.json:
{
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}
Available Imports
| Package | Purpose |
|---|---|
@earendil-works/pi-coding-agent | Extension types (ExtensionAPI, ExtensionContext, events) |
typebox | Schema definitions for tool parameters |
@earendil-works/pi-ai | AI utilities (StringEnum for Google-compatible enums) |
@earendil-works/pi-tui | TUI components for custom rendering |
npm dependencies work too. Add a package.json next to your extension and run npm install.
Extension Styles
Simplest, for small extensions:
~/.pi/agent/extensions/ └── my-extension.ts
For multi-file extensions:
~/.pi/agent/extensions/
└── my-extension/
├── index.ts # Entry point
├── tools.ts # Helper module
└── utils.ts # Helper module
For extensions that need npm packages:
{
"name": "my-extension",
"dependencies": {
"zod": "^3.0.0",
"chalk": "^5.0.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}
Events
Extensions subscribe to events using pi.on(eventName, handler). Events fire across the full lifecycle of a session.
Lifecycle Overview
Resource Events
resources_discover
Fired after session_start so extensions can contribute additional skill, prompt, and theme paths.
pi.on("resources_discover", async (event, _ctx) => { return { skillPaths: ["/path/to/skills"], promptPaths: ["/path/to/prompts"], themePaths: ["/path/to/themes"], }; });
Session Events
Fired when a session is started, loaded, or reloaded.
pi.on("session_start", async (event, ctx) => { // event.reason - "startup" | "reload" | "new" | "resume" | "fork" ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); });
Fired before starting a new session (/new) or switching sessions (/resume). Return { cancel: true } to block.
pi.on("session_before_switch", async (event, ctx) => { if (event.reason === "new") { const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); if (!ok) return { cancel: true }; } });
Fired when forking via /fork or cloning via /clone. Return { cancel: true } to block.
pi.on("session_before_fork", async (event, ctx) => { // event.entryId, event.position ("before" | "at") return { cancel: true }; // Cancel fork/clone });
Fired on compaction. You can cancel, or provide a custom summary.
pi.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions, signal } = event; // Cancel: return { cancel: true }; // Custom summary: return { compaction: { summary: "...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, } }; });
Fired before an extension runtime is torn down. Use for cleanup and state saving.
pi.on("session_shutdown", async (event, ctx) => { // event.reason - "quit" | "reload" | "new" | "resume" | "fork" // Cleanup, save state, etc. });
Agent Events
Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt.
pi.on("before_agent_start", async (event, ctx) => { // event.prompt, event.images, event.systemPrompt // event.systemPromptOptions - structured options used to build the system prompt return { message: { customType: "my-extension", content: "Additional context for the LLM", display: true, }, systemPrompt: event.systemPrompt + "\n\nExtra instructions...", }; });
Fired once per user prompt.
pi.on("agent_end", async (event, ctx) => { // event.messages - messages from this prompt });
Fired for each turn (one LLM response + tool calls).
pi.on("turn_start", async (event, ctx) => { // event.turnIndex, event.timestamp }); pi.on("turn_end", async (event, ctx) => { // event.turnIndex, event.message, event.toolResults });
Fired for message lifecycle updates. message_update fires for assistant streaming updates. message_end handlers can return { message } to replace the finalized message.
pi.on("message_end", async (event, ctx) => { if (event.message.role !== "assistant") return; return { message: { ...event.message, usage: { ...event.message.usage, cost: { ...event.message.usage.cost, total: 0.123 } }, }, }; });
Fired before each LLM call. Modify messages non-destructively. event.messages is a deep copy, safe to modify.
pi.on("context", async (event, ctx) => { const filtered = event.messages.filter(m => !shouldPrune(m)); return { messages: filtered }; });
before_provider_request fires after the provider-specific payload is built, right before the request is sent. Return a value to replace the payload.
after_provider_response fires after an HTTP response is received and before its stream body is consumed.
pi.on("before_provider_request", (event, ctx) => { // event.payload - the serialized provider payload // Return to replace: return { ...event.payload, temperature: 0 }; }); pi.on("after_provider_response", (event, ctx) => { // event.status, event.headers if (event.status === 429) { console.log("rate limited", event.headers["retry-after"]); } });
Model Events
Fired when the model changes via /model command, model cycling (Ctrl+P), or session restore.
pi.on("model_select", async (event, ctx) => { // event.model, event.previousModel, event.source ("set" | "cycle" | "restore") const prev = event.previousModel ? `${event.previousModel.provider}/${event.previousModel.id}` : "none"; const next = `${event.model.provider}/${event.model.id}`; ctx.ui.notify(`Model: ${prev} -> ${next}`, "info"); });
Fired when the thinking level changes. Notification-only; handler return values are ignored.
pi.on("thinking_level_select", async (event, ctx) => { ctx.ui.setStatus("thinking", `thinking: ${event.level}`); });
Tool Events
Fired after tool_execution_start, before the tool executes. Can block. Use isToolCallEventType to narrow and get typed inputs.
event.input is mutable — mutate it in place to patch tool arguments before execution.
import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; pi.on("tool_call", async (event, ctx) => { if (isToolCallEventType("bash", event)) { // event.input is { command: string; timeout?: number } event.input.command = `source ~/.profile\n${event.input.command}`; if (event.input.command.includes("rm -rf")) { return { block: true, reason: "Dangerous command" }; } } if (isToolCallEventType("read", event)) { // event.input is { path: string; offset?: number; limit?: number } console.log(`Reading: ${event.input.path}`); } });
Fired after tool execution finishes. Can modify result. Handlers chain like middleware — each handler sees the latest result.
import { isBashToolResult } from "@earendil-works/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { if (isBashToolResult(event)) { // event.details is typed as BashToolDetails } const response = await fetch("https://example.com/summarize", { method: "POST", body: JSON.stringify({ content: event.content }), signal: ctx.signal, }); return { content: [...], details: {...}, isError: false }; });
User Bash Events
user_bash
Fired when user executes ! or !! commands. Can intercept.
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent"; pi.on("user_bash", (event, ctx) => { // Option 1: Provide custom operations (e.g., SSH) return { operations: remoteBashOps }; // Option 2: Wrap pi's built-in local bash backend const local = createLocalBashOperations(); return { operations: { exec(command, cwd, options) { return local.exec(`source ~/.profile\n${command}`, cwd, options); } } }; });
Input Events
input
Fired when user input is received, after extension commands are checked but before skill and template expansion.
pi.on("input", async (event, ctx) => { // event.text - raw input (before skill/template expansion) // event.source - "interactive" | "rpc" | "extension" // Transform: rewrite input before expansion if (event.text.startsWith("?quick ")) return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; // Handle: respond without LLM if (event.text === "ping") { ctx.ui.notify("pong", "info"); return { action: "handled" }; } return { action: "continue" }; // Default: pass through });
ExtensionContext
All handlers receive ctx: ExtensionContext. Here are the key capabilities:
ctx.ui — User Interaction
// Dialogs const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); const name = await ctx.ui.input("Name:", "placeholder"); const text = await ctx.ui.editor("Edit:", "prefilled text"); // Notifications ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" // Status in footer ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", undefined); // Clear // Widget above/below editor ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); ctx.ui.setWidget("my-widget", ["Line 1"], { placement: "belowEditor" }); // Custom footer ctx.ui.setFooter((tui, theme) => ({ render(w) { return [theme.fg("dim", "Custom")]; } })); // Working indicator during streaming ctx.ui.setWorkingMessage("Thinking deeply..."); ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Theme management const themes = ctx.ui.getAllThemes(); ctx.ui.setTheme("light"); // Custom autocomplete ctx.ui.addAutocompleteProvider((current) => ({ async getSuggestions(lines, line, col, options) { return current.getSuggestions(lines, line, col, options); }, }));
Session Management
// Read session state ctx.sessionManager.getEntries() // All entries ctx.sessionManager.getBranch() // Current branch ctx.sessionManager.getLeafId() // Current leaf entry ID ctx.sessionManager.getSessionFile() // Session file path ctx.sessionManager.getLabel(entryId) // Get label for entry // Persist extension state (does NOT participate in LLM context) pi.appendEntry("my-state", { count: 42 }); // Set session name pi.setSessionName("Refactor auth module"); // Set/clear labels on entries pi.setLabel(entryId, "checkpoint-before-refactor"); pi.setLabel(entryId, undefined); // Clear // Trigger compaction ctx.compact({ customInstructions: "Focus on recent changes", onComplete: (result) => ctx.ui.notify("Done!", "info"), }); // Get context usage const usage = ctx.getContextUsage(); // Get system prompt string const prompt = ctx.getSystemPrompt();
Control Flow
// Abort signal for nested async work const response = await fetch("https://api.example.com", { signal: ctx.signal, // Respects Esc cancellation }); // Check idle state const idle = ctx.isIdle(); const hasPending = ctx.hasPendingMessages(); // Request graceful shutdown ctx.shutdown(); // Check if UI is available (false in print/JSON mode) if (ctx.hasUI) { /* safe to use ctx.ui */ }
Command Context
Command handlers receive ExtensionCommandContext, which extends ExtensionContext with session control methods:
pi.registerCommand("my-cmd", { handler: async (args, ctx) => { // Wait for agent to finish streaming await ctx.waitForIdle(); // Create a new session const result = await ctx.newSession({ withSession: async (ctx) => { await ctx.sendUserMessage("Continue in replacement session"); }, }); // Fork from a specific entry await ctx.fork("entry-id-123"); // Navigate tree await ctx.navigateTree("entry-id-456", { summarize: true }); // Switch sessions await ctx.switchSession("/path/to/session.jsonl"); // Reload extensions await ctx.reload(); return; // Treat reload as terminal }, });
withSession receives a fresh context. Captured old pi / old ctx session-bound objects are stale after replacement and will throw. Use only the ctx passed to withSession.
ExtensionAPI Methods
pi.on() — Event Subscription
Subscribe to events. See Events above for full event reference.
pi.on("event_name", async (event, ctx) => { // handler });
pi.registerTool()
Register a custom tool callable by the LLM. Works both during extension load and after startup.
import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does", promptSnippet: "Summarize or transform text", promptGuidelines: [ "Use my_tool when the user asks to summarize text.", ], parameters: Type.Object({ action: StringEnum(["list", "add"] as const), text: Type.Optional(Type.String()), }), prepareArguments(args) { // Compatibility shim — runs before schema validation return args; }, async execute(toolCallId, params, signal, onUpdate, ctx) { // Stream progress onUpdate?({ content: [{ type: "text", text: "Working..." }] }); if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; } return { content: [{ type: "text", text: "Done" }], details: { result: "..." }, // Optional: skip follow-up LLM call terminate: true, }; }, // Optional: custom rendering renderCall(args, theme, context) { ... }, renderResult(result, options, theme, context) { ... }, });
- Use
StringEnumfrom@earendil-works/pi-aifor string enums (Google API compatibility) - Throw an error from
executeto signal failure (isError: true) - Return
terminate: trueto hint the LLM should skip follow-up calls - Use
withFileMutationQueue()for tools that mutate files (prevents race conditions)
pi.registerCommand()
Register slash commands like /mycommand. If multiple extensions register the same name, numeric suffixes are added (/review:1, /review:2).
import type { AutocompleteItem } from "@earendil-works/pi-tui"; pi.registerCommand("deploy", { description: "Deploy to an environment", getArgumentCompletions: (prefix: string) => { const envs = ["dev", "staging", "prod"]; return envs .filter(e => e.startsWith(prefix)) .map(e => ({ value: e, label: e })); }, handler: async (args, ctx) => { ctx.ui.notify(`Deploying: ${args}`, "info"); }, });
Get available commands: pi.getCommands() — includes extension commands, prompt templates, and skill commands.
pi.registerShortcut()
Register keyboard shortcuts. See keybindings.md for format details.
pi.registerShortcut("ctrl+shift+p", { description: "Toggle plan mode", handler: async (ctx) => { ctx.ui.notify("Toggled!"); }, });
pi.registerFlag()
Register CLI flags.
pi.registerFlag("plan", { description: "Start in plan mode", type: "boolean", default: false, }); if (pi.getFlag("plan")) { // Plan mode enabled }
pi.registerMessageRenderer()
Register a custom TUI renderer for messages with your customType.
import { Text } from "@earendil-works/pi-tui"; pi.registerMessageRenderer("my-extension", (message, options, theme) => { const { expanded } = options; let text = theme.fg("accent", `[${message.customType}] `); text += message.content; if (expanded && message.details) { text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); } return new Text(text, 0, 0); }); // Send messages with matching customType pi.sendMessage({ customType: "my-extension", content: "Status update", display: true, details: { ... }, });
pi.registerProvider()
Register or override a model provider dynamically. Supports OAuth for SSO flows.
// Register a new provider pi.registerProvider("my-proxy", { name: "My Proxy", baseUrl: "https://proxy.example.com", apiKey: "$PROXY_API_KEY", // env var reference api: "anthropic-messages", models: [{ id: "claude-sonnet-4-20250514", name: "Claude 4 Sonnet (proxy)", reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16384, }], }); // Override baseUrl for an existing provider pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com", }); // With OAuth pi.registerProvider("corporate-ai", { baseUrl: "https://ai.corp.com", api: "openai-responses", models: [...], oauth: { name: "Corporate AI (SSO)", async login(callbacks) { callbacks.onAuth({ url: "https://sso.corp.com/..." }); const code = await callbacks.onPrompt({ message: "Enter code:" }); return { refresh: code, access: code, expires: Date.now() + 3600000 }; }, async refreshToken(credentials) { return credentials; }, getApiKey(credentials) { return credentials.access; }, }, }); // Unregister pi.unregisterProvider("my-proxy");
Model & Thinking
// Set model const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (model) { const success = await pi.setModel(model); } // Thinking level const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" pi.setThinkingLevel("high");
Tool Management
const active = pi.getActiveTools(); const all = pi.getAllTools(); // Filter by source const builtin = all.filter(t => t.sourceInfo.source === "builtin"); const extTools = all.filter(t => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk"); // Switch active tools pi.setActiveTools(["read", "bash"]); // Read-only mode
Event Bus
Shared event bus for communication between extensions:
// Extension A pi.events.emit("my:event", { data: "hello" }); // Extension B pi.events.on("my:event", (data) => { console.log(data); // { data: "hello" } });
Utility Methods
// Send a message into the session pi.sendMessage({ customType: "my-extension", content: "Message text", display: true, }, { deliverAs: "steer", // "steer" | "followUp" | "nextTurn" triggerTurn: true, }); // Send a user message (triggers a turn) pi.sendUserMessage("What is 2+2?"); pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); // Execute a shell command const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); // result.stdout, result.stderr, result.code, result.killed // Append persistent state pi.appendEntry("my-state", { count: 42 });
Custom Tools — Deep Dive
Register tools the LLM can call via pi.registerTool(). Tools appear in the system prompt and can have custom rendering.
promptSnippet— one-line entry in the "Available tools" section. If omitted, custom tools are left out.promptGuidelines— bullets added to the Guidelines section. Each must name the tool explicitly (e.g., "Use my_tool when...")
Overriding Built-in Tools
Extensions can override built-in tools (read, bash, edit, write, grep, find, ls) by registering a tool with the same name.
// Override built-in read with logging import { createReadTool } from "@earendil-works/pi-coding-agent"; const localRead = createReadTool(cwd); pi.registerTool({ ...localRead, async execute(id, params, signal, onUpdate, ctx) { console.log(`Read called: ${params.path}`); return localRead.execute(id, params, signal, onUpdate, ctx); }, });
renderCall or renderResult, the built-in renderer is used automatically.
Remote Execution
Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.):
import { createReadTool, type ReadOperations } from "@earendil-works/pi-coding-agent"; const remoteRead = createReadTool(cwd, { operations: { readFile: (path) => sshExec(remote, `cat ${path}`), access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), } });
Operations interfaces: ReadOperations, WriteOperations, EditOperations, BashOperations, LsOperations, GrepOperations, FindOperations
Output Truncation
Tools MUST truncate their output to avoid overwhelming the LLM context. The built-in limit is 50KB (~10k tokens) and 2000 lines.
import { truncateHead, truncateTail, truncateLine, formatSize, DEFAULT_MAX_BYTES, // 50KB DEFAULT_MAX_LINES, // 2000 } from "@earendil-works/pi-coding-agent"; const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); if (truncation.truncated) { const tempFile = writeTempFile(output); result += `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines]`; }
Custom Rendering
Tools can provide renderCall and renderResult for custom TUI display.
import { Text } from "@earendil-works/pi-tui"; import { keyHint } from "@earendil-works/pi-coding-agent"; renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("my_tool ")); content += theme.fg("muted", args.action); text.setText(content); return text; }, renderResult(result, { expanded, isPartial }, theme, context) { if (isPartial) { return new Text(theme.fg("warning", "Processing..."), 0, 0); } let text = theme.fg("success", "✓ Done"); if (!expanded) { text += ` (${keyHint("app.tools.expand", "to expand")} to expand)`; } return new Text(text, 0, 0); },
State Management
Extensions with state should store it in tool result details for proper branching support:
export default function (pi: ExtensionAPI) { let items: string[] = []; // Reconstruct state from session pi.on("session_start", async (_event, ctx) => { items = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "toolResult") { if (entry.message.toolName === "my_tool") { items = entry.message.details?.items ?? []; } } } }); pi.registerTool({ name: "my_tool", async execute(toolCallId, params, signal, onUpdate, ctx) { items.push("new item"); return { content: [{ type: "text", text: "Added" }], details: { items: [...items] }, // Store for reconstruction }; }, }); }
Custom UI — Deep Dive
Dialogs
// Select from options const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // Confirm dialog const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // Text input const name = await ctx.ui.input("Name:", "placeholder"); // Multi-line editor const text = await ctx.ui.editor("Edit:", "prefilled text"); // Timed dialog with countdown const confirmed = await ctx.ui.confirm( "Timed Confirmation", "Auto-cancels in 5 seconds", { timeout: 5000 } ); // Manual dismissal with AbortSignal const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const confirmed2 = await ctx.ui.confirm("Title", "Msg", { signal: controller.signal }); clearTimeout(timeoutId);
Widgets & Status
// Status in footer ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", undefined); // Clear // Working loader during streaming ctx.ui.setWorkingMessage("Thinking deeply..."); ctx.ui.setWorkingVisible(false); // Hide built-in working row // Custom working indicator ctx.ui.setWorkingIndicator({ frames: [ ctx.ui.theme.fg("dim", "·"), ctx.ui.theme.fg("muted", "•"), ctx.ui.theme.fg("accent", "●"), ], intervalMs: 120, }); // Widget above editor ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); // Widget below editor ctx.ui.setWidget("my-widget", ["Line 1"], { placement: "belowEditor" }); // Custom footer ctx.ui.setFooter((tui, theme) => ({ render(width) { return [theme.fg("dim", "Custom footer")]; }, invalidate() {}, })); // Terminal title ctx.ui.setTitle("pi - my-project");
Custom Components
For complex UI, use ctx.ui.custom(). This temporarily replaces the editor with your component until done() is called:
import { Text, Component } from "@earendil-works/pi-tui"; const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => { const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); text.onKey = (key) => { if (key === "return") done(true); if (key === "escape") done(false); return true; }; return text; }); // Overlay mode (floating modal on top of content) const overlayResult = await ctx.ui.custom( (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, } );
Custom Editor
Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.):
import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { matchesKey } from "@earendil-works/pi-tui"; class VimEditor extends CustomEditor { private mode: "normal" | "insert" = "insert"; handleInput(data: string): void { if (matchesKey(data, "escape") && this.mode === "insert") { this.mode = "normal"; return; } if (this.mode === "normal" && data === "i") { this.mode = "insert"; return; } super.handleInput(data); } } export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings) ); }); }
Autocomplete
Stack custom autocomplete logic on top of built-in slash-command and path providers:
pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options); return { prefix: `#${match[1] ?? ""}`, items: [ { value: "#2983", label: "#2983", description: "Extension API docs" }, ], }; }, applyCompletion(lines, line, col, item, prefix) { return current.applyCompletion(lines, line, col, item, prefix); }, })); });
Theme Colors
// Foreground colors theme.fg("toolTitle", text) // Tool names theme.fg("accent", text) // Highlights theme.fg("success", text) // Success (green) theme.fg("error", text) // Errors (red) theme.fg("warning", text) // Warnings (yellow) theme.fg("muted", text) // Secondary text theme.fg("dim", text) // Tertiary text // Text styles theme.bold(text) theme.italic(text) theme.strikethrough(text) // Syntax highlighting import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent"; const highlighted = highlightCode("const x = 1;", "typescript", theme); const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
Patterns & Examples
Permission Gates
Block dangerous commands before they execute:
pi.on("tool_call", async (event, ctx) => { if (isToolCallEventType("bash", event)) { const dangerous = ["rm -rf", "sudo", "mkfs", "> /dev/"]; const match = dangerous.find(d => event.input.command.includes(d)); if (match) { const ok = await ctx.ui.confirm("⚠️ Dangerous Command", `Block \`${match}\`?`); if (!ok) return { block: true, reason: `Blocked: ${match}` }; } } });
Git Checkpointing
Stash at each turn, restore on branch:
pi.on("turn_start", async (_event, ctx) => { await pi.exec("git", ["stash", "push", "pi-checkpoint"], { cwd: ctx.cwd }); }); pi.on("turn_end", async (_event, ctx) => { await pi.exec("git", ["stash", "pop"], { cwd: ctx.cwd }); });
Custom Compaction
Provide custom summary instructions or cancel compaction:
pi.on("session_before_compact", async (event, ctx) => { const { preparation } = event; return { compaction: { summary: "Focus on code changes, ignore chit-chat", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, } }; });
Session Handoff
Cross-session operations using command context:
pi.registerCommand("handoff", { handler: async (_args, ctx) => { const kickoff = "Continue from the replacement session"; await ctx.newSession({ withSession: async (ctx) => { await ctx.sendUserMessage(kickoff); }, }); }, });
ctx passed to withSession. Captured old pi / ctx objects are stale after replacement.
Dynamic Tools
Register tools after startup and during commands:
pi.on("session_start", async (event, ctx) => { if (event.reason === "startup") { pi.registerTool({ name: "dynamic_tool", label: "Dynamic Tool", description: "Registered at startup", parameters: Type.Object({}), async execute() { return { content: [{ type: "text", text: "Dynamic!" }], details: {} }; }, }); } });
Remote & Sandbox
Delegate tool execution to remote systems (SSH, containers) or sandboxed environments:
// SSH remote execution pi.registerFlag("ssh", { description: "Use SSH remote", type: "boolean" }); pi.on("user_bash", (event, ctx) => { if (pi.getFlag("ssh")) { return { operations: createRemoteOps(getSshConfig()) }; } return { operations: createLocalBashOperations() }; });
Games
Extensions can create full games using ctx.ui.custom() with keyboard handling. Examples include Snake, Space Invaders, and even Doom rendered in an overlay.
Custom Providers
Register or override model providers with OAuth support for SSO flows:
// Custom Anthropic proxy pi.registerProvider("my-anthropic", { baseUrl: "https://proxy.example.com/v1", apiKey: "$ANTHROPIC_API_KEY", api: "anthropic-messages", models: [{ id: "claude-sonnet-4-20250514", name: "Claude 4 Sonnet", reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16384, }], });
Error Handling
| Scenario | Behavior |
|---|---|
| Extension errors | Logged, agent continues |
tool_call errors | Block the tool (fail-safe) |
Tool execute errors | Signal by throwing; caught, reported to LLM with isError: true, execution continues |
Mode Behavior
| Mode | UI Methods | Notes |
|---|---|---|
| Interactive | Full TUI | Normal operation |
RPC (--mode rpc) | JSON protocol | Host handles UI |
JSON (--mode json) | No-op | Event stream to stdout |
Print (-p) | No-op | Extensions run but can't prompt |
In non-interactive modes, check ctx.hasUI before using UI methods.
Examples Reference
All examples live in examples/extensions/. Here's the complete catalog:
| Category | Example | Description |
|---|---|---|
| Tools | hello.ts | Minimal tool registration |
question.ts | Tool with user interaction | |
questionnaire.ts | Multi-step wizard tool | |
todo.ts | Stateful tool with persistence | |
dynamic-tools.ts | Register tools after startup | |
structured-output.ts | Terminating structured-output tool | |
truncated-tool.ts | Output truncation example | |
| Commands | pirate.ts | Modify system prompt per-turn |
summarize.ts | Conversation summary command | |
handoff.ts | Cross-provider model handoff | |
qna.ts | Q&A with custom UI | |
send-user-message.ts | Inject user messages | |
reload-runtime.ts | Reload command + LLM tool handoff | |
shutdown-command.ts | Graceful shutdown command | |
| Events & Gates | permission-gate.ts | Block dangerous commands |
protected-paths.ts | Block writes to specific paths | |
confirm-destructive.ts | Confirm session changes | |
dirty-repo-guard.ts | Warn on dirty git repo | |
input-transform.ts | Transform user input | |
model-status.ts | React to model changes | |
provider-payload.ts | Inspect payloads & response headers | |
file-trigger.ts | File watcher triggers messages | |
| Compaction & Sessions | custom-compaction.ts | Custom compaction summary |
trigger-compact.ts | Trigger compaction manually | |
git-checkpoint.ts | Git stash on turns | |
auto-commit-on-exit.ts | Commit on shutdown | |
| UI Components | status-line.ts | Footer status indicator |
working-indicator.ts | Customize streaming indicator | |
github-issue-autocomplete.ts | Add #1234 issue completions | |
custom-footer.ts | Replace footer entirely | |
modal-editor.ts | Vim-style modal editor | |
overlay-test.ts | Overlay components | |
notify.ts | Simple notifications | |
timed-confirm.ts | Dialogs with timeout | |
| Complex | plan-mode/ | Full plan mode implementation |
tools.ts | Toggle tools on/off UI | |
preset.ts | Saveable presets | |
| Remote | ssh.ts | SSH remote execution |
interactive-shell.ts | Persistent shell session | |
sandbox/ | Sandboxed tool execution | |
subagent/ | Spawn sub-agents | |
| Games | snake.ts | Snake game |
space-invaders.ts | Space Invaders | |
doom-overlay/ | Doom in overlay | |
| Providers | custom-provider-anthropic/ | Custom Anthropic proxy |
custom-provider-gitlab-duo/ | GitLab Duo with OAuth | |
| Messages | message-renderer.ts | Custom message rendering |
| Events | event-bus.ts | Inter-extension events |
| Metadata | session-name.ts | Name sessions for selector |
| Metadata | bookmark.ts | Bookmark entries for /tree |
| Misc | with-deps/ | Extension with npm dependencies |