Pi Extensions Guide

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.

🔧
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 slash 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
🌐
Custom Providers
Register or override model providers with OAuth support

Quick Start

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

TypeScript
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

⚠️ Security
Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.
LocationScope
~/.pi/agent/extensions/*.tsGlobal (all projects)
~/.pi/agent/extensions/*/index.tsGlobal (subdirectory)
.pi/extensions/*.tsProject-local
.pi/extensions/*/index.tsProject-local (subdirectory)

Additional paths via settings.json:

JSON
{
  "extensions": [
    "/path/to/local/extension.ts",
    "/path/to/local/extension/dir"
  ]
}

Available Imports

PackagePurpose
@earendil-works/pi-coding-agentExtension types (ExtensionAPI, ExtensionContext, events)
typeboxSchema definitions for tool parameters
@earendil-works/pi-aiAI utilities (StringEnum for Google-compatible enums)
@earendil-works/pi-tuiTUI 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:

File tree
~/.pi/agent/extensions/
└── my-extension.ts

For multi-file extensions:

File tree
~/.pi/agent/extensions/
└── my-extension/
    ├── index.ts        # Entry point
    ├── tools.ts        # Helper module
    └── utils.ts        # Helper module

For extensions that need npm packages:

package.json
{
  "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

pi starts │ ├─► session_start { reason: "startup" } └─► resources_discover { reason: "startup" } │ ▼ user sends prompt ─────────────────────────────────────────┐ │ │ ├─► (extension commands checked first) │ ├─► input (can intercept, transform, or handle) │ ├─► (skill/template expansion if not handled) │ ├─► before_agent_start (can inject message, modify system prompt) ├─► agent_start │ ├─► message_start / message_update / message_end │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ │ ├─► context (can modify messages) │ │ │ ├─► before_provider_request │ │ │ ├─► after_provider_response │ │ │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_execution_start │ │ │ │ ├─► tool_call (can block) │ │ │ │ ├─► tool_execution_update │ │ │ │ ├─► tool_result (can modify) │ │ │ │ └─► tool_execution_end │ │ │ │ │ │ │ └─► turn_end │ │ │ │ └─► agent_end │ │ /new or /resume ├─► session_before_switch (can cancel) ├─► session_shutdown ├─► session_start { reason: "new" | "resume" } └─► resources_discover { reason: "startup" } /compact or auto-compaction ├─► session_before_compact (can cancel or customize) └─► session_compact exit (Ctrl+C, Ctrl+D) └─► session_shutdown

Resource Events

resources_discover

Fired after session_start so extensions can contribute additional skill, prompt, and theme paths.

TypeScript
pi.on("resources_discover", async (event, _ctx) => {
  return {
    skillPaths: ["/path/to/skills"],
    promptPaths: ["/path/to/prompts"],
    themePaths: ["/path/to/themes"],
  };
});

Session Events

session_start

Fired when a session is started, loaded, or reloaded.

TypeScript
pi.on("session_start", async (event, ctx) => {
  // event.reason - "startup" | "reload" | "new" | "resume" | "fork"
  ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
session_before_switch

Fired before starting a new session (/new) or switching sessions (/resume). Return { cancel: true } to block.

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

Fired when forking via /fork or cloning via /clone. Return { cancel: true } to block.

TypeScript
pi.on("session_before_fork", async (event, ctx) => {
  // event.entryId, event.position ("before" | "at")
  return { cancel: true }; // Cancel fork/clone
});
session_before_compact / session_compact

Fired on compaction. You can cancel, or provide a custom summary.

TypeScript
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,
    }
  };
});
session_shutdown

Fired before an extension runtime is torn down. Use for cleanup and state saving.

TypeScript
pi.on("session_shutdown", async (event, ctx) => {
  // event.reason - "quit" | "reload" | "new" | "resume" | "fork"
  // Cleanup, save state, etc.
});

Agent Events

before_agent_start

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

TypeScript
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...",
  };
});
agent_start / agent_end

Fired once per user prompt.

TypeScript
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).

TypeScript
pi.on("turn_start", async (event, ctx) => {
  // event.turnIndex, event.timestamp
});

pi.on("turn_end", async (event, ctx) => {
  // event.turnIndex, event.message, event.toolResults
});
message_start / message_update / message_end

Fired for message lifecycle updates. message_update fires for assistant streaming updates. message_end handlers can return { message } to replace the finalized message.

TypeScript
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 } },
    },
  };
});
context

Fired before each LLM call. Modify messages non-destructively. event.messages is a deep copy, safe to modify.

TypeScript
pi.on("context", async (event, ctx) => {
  const filtered = event.messages.filter(m => !shouldPrune(m));
  return { messages: filtered };
});
before_provider_request / after_provider_response

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.

TypeScript
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

model_select

Fired when the model changes via /model command, model cycling (Ctrl+P), or session restore.

TypeScript
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");
});
thinking_level_select

Fired when the thinking level changes. Notification-only; handler return values are ignored.

TypeScript
pi.on("thinking_level_select", async (event, ctx) => {
  ctx.ui.setStatus("thinking", `thinking: ${event.level}`);
});

Tool Events

tool_call

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.

TypeScript
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}`);
  }
});
tool_result

Fired after tool execution finishes. Can modify result. Handlers chain like middleware — each handler sees the latest result.

TypeScript
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.

TypeScript
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.

TypeScript
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

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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:

TypeScript
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
  },
});
⚠️ Session Replacement Footguns
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.

TypeScript
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.

TypeScript
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) { ... },
});
💡 Key Notes
  • Use StringEnum from @earendil-works/pi-ai for string enums (Google API compatibility)
  • Throw an error from execute to signal failure (isError: true)
  • Return terminate: true to 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).

TypeScript
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.

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

pi.registerFlag()

Register CLI flags.

TypeScript
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.

TypeScript
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.

TypeScript
// 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

TypeScript
// 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

TypeScript
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:

TypeScript
// Extension A
pi.events.emit("my:event", { data: "hello" });

// Extension B
pi.events.on("my:event", (data) => {
  console.log(data);  // { data: "hello" }
});

Utility Methods

TypeScript
// 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 & promptGuidelines
  • 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.

TypeScript
// 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);
  },
});
💡 Rendering Inheritance
Built-in renderer inheritance is resolved per slot. If your override omits 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.):

TypeScript
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.

TypeScript
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.

TypeScript
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:

TypeScript
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

TypeScript
// 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

TypeScript
// 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:

TypeScript
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.):

TypeScript
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:

TypeScript
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

TypeScript
// 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
pi.registerCommand("handoff", {
  handler: async (_args, ctx) => {
    const kickoff = "Continue from the replacement session";
    await ctx.newSession({
      withSession: async (ctx) => {
        await ctx.sendUserMessage(kickoff);
      },
    });
  },
});
⚠️ Safe Pattern
Only use the ctx passed to withSession. Captured old pi / ctx objects are stale after replacement.

Dynamic Tools

Register tools after startup and during commands:

TypeScript
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:

TypeScript
// 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.

🐍
Snake
Classic snake game with keyboard controls
👾
Space Invaders
Full space invaders clone
💀
Doom Overlay
Doom rendered as a floating overlay component

Custom Providers

Register or override model providers with OAuth support for SSO flows:

TypeScript
// 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

ScenarioBehavior
Extension errorsLogged, agent continues
tool_call errorsBlock the tool (fail-safe)
Tool execute errorsSignal by throwing; caught, reported to LLM with isError: true, execution continues

Mode Behavior

ModeUI MethodsNotes
InteractiveFull TUINormal operation
RPC (--mode rpc)JSON protocolHost handles UI
JSON (--mode json)No-opEvent stream to stdout
Print (-p)No-opExtensions 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:

CategoryExampleDescription
Toolshello.tsMinimal tool registration
question.tsTool with user interaction
questionnaire.tsMulti-step wizard tool
todo.tsStateful tool with persistence
dynamic-tools.tsRegister tools after startup
structured-output.tsTerminating structured-output tool
truncated-tool.tsOutput truncation example
Commandspirate.tsModify system prompt per-turn
summarize.tsConversation summary command
handoff.tsCross-provider model handoff
qna.tsQ&A with custom UI
send-user-message.tsInject user messages
reload-runtime.tsReload command + LLM tool handoff
shutdown-command.tsGraceful shutdown command
Events & Gatespermission-gate.tsBlock dangerous commands
protected-paths.tsBlock writes to specific paths
confirm-destructive.tsConfirm session changes
dirty-repo-guard.tsWarn on dirty git repo
input-transform.tsTransform user input
model-status.tsReact to model changes
provider-payload.tsInspect payloads & response headers
file-trigger.tsFile watcher triggers messages
Compaction & Sessionscustom-compaction.tsCustom compaction summary
trigger-compact.tsTrigger compaction manually
git-checkpoint.tsGit stash on turns
auto-commit-on-exit.tsCommit on shutdown
UI Componentsstatus-line.tsFooter status indicator
working-indicator.tsCustomize streaming indicator
github-issue-autocomplete.tsAdd #1234 issue completions
custom-footer.tsReplace footer entirely
modal-editor.tsVim-style modal editor
overlay-test.tsOverlay components
notify.tsSimple notifications
timed-confirm.tsDialogs with timeout
Complexplan-mode/Full plan mode implementation
tools.tsToggle tools on/off UI
preset.tsSaveable presets
Remotessh.tsSSH remote execution
interactive-shell.tsPersistent shell session
sandbox/Sandboxed tool execution
subagent/Spawn sub-agents
Gamessnake.tsSnake game
space-invaders.tsSpace Invaders
doom-overlay/Doom in overlay
Providerscustom-provider-anthropic/Custom Anthropic proxy
custom-provider-gitlab-duo/GitLab Duo with OAuth
Messagesmessage-renderer.tsCustom message rendering
Eventsevent-bus.tsInter-extension events
Metadatasession-name.tsName sessions for selector
Metadatabookmark.tsBookmark entries for /tree
Miscwith-deps/Extension with npm dependencies