co-mono/packages/coding-agent/docs/extensions.md
Mario Zechner c6fc084534 Merge hooks and custom-tools into unified extensions system (#454)
Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
2026-01-05 01:43:35 +01:00

18 KiB

pi can create extensions. Ask it to build one for your use case.

Extensions

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.

Key capabilities:

  • Custom tools - Register tools the LLM can call via pi.registerTool()
  • Event interception - Block or modify tool calls, inject context, customize compaction
  • User interaction - Prompt users via ctx.ui (select, confirm, input, notify)
  • Custom UI components - Full TUI components with keyboard input via ctx.ui.custom()
  • Custom commands - Register commands like /mycommand via pi.registerCommand()
  • Session persistence - Store state that survives restarts via pi.appendEntry()
  • Custom rendering - Control how tool calls/results and messages appear in TUI

Example use cases:

  • Permission gates (confirm before rm -rf, sudo, etc.)
  • Git checkpointing (stash at each turn, restore on /branch)
  • Path protection (block writes to .env, node_modules/)
  • Interactive tools (questions, wizards, custom dialogs)
  • Stateful tools (todo lists, connection pools)
  • External integrations (file watchers, webhooks, CI triggers)

See examples/extensions/ and examples/hooks/ for working implementations.

Quick Start

Create ~/.pi/agent/extensions/my-extension.ts:

import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/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, onUpdate, ctx, signal) {
      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 --extension (or -e) flag:

pi -e ./my-extension.ts

Extension Locations

Extensions are auto-discovered from:

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/extension.ts"]
}

Subdirectory structure with package.json:

~/.pi/agent/extensions/
├── simple.ts                    # Direct file (auto-discovered)
└── complex-extension/
    ├── package.json             # Optional: { "pi": { "extensions": ["./src/main.ts"] } }
    ├── index.ts                 # Entry point (if no package.json)
    └── src/
        └── main.ts              # Custom entry (via package.json)

Available Imports

Package Purpose
@mariozechner/pi-coding-agent Extension types (ExtensionAPI, ExtensionContext, events)
@sinclair/typebox Schema definitions for tool parameters
@mariozechner/pi-ai AI utilities (StringEnum for Google-compatible enums)
@mariozechner/pi-tui TUI components for custom rendering

Node.js built-ins (node:fs, node:path, etc.) are also available.

Writing an Extension

An extension exports a default function that receives ExtensionAPI:

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

export default function (pi: ExtensionAPI) {
  // Subscribe to events
  pi.on("event_name", async (event, ctx) => {
    // Handle event
  });

  // Register tools, commands, shortcuts, flags
  pi.registerTool({ ... });
  pi.registerCommand("name", { ... });
  pi.registerShortcut("ctrl+x", { ... });
  pi.registerFlag("--my-flag", { ... });
}

Extensions are loaded via jiti, so TypeScript works without compilation.

Events

Lifecycle Overview

pi starts
  │
  └─► session_start
      │
      ▼
user sends prompt ─────────────────────────────────────────┐
  │                                                        │
  ├─► before_agent_start (can inject message, append to system prompt)
  ├─► agent_start                                          │
  │                                                        │
  │   ┌─── turn (repeats while LLM calls tools) ───┐       │
  │   │                                            │       │
  │   ├─► turn_start                               │       │
  │   ├─► context (can modify messages)            │       │
  │   │                                            │       │
  │   │   LLM responds, may call tools:            │       │
  │   │     ├─► tool_call (can block)              │       │
  │   │     │   tool executes                      │       │
  │   │     └─► tool_result (can modify)           │       │
  │   │                                            │       │
  │   └─► turn_end                                 │       │
  │                                                        │
  └─► agent_end                                            │
                                                           │
user sends another prompt ◄────────────────────────────────┘

/new (new session) or /resume (switch session)
  ├─► session_before_switch (can cancel)
  └─► session_switch

/branch
  ├─► session_before_branch (can cancel)
  └─► session_branch

/compact or auto-compaction
  ├─► session_before_compact (can cancel or customize)
  └─► session_compact

/tree navigation
  ├─► session_before_tree (can cancel or customize)
  └─► session_tree

exit (Ctrl+C, Ctrl+D)
  └─► session_shutdown

Session Events

session_start

Fired on initial session load.

pi.on("session_start", async (_event, ctx) => {
  ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});

session_before_switch / session_switch

Fired when starting a new session (/new) or switching sessions (/resume).

pi.on("session_before_switch", async (event, ctx) => {
  // event.reason - "new" or "resume"
  // event.targetSessionFile - session we're switching to (only for "resume")
  
  if (event.reason === "new") {
    const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
    if (!ok) return { cancel: true };
  }
});

pi.on("session_switch", async (event, ctx) => {
  // event.reason - "new" or "resume"
  // event.previousSessionFile - session we came from
});

session_before_branch / session_branch

Fired when branching via /branch.

pi.on("session_before_branch", async (event, ctx) => {
  // event.entryId - ID of the entry being branched from
  return { cancel: true }; // Cancel branch
  // OR
  return { skipConversationRestore: true }; // Branch but don't rewind messages
});

pi.on("session_branch", async (event, ctx) => {
  // event.previousSessionFile - previous session file
});

session_before_compact / session_compact

Fired on compaction. See compaction.md for details.

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, signal } = event;

  // Cancel:
  return { cancel: true };

  // Custom summary:
  return {
    compaction: {
      summary: "...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
    }
  };
});

pi.on("session_compact", async (event, ctx) => {
  // event.compactionEntry - the saved compaction
  // event.fromExtension - whether extension provided it
});

session_before_tree / session_tree

Fired on /tree navigation.

pi.on("session_before_tree", async (event, ctx) => {
  const { preparation, signal } = event;
  return { cancel: true };
  // OR provide custom summary:
  return { summary: { summary: "...", details: {} } };
});

pi.on("session_tree", async (event, ctx) => {
  // event.newLeafId, oldLeafId, summaryEntry, fromExtension
});

session_shutdown

Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).

pi.on("session_shutdown", async (_event, ctx) => {
  // Cleanup, save state, etc.
});

Agent Events

before_agent_start

Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.

pi.on("before_agent_start", async (event, ctx) => {
  // event.prompt - user's prompt text
  // event.images - attached images (if any)

  return {
    // Inject a persistent message (stored in session, sent to LLM)
    message: {
      customType: "my-extension",
      content: "Additional context for the LLM",
      display: true,
    },
    // Append to system prompt for this turn only
    systemPromptAppend: "Extra instructions for this turn...",
  };
});

agent_start / agent_end

Fired once per user prompt.

pi.on("agent_start", async (_event, ctx) => {});

pi.on("agent_end", async (event, ctx) => {
  // event.messages - messages from this prompt
});

turn_start / turn_end

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
});

context

Fired before each LLM call. Modify messages non-destructively.

pi.on("context", async (event, ctx) => {
  // event.messages - deep copy, safe to modify
  const filtered = event.messages.filter(m => !shouldPrune(m));
  return { messages: filtered };
});

Tool Events

tool_call

Fired before tool executes. Can block.

pi.on("tool_call", async (event, ctx) => {
  // event.toolName - "bash", "read", "write", "edit", etc.
  // event.toolCallId
  // event.input - tool parameters

  if (shouldBlock(event)) {
    return { block: true, reason: "Not allowed" };
  }
});

tool_result

Fired after tool executes. Can modify result.

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

pi.on("tool_result", async (event, ctx) => {
  // event.toolName, event.toolCallId, event.input
  // event.content, event.details, event.isError

  if (isBashToolResult(event)) {
    // event.details is typed as BashToolDetails
  }

  // Modify result:
  return { content: [...], details: {...}, isError: false };
});

ExtensionContext

Every handler receives ctx: ExtensionContext:

ctx.ui

UI methods for user interaction:

// 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");

// Notification
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 editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
ctx.ui.setWidget("my-widget", undefined);  // Clear

// Terminal title
ctx.ui.setTitle("pi - my-project");

// Editor text
ctx.ui.setEditorText("Prefill text");
const current = ctx.ui.getEditorText();

Custom components:

const result = await ctx.ui.custom((tui, theme, done) => {
  const component = new MyComponent();
  component.onComplete = (value) => done(value);
  return component;
});

ctx.hasUI

false in print mode (-p), JSON mode, and RPC mode. Always check before using ctx.ui.

ctx.cwd

Current working directory.

ctx.sessionManager

Read-only access to session state:

ctx.sessionManager.getEntries()       // All entries
ctx.sessionManager.getBranch()        // Current branch
ctx.sessionManager.getLeafId()        // Current leaf entry ID

ctx.modelRegistry / ctx.model

Access to models and API keys.

ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()

Control flow helpers.

ExtensionCommandContext

Slash command handlers receive ExtensionCommandContext, which extends ExtensionContext with:

await ctx.waitForIdle();              // Wait for agent to finish
await ctx.newSession({ ... });        // Create new session
await ctx.branch(entryId);            // Branch from entry
await ctx.navigateTree(targetId);     // Navigate tree

ExtensionAPI Methods

pi.on(event, handler)

Subscribe to events. See Events.

pi.registerTool(definition)

Register a custom tool callable by the LLM:

import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";

pi.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "What this tool does",
  parameters: Type.Object({
    action: StringEnum(["list", "add"] as const),
    text: Type.Optional(Type.String()),
  }),

  async execute(toolCallId, params, onUpdate, ctx, signal) {
    // Stream progress
    onUpdate?.({
      content: [{ type: "text", text: "Working..." }],
      details: { progress: 50 },
    });

    return {
      content: [{ type: "text", text: "Done" }],
      details: { result: "..." },
    };
  },

  // Optional: Custom rendering
  renderCall(args, theme) {
    return new Text(theme.fg("toolTitle", "my_tool ") + args.action, 0, 0);
  },

  renderResult(result, { expanded, isPartial }, theme) {
    if (isPartial) return new Text("Working...", 0, 0);
    return new Text(theme.fg("success", "✓ Done"), 0, 0);
  },
});

Important: Use StringEnum from @mariozechner/pi-ai for string enums (Google API compatible).

pi.sendMessage(message, options?)

Inject a message into the session:

pi.sendMessage({
  customType: "my-extension",
  content: "Message text",
  display: true,
  details: { ... },
}, {
  triggerTurn: true,          // Trigger LLM response if idle
  deliverAs: "steer",         // "steer", "followUp", or "nextTurn"
});

pi.appendEntry(customType, data?)

Persist extension state (does NOT participate in LLM context):

pi.appendEntry("my-state", { count: 42 });

// Restore on reload
pi.on("session_start", async (_event, ctx) => {
  for (const entry of ctx.sessionManager.getEntries()) {
    if (entry.type === "custom" && entry.customType === "my-state") {
      // Reconstruct from entry.data
    }
  }
});

pi.registerCommand(name, options)

Register a command:

pi.registerCommand("stats", {
  description: "Show session statistics",
  handler: async (args, ctx) => {
    const count = ctx.sessionManager.getEntries().length;
    ctx.ui.notify(`${count} entries`, "info");
  }
});

pi.registerMessageRenderer(customType, renderer)

Register a custom TUI renderer for messages with your customType:

pi.registerMessageRenderer("my-extension", (message, options, theme) => {
  return new Text(theme.fg("accent", `[INFO] `) + message.content, 0, 0);
});

pi.registerShortcut(shortcut, options)

Register a keyboard shortcut:

pi.registerShortcut("ctrl+shift+p", {
  description: "Toggle plan mode",
  handler: async (ctx) => {
    ctx.ui.notify("Toggled!");
  },
});

pi.registerFlag(name, options)

Register a CLI flag:

pi.registerFlag("--plan", {
  description: "Start in plan mode",
  type: "boolean",
  default: false,
});

// Check value
if (pi.getFlag("--plan")) {
  // Plan mode enabled
}

pi.exec(command, args, options?)

Execute a shell command:

const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
// result.stdout, result.stderr, result.code, result.killed

pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)

Manage active tools:

const active = pi.getActiveTools();  // ["read", "bash", "edit", "write"]
pi.setActiveTools(["read", "bash"]); // Switch to read-only

pi.events

Shared event bus for communication between extensions:

pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });

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, onUpdate, ctx, signal) {
      items.push("new item");
      return {
        content: [{ type: "text", text: "Added" }],
        details: { items: [...items] },  // Store for reconstruction
      };
    },
  });
}

Error Handling

  • Extension errors are logged, agent continues
  • tool_call errors block the tool (fail-safe)
  • Tool execute errors are reported to the LLM with isError: true

Mode Behavior

Mode UI Methods Notes
Interactive Full TUI Normal operation
RPC JSON protocol Host handles UI
Print (-p) No-op Extensions run but can't prompt

In print mode, check ctx.hasUI before using UI methods.