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:

  1. Event Subscription: pi.on("event_name", handler) — covers the full lifecycle from session_start to tool_call.
  2. Tool Registration: pi.registerTool({ name, description, parameters, execute }) — lets the LLM call your custom functions.
  3. Command Registration: pi.registerCommand("name", { handler }) — adds /name slash 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-summary quickly 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_start or 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 onUpdate callbacks 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:

  1. Read the official Extensions documentation
  2. Check out the examples/extensions/ samples
  3. Find inspiration at pi.dev/packages, or publish your first extension