Why Write Extensions for Pi Agent?
If you have used Pi Coding Agent, you know it is a "small but beautiful" terminal AI coding assistant — the core does one thing well: wiring LLM capabilities into your terminal workflow. But what truly makes it yours is its extension system.
Pi's extensions are TypeScript modules that subscribe to lifecycle events, register custom tools callable by the LLM, add slash commands, and even render custom TUI components. The community already has projects like pi-hosts (23 pts) and Parallel Pi agents (8 pts), but in-depth Chinese technical content remains scarce.
If you have not installed Pi yet, start with this primer first: 2026 Top 5 Open-Source AI Coding Agents Compared, which covers Pi's positioning. This article focuses entirely on extension development and walks you through building four real extensions.
Pi Agent Architecture
Core Components
Pi's architecture can be summarized in one sentence: minimal kernel + extensible boundaries.
┌─────────────────────────────────────────────┐
│ Pi Core Kernel │
│ ├─ LLM Provider Layer (Anthropic/OpenAI/…) │
│ ├─ Session Management (compress, branch) │
│ ├─ Built-in Tools (bash, read, write, …) │
│ └─ TUI Rendering (Ink/React) │
└─────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
Extensions Skills Packages
(TypeScript) (Markdown) (npm/git)
- Extensions: TypeScript modules loaded at runtime — intercept events, register tools, add commands.
- Skills: Markdown files loaded on demand into context — define workflows and prompts.
- Packages: Extension collections distributed via npm or git.
Plugin System Internals
Pi loads extensions via jiti, running TypeScript directly without compilation. An extension's entry point is a default-exported factory function that receives an ExtensionAPI instance:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Subscribe to events, register tools, add commands here
}
Extensions are auto-discovered from two locations:
| Location | Scope |
|---|---|
~/.pi/agent/extensions/*.ts |
Global (all projects) |
.pi/extensions/*.ts |
Project-local (requires project trust) |
API Reference
ExtensionAPI provides three categories of capabilities:
- Event Subscription:
pi.on("event_name", handler)— covers the full lifecycle fromsession_starttotool_call. - Tool Registration:
pi.registerTool({ name, description, parameters, execute })— lets the LLM call your custom functions. - Command Registration:
pi.registerCommand("name", { handler })— adds/nameslash commands.
Setting Up the Development Environment
Prerequisites
- Node.js 18+ (20 LTS recommended)
- npm or pnpm
- Pi Coding Agent installed:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
Creating an Extension Project
# Create global extension directory
mkdir -p ~/.pi/agent/extensions/my-first-ext
cd ~/.pi/agent/extensions/my-first-ext
# Initialize package.json
npm init -y
# Install type definitions
npm install @earendil-works/pi-coding-agent typebox
# Create entry file
touch index.ts
Directory Structure
my-first-ext/
├── package.json
├── node_modules/
└── src/
└── index.ts # Entry point
package.json must declare the extension entry:
{
"name": "my-first-ext",
"dependencies": {
"@earendil-works/pi-coding-agent": "^1.0.0",
"typebox": "^0.34.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}
Your First Extension: Hello World
Basic Version: Notification + Command
// src/index.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Show notification when session starts
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Hello World extension loaded!", "info");
});
// Register /hello command
pi.registerCommand("hello", {
description: "Say hello to Pi",
handler: async (args, ctx) => {
const name = args?.trim() || "World";
ctx.ui.notify(`Hello, ${name}! 👋`, "info");
},
});
}
Testing
# Option 1: Auto-discovery (placed in ~/.pi/agent/extensions/)
pi
# Option 2: Explicit path (quick test)
pi -e ./src/index.ts
After entering Pi, type /hello Kevin — you should see the notification: Hello, Kevin! 👋.
Advanced: Intercepting Dangerous Commands
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" };
}
});
This extension demonstrates the interception power of tool_call events — you can perform permission checks before the LLM executes a tool.
Real Project 1: Custom Code Template Generator
Requirements
Your team has standard code templates (React components, Express routes, Python scripts). Instead of copy-pasting every time, write an extension that lets the LLM call a generate_template tool to auto-generate files.
Implementation
// src/template-generator.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";
const TEMPLATES: Record<string, string> = {
"react-component": `import React from 'react';
interface {{Name}}Props {
title: string;
}
export const {{Name}}: React.FC<{{Name}}Props> = ({ title }) => {
return <div className="{{name}}">{title}</div>;
};
`,
"express-route": `import { Router, Request, Response } from 'express';
const router = Router();
router.get('/', (req: Request, res: Response) => {
res.json({ message: '{{Name}} endpoint' });
});
export default router;
`,
"python-script": `#!/usr/bin/env python3
"""{{Name}} module."""
import argparse
import sys
def main(args: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="{{Name}}")
parser.add_argument("--name", default="World")
parsed = parser.parse_args(args)
print(f"Hello, {parsed.name}!")
return 0
if __name__ == "__main__":
raise SystemExit(main())
`,
};
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "generate_template",
label: "Template Generator",
description: "Generate a code file from a template. Available: react-component, express-route, python-script",
parameters: Type.Object({
template: Type.String({ description: "Template name" }),
name: Type.String({ description: "Component/module name (PascalCase)" }),
outputPath: Type.String({ description: "Output file path" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const tpl = TEMPLATES[params.template];
if (!tpl) {
return {
content: [{ type: "text", text: `❌ Unknown template: ${params.template}` }],
details: {},
};
}
const pascalName = params.name.replace(/(^|[-_])(\w)/g, (_, __, c) => c.toUpperCase());
const kebabName = pascalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
const code = tpl
.replace(/\{\{Name\}\}/g, pascalName)
.replace(/\{\{name\}\}/g, kebabName);
const outPath = path.resolve(params.outputPath);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, code, "utf-8");
return {
content: [{ type: "text", text: `✅ Generated ${outPath}\n\n${code}` }],
details: { path: outPath, template: params.template },
};
},
});
}
Usage
In Pi, simply say:
Generate a UserProfile component using the react-component template, save to src/components/UserProfile.tsx
Pi calls the generate_template tool, performs variable substitution, and writes the file.
Real Project 2: Git Workflow Automation
Requirements
Auto-run lint before every commit, generate commit messages, and notify the team after push. We write an extension that intercepts the bash tool and injects pre-checks when git commit is detected.
Implementation
// src/git-workflow.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execSync } from "node:child_process";
export default function (pi: ExtensionAPI) {
// Intercept git commit, run pre-lint
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd: string = event.input.command || "";
if (cmd.startsWith("git commit")) {
ctx.ui.setStatus("git-workflow", "🔍 Running pre-commit checks...");
try {
execSync("npm run lint --if-present", { stdio: "pipe" });
execSync("npm run test --if-present", { stdio: "pipe" });
ctx.ui.setStatus("git-workflow", "✅ Checks passed, continuing commit");
} catch (err) {
ctx.ui.notify("❌ Lint/Test failed, commit blocked", "error");
return { block: true, reason: "pre-commit checks failed" };
}
}
// Intercept git push, send notification
if (cmd.startsWith("git push")) {
ctx.ui.setStatus("git-workflow", "📤 Pushing...");
// Call webhook here to notify the team
}
});
// Register /git-summary command
pi.registerCommand("git-summary", {
description: "Show commit summary for current branch",
handler: async (_args, ctx) => {
try {
const log = execSync(
"git log --oneline -10 --format='%h %s (%an, %ar)'",
{ encoding: "utf-8" }
);
ctx.ui.notify(`Last 10 commits:\n${log}`, "info");
} catch {
ctx.ui.notify("❌ Cannot read git history", "error");
}
},
});
}
Result
- When Pi tries to run
git commit, the extension auto-runs lint and tests, blocking the commit on failure. /git-summaryquickly shows recent commits.
Real Project 3: Code Quality Check Integration
Requirements
Let Pi automatically invoke external quality tools (like eslint, pylint, semgrep) when writing code, with fix suggestions in the results.
Implementation
// src/quality-checker.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { execSync } from "node:child_process";
type Linter = {
cmd: string;
pattern: RegExp;
};
const LINTERS: Linter[] = [
{ cmd: "npx eslint --format json", pattern: /\.(ts|tsx|js|jsx)$/ },
{ cmd: "pylint --output-format=json", pattern: /\.py$/ },
{ cmd: "semgrep --json", pattern: /.*/ },
];
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "check_code_quality",
label: "Code Quality Check",
description: "Run lint/static analysis on a file, returning issues and fix suggestions",
parameters: Type.Object({
filePath: Type.String({ description: "File path to check" }),
linter: Type.Optional(
Type.String({ description: "Specify linter (eslint/pylint/semgrep), auto-detect if empty" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const file = params.filePath;
let linter = LINTERS.find((l) => l.pattern.test(file));
if (params.linter) {
linter = LINTERS.find((l) => l.cmd.startsWith(params.linter!));
}
if (!linter) {
return {
content: [{ type: "text", text: "⚠️ No matching linter found" }],
details: {},
};
}
ctx.ui.setStatus("quality", `🔍 Running ${linter.cmd.split(" ")[0]}...`);
try {
const output = execSync(`${linter.cmd} ${file}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return {
content: [{ type: "text", text: `✅ No issues\n${output}` }],
details: { file, linter: linter.cmd },
};
} catch (err: any) {
const output = err.stdout || err.stderr || "";
return {
content: [{ type: "text", text: `⚠️ Issues found:\n${output.slice(0, 3000)}` }],
details: { file, linter: linter.cmd, hasIssues: true },
};
}
},
});
}
Usage
Check the code quality of src/auth/login.ts
Pi calls check_code_quality, returns lint results, then auto-generates fix suggestions.
Real Project 4: Custom Search Tool Integration
Requirements
Let Pi search your team's internal knowledge base (Notion, Confluence, or local Markdown files). Here we use a local Markdown knowledge base as an example.
Implementation
// src/knowledge-search.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";
const KB_ROOT = process.env.KB_ROOT || "~/knowledge-base";
interface SearchResult {
file: string;
line: number;
context: string;
}
function searchMarkdown(query: string, root: string): SearchResult[] {
const results: SearchResult[] = [];
const terms = query.toLowerCase().split(/\s+/);
function walk(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.name.endsWith(".md")) {
const content = fs.readFileSync(full, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const lower = lines[i].toLowerCase();
if (terms.every((t) => lower.includes(t))) {
results.push({
file: path.relative(root, full),
line: i + 1,
context: lines.slice(Math.max(0, i - 1), i + 2).join("\n"),
});
}
}
}
}
}
walk(path.resolve(root));
return results.slice(0, 10);
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "search_knowledge_base",
label: "Knowledge Base Search",
description: "Search local Markdown knowledge base for relevant content",
parameters: Type.Object({
query: Type.String({ description: "Search keywords" }),
kbRoot: Type.Optional(Type.String({ description: "KB root directory, default ~/knowledge-base" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const root = params.kbRoot || KB_ROOT;
const resolved = path.resolve(root.replace("~/", process.env.HOME + "/"));
if (!fs.existsSync(resolved)) {
return {
content: [{ type: "text", text: `❌ KB directory not found: ${resolved}` }],
details: {},
};
}
const results = searchMarkdown(params.query, resolved);
if (results.length === 0) {
return {
content: [{ type: "text", text: "🔍 No matches found" }],
details: { query: params.query },
};
}
const formatted = results
.map((r) => `📄 ${r.file}:${r.line}\n${r.context}`)
.join("\n\n");
return {
content: [{ type: "text", text: `Found ${results.length} matches:\n\n${formatted}` }],
details: { query: params.query, count: results.length },
};
},
});
}
Usage
Set the environment variable KB_ROOT=~/my-docs, then in Pi:
Search the knowledge base for API authentication content
Pi calls search_knowledge_base and returns matching file snippets.
Advanced Topics
Multi-Agent Collaboration
Pi supports multi-agent patterns through extensions. The core idea: a main Agent receives a task, splits it, and delegates to sub-agents (by spawning new pi processes via the bash tool, sharing context through --resume or session files).
pi.registerTool({
name: "delegate_to_specialist",
label: "Delegate to Specialist",
description: "Delegate a subtask to a specialized Agent",
parameters: Type.Object({
task: Type.String({ description: "Subtask description" }),
specialist: Type.String({ description: "Specialist type: frontend/backend/test" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const sessionFile = `/tmp/pi-${params.specialist}-${Date.now()}.json`;
ctx.ui.setStatus("delegate", `🤖 Delegating to ${params.specialist}...`);
try {
const output = execSync(
`pi --session ${sessionFile} --print "${params.task}"`,
{ encoding: "utf-8", timeout: 120_000 }
);
return {
content: [{ type: "text", text: output }],
details: { specialist: params.specialist, session: sessionFile },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `❌ Delegation failed: ${err.message}` }],
details: {},
};
}
},
});
Performance Optimization
- Lazy Loading: Do not start background processes in the factory function — defer to
session_startor the first tool call. - Cache Results: For frequently called tools (like lint), use a Map to cache file hashes and results, avoiding redundant execution.
- Streaming Updates: Use
onUpdatecallbacks for real-time progress feedback, preventing UI freezes.
// Caching example
const cache = new Map<string, string>();
async execute(_toolCallId, params) {
const hash = crypto.createHash("md5")
.update(fs.readFileSync(params.filePath)).digest("hex");
const key = `${params.filePath}:${hash}`;
if (cache.has(key))
return { content: [{ type: "text", text: cache.get(key)! }], details: { cached: true } };
// ... run check ...
cache.set(key, result);
return { content: [{ type: "text", text: result }], details: {} };
}
Publishing and Sharing Extensions
Pi extensions can be distributed as pi packages via npm or git repositories:
// package.json
{
"name": "pi-ext-my-tools",
"version": "1.0.0",
"pi": {
"extensions": ["./src/index.ts"]
}
}
Users install with:
# From npm
pi install npm:pi-ext-my-tools@1.0.0
# From git
pi install git:github.com/user/repo@v1
You can also publish to the official package directory at pi.dev/packages.
Community Extension Highlights
| Extension | Description | Link |
|---|---|---|
| pi-hosts | Manage /etc/hosts for quick environment switching | GitHub |
| Parallel Pi agents | Multi-agent parallel task execution | GitHub |
| pi-extensions (narumiruna) | Automation, planning, browser control, Git workflow collection | GitHub |
| pi-extensions-skill (Dwsy) | Progressive extension development learning guide | GitHub |
| pi-extension-builder (LobeHub) | Extension scaffolding generator | LobeHub |
FAQ
What is the difference between Pi Agent Extensions and Skills?
Extensions are TypeScript modules loaded at runtime — they intercept events, register tools, add commands, and have full system access. Skills are Markdown files loaded on demand into the LLM context — they define workflows and prompts but cannot execute code directly. In short: Extensions are code-level, Skills are prompt-level.
Do I need to know TypeScript to develop Pi extensions?
You need basic TypeScript skills. Pi uses jiti to load .ts files directly with no compilation step. If you only know JavaScript, you can still write extensions — just use the .js extension instead; type definitions are optional.
How do I debug an extension?
Launch with pi -e ./my-ext.ts, add console.log in your code — output appears at the bottom of Pi's TUI. You can also use ctx.ui.notify() to pop up notifications. For complex logic, write unit tests and run them directly with Node.js.
Do extensions affect Pi's startup speed?
Synchronously loaded extensions block startup. Defer expensive operations (network requests, file scans) to the session_start event or the first tool call. Keep the factory function lightweight — only pi.on() and pi.registerTool() registrations.
Can I call external APIs from an extension?
Yes. Extensions run in a Node.js environment — you can use fetch, http, or any npm package. Remember to declare dependencies in your package.json's dependencies field.
Summary
Pi Agent's extension system gives developers the full power to reshape their AI assistant. From intercepting dangerous commands to registering custom tools, from Git automation to knowledge base search — the TypeScript + event-driven combination makes extensions both flexible and controllable.
The four real projects in this article cover the most common scenarios: template generation, Git workflows, code quality, and knowledge search. You can use them directly or adapt them as templates for your own extensions.
Next steps:
- Read the official Extensions documentation
- Check out the examples/extensions/ samples
- Find inspiration at pi.dev/packages, or publish your first extension