Agent Skills: Pi Extension Builder

Create pi extensions (TypeScript modules) with tools, commands, events, or UI components. Use when user says "create pi extension", "build extension for pi", "extend pi", or wants to customize pi agent behavior.

UncategorizedID: l-lin/dotfiles/pi-extension-builder

Install this agent skill to your local

pnpm dlx add-skill https://github.com/l-lin/dotfiles/tree/HEAD/home-manager/modules/share/ai/pi/.pi/agent/extensions/.ai/skills/pi-extension-builder

Skill Files

Browse the full folder contents for pi-extension-builder.

Download Skill

Loading file tree…

home-manager/modules/share/ai/pi/.pi/agent/extensions/.ai/skills/pi-extension-builder/SKILL.md

Skill Metadata

Name
pi-extension-builder
Description
Use when user says "create pi extension", "build extension for pi", "extend pi", or wants to customize pi agent behavior with tools, commands, events, or UI components.

Pi Extension Builder

Build TypeScript extensions for the pi coding agent with proper structure, API usage, and testing.

Step 1: Gather Requirements

Ask the user using ask-user-question:

Question 1: Extension name and purpose

  • Extension name (kebab-case, e.g., github-pr-helper)
  • What should this extension do?

Question 2: Extension type

  • Custom tool (LLM-callable function with parameters)
  • Command (/my-command for user to invoke)
  • Event handler (intercept tool calls, session events)
  • UI component (widget, status line, footer)
  • Combination of the above

Question 3: Capabilities (based on type selected)

For Custom Tools: parameters needed, user interaction (ctx.ui.select/input/confirm), custom TUI rendering, state persistence?

For Commands: arguments, tab completions?

For Events: which events? (session_start, tool_call, message_sent)

For UI: widget placement? (top-right, bottom-left, etc.)

Step 2: Create Extension File

  • Project-specific: <project>/.pi/extensions/<name>.ts
  • Global: ~/.pi/agent/extensions/<name>.ts

Step 3: Generate Code

All extensions share this base:

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";

export default function (pi: ExtensionAPI) {
  // Register tools, commands, or event handlers
}

Template 1: Minimal Tool (Most Common)

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";

const MyToolParams = Type.Object({
  input: Type.String({ description: "Description of the input" }),
});

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "my_tool",          // snake_case
    label: "MyTool",          // display name
    description: "What this tool does for the LLM",
    parameters: MyToolParams,

    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
      return {
        content: [{ type: "text", text: `Processed: ${params.input}` }],
      };
    },
  });
}

Template 2: Interactive Tool

Always guard with ctx.hasUI before calling any ctx.ui.* method:

async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
  if (!ctx.hasUI) {
    return { content: [{ type: "text", text: "UI not available" }] };
  }

  const choice = await ctx.ui.select("Choose an option", ["Option 1", "Option 2"]);
  const userInput = await ctx.ui.input("Enter something");
  const confirmed = await ctx.ui.confirm("Proceed?");

  return { content: [{ type: "text", text: `Selected: ${choice}` }] };
}

Template 3: Custom Rendering

import { Text } from "@mariozechner/pi-tui"; // required import
import { keyHint } from "@mariozechner/pi-coding-agent"; // for keybinding hints

pi.registerTool({
  name: "my_tool",
  // ... other fields ...

  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
    const result = { status: "success", items: ["item1", "item2"], data: params.input };
    return {
      content: [{ type: "text", text: JSON.stringify(result) }],
      details: result, // passed to renderResult
    };
  },

  renderCall(args, theme) {
    const text = theme.fg("toolTitle", theme.bold("MyTool ")) + theme.fg("muted", args.input);
    return new Text(text, 0, 0);
  },

  // expanded = false → collapsed (minimal, 1–2 lines)
  // expanded = true  → user pressed Ctrl+O to see full detail
  renderResult(result, { expanded, isPartial }, theme) {
    // Streaming: show progress indicator
    if (isPartial) {
      return new Text(theme.fg("warning", "⟳ Processing..."), 0, 0);
    }

    // Errors: always show clearly
    if (result.details?.status === "error") {
      return new Text(theme.fg("error", `✗ ${result.details.error}`), 0, 0);
    }

    // Collapsed (default): single summary line + expand hint
    if (!expanded) {
      const hint = keyHint("expandTools", "to expand");
      return new Text(theme.fg("success", "✓ Done") + theme.fg("muted", ` (${hint})`), 0, 0);
    }

    // Expanded (Ctrl+O): full detail
    let text = theme.fg("success", "✓ Done");
    if (result.details?.items) {
      for (const item of result.details.items) {
        text += "\n  " + theme.fg("dim", item);
      }
    }
    return new Text(text, 0, 0);
  },
});

Template 4: Command

pi.registerCommand({
  name: "mycommand",
  description: "What this command does",

  async execute(args, ctx) {
    ctx.ui.notify(`Executed with: ${args.join(" ")}`, "info");
  },

  complete(partial) {
    return ["option1", "option2"].filter((o) => o.startsWith(partial));
  },
});

Template 5: Event Handler

import { isToolCallEventType } from "@mariozechner/pi-coding-agent";

pi.on("tool_call", async (event, ctx) => {
  if (isToolCallEventType("bash", event)) {
    if (event.input.command.includes("rm -rf")) {
      ctx.ui.notify("🛑 Dangerous command blocked", "warning");
      return { block: true, reason: "Safety check failed" };
    }
  }
  return undefined; // allow
});

pi.on("session_start", (_event, ctx) => {
  ctx.ui.notify("Extension loaded", "info");
});

Template 6: Widget

pi.on("session_start", (_event, ctx) => {
  ctx.ui.setWidget({
    content: "📊 Status: Active",
    placement: "top-right",
  });
});

Step 4: Test Extension

REQUIRED SUB-SKILL: Use tmux for running pi interactively.

# Launch pi with extension
pi --models "github-copilot/gpt-4o" -e ./path/to/extension.ts

# Hot reload after changes
/reload

Verify:

  1. Extension loads without errors
  2. Tool/command appears in help
  3. Functionality works as expected

Key APIs

| API | Purpose | | ----------------------------- | ------------------------------------------ | | pi.registerTool() | LLM-callable tools | | pi.registerCommand() | User /commands | | pi.on(event, handler) | Lifecycle hooks | | ctx.ui.select/input/confirm | User prompts | | ctx.ui.setWidget() | Status display | | ctx.ui.notify() | Notifications | | pi.sendMessage() | Inject LLM messages | | pi.appendEntry() | Persist state | | keyHint(action, desc) | Keybinding hint in renderResult text | | expanded (renderResult opt) | false = collapsed (minimal), true = full detail (Ctrl+O) | | isPartial (renderResult opt)| true = tool still streaming, show progress |

See ./reference.md for complete API documentation.

Common Mistakes

  • No ctx.hasUI guard — always check if (!ctx.hasUI) before any ctx.ui.* call
  • Tool name in kebab-case — tool names must be snake_case (my_tool, not my-tool)
  • Missing Text import for custom rendering — add import { Text } from "@mariozechner/pi-tui"
  • Missing isToolCallEventType import — add import { isToolCallEventType } from "@mariozechner/pi-coding-agent"
  • Ignoring expanded in renderResult — always branch on expanded: collapsed = minimal 1-line summary, expanded = full detail
  • Ignoring isPartial in renderResult — show a progress indicator when isPartial is true (streaming in progress)
  • Missing keybind hint in collapsed view — use keyHint("expandTools", "to expand") so users know Ctrl+O works
  • Unconditional { block: true } in event handler — always return undefined on the allow path
  • Missing entry point — every extension must have export default function(pi: ExtensionAPI)

Output

After creating the extension:

  • Show file path
  • Provide test command
  • List verification steps