co-mono/packages/coding-agent/docs/extensions.md
Mario Zechner b85bf05461 docs(coding-agent): restructure extensions.md, consolidate examples
- Reduce from 1840 to 817 lines (55% smaller)
- Move all scattered example references to Examples Reference table at end
- Add missing setHeader() API documentation
- Add all 50 examples organized by category (Tools, Commands, Events, UI, etc.)
- Fix tui.md hooks->extensions terminology
2026-01-26 02:40:46 +01:00

25 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

See Examples Reference for working implementations.

Table of Contents

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:

{
  "packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"],
  "extensions": ["/path/to/extension.ts"]
}

Manage packages with CLI:

pi install npm:@foo/bar@1.0.0
pi install git:github.com/user/repo@v1
pi install https://github.com/user/repo
pi remove npm:@foo/bar
pi list
pi update

Package filtering: Selectively load resources:

{
  "packages": [
    "npm:simple-pkg",
    {
      "source": "npm:my-extensions",
      "extensions": ["extensions/oracle.ts"],
      "skills": [],
      "themes": [],
      "prompts": []
    }
  ]
}
  • Omit key = load all, empty array = load none
  • Glob patterns and !exclusions supported
  • User filters layer on top of manifest filters

Package deduplication: If same package in global and project settings, project wins.

Discovery rules:

  1. Direct files: extensions/*.ts → loaded directly
  2. Subdirectory with index: extensions/myext/index.ts → single extension
  3. Subdirectory with package.json: extensions/myext/package.json with "pi" field → loads declared paths
// package.json with pi manifest
{
  "name": "my-extension-pack",
  "keywords": ["pi-package"],
  "dependencies": { "zod": "^3.0.0" },
  "pi": {
    "extensions": ["./src/index.ts"],
    "skills": ["./skills/"],
    "prompts": ["./prompts/"],
    "themes": ["./themes/"]
  }
}

Run npm install in extensions with dependencies.

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

npm dependencies work if you add package.json next to extension. Node.js built-ins available.

Writing an Extension

Export a default function receiving ExtensionAPI:

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

export default function (pi: ExtensionAPI) {
  pi.on("event_name", async (event, ctx) => { ... });
  pi.registerTool({ ... });
  pi.registerCommand("name", { ... });
  pi.registerShortcut("ctrl+x", { ... });
  pi.registerFlag("my-flag", { ... });
}

Extensions loaded via jiti, TypeScript works without compilation.

Styles: Single file, directory with index.ts, or package with package.json for npm dependencies.

Events

Lifecycle Overview

pi starts → session_start

user sends prompt
  ├─► (extension commands bypass if found)
  ├─► input (can intercept/transform)
  ├─► (skill/template expansion)
  ├─► before_agent_start (inject message, modify system prompt)
  ├─► agent_start
  │   ┌─── turn (repeats while LLM calls tools) ───┐
  │   ├─► turn_start
  │   ├─► context (modify messages)
  │   │   LLM responds:
  │   │     ├─► tool_call (can block)
  │   │     └─► tool_result (can modify)
  │   └─► turn_end
  └─► agent_end

/new, /resume → session_before_switch → session_switch
/fork → session_before_fork → session_fork
/compact → session_before_compact → session_compact
/tree → session_before_tree → session_tree
/model, Ctrl+P → model_select
exit → 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 on /new or /resume. Can cancel.

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

session_before_fork / session_fork

Fired on /fork. Can cancel or skip conversation restore.

pi.on("session_before_fork", async (event, ctx) => {
  // event.entryId
  return { cancel: true };
  // OR: return { skipConversationRestore: true };
});

session_before_compact / session_compact

Fired on compaction. Can cancel or provide custom summary. See compaction.md.

pi.on("session_before_compact", async (event, ctx) => {
  // event.preparation, event.branchEntries, event.customInstructions, event.signal
  return { cancel: true };
  // OR: return { compaction: { summary: "...", firstKeptEntryId: "...", tokensBefore: 0 } };
});

session_before_tree / session_tree

Fired on /tree navigation. Can cancel or provide custom summary.

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

session_shutdown

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

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

Agent Events

before_agent_start

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

pi.on("before_agent_start", async (event, ctx) => {
  // event.prompt, event.images, event.systemPrompt
  return {
    message: { customType: "my-ext", content: "Context", display: true },
    systemPrompt: event.systemPrompt + "\n\nExtra instructions...",
  };
});

agent_start / agent_end

Fired once per user prompt.

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_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
  return { messages: event.messages.filter(m => !shouldPrune(m)) };
});

Model Events

model_select

Fired when model changes via /model, Ctrl+P, or session restore.

pi.on("model_select", async (event, ctx) => {
  // event.model, event.previousModel, event.source ("set" | "cycle" | "restore")
});

Tool Events

tool_call

Fired before tool executes. Can block.

pi.on("tool_call", async (event, ctx) => {
  // event.toolName, event.toolCallId, event.input
  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 typed as BashToolDetails */ }
  return { content: [...], details: {...}, isError: false };
});

Type guards: isBashToolResult, isReadToolResult, isEditToolResult, isWriteToolResult, isGrepToolResult, isFindToolResult, isLsToolResult

User Bash Events

user_bash

Fired on ! or !! commands. Can intercept.

pi.on("user_bash", (event, ctx) => {
  // event.command, event.excludeFromContext, event.cwd
  return { operations: remoteBashOps };
  // OR: return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
});

Input Events

input

Fired after extension commands checked, before skill/template expansion.

pi.on("input", async (event, ctx) => {
  // event.text, event.images, event.source ("interactive" | "rpc" | "extension")
  if (event.text.startsWith("?quick "))
    return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
  if (event.text === "ping") {
    ctx.ui.notify("pong", "info");
    return { action: "handled" };
  }
  return { action: "continue" };
});

Results: continue (default), transform, handled

ExtensionContext

Every handler receives ctx: ExtensionContext:

Property/Method Description
ui UI methods. See Custom UI
hasUI false in print/RPC mode
cwd Current working directory
sessionManager Read-only: getEntries(), getBranch(), getLeafId()
modelRegistry Model and API key access
model Current model (may be undefined)
isIdle() Whether agent is idle
abort() Abort current operation
hasPendingMessages() Whether messages are queued
shutdown() Request graceful exit
getContextUsage() Returns { tokens, contextWindow, percent, ... }
compact(options?) Trigger compaction with onComplete/onError callbacks

ExtensionCommandContext

Command handlers get ExtensionCommandContext (extends ExtensionContext):

Method Description
waitForIdle() Wait for agent to finish streaming
newSession(options?) Create new session with optional parentSession and setup callback
fork(entryId) Fork from entry, creating new session file
navigateTree(targetId, options?) Navigate tree with summarize, customInstructions, replaceInstructions, label

ExtensionAPI Methods

Event Subscription

pi.on(event, handler)  // See Events section

Tool Registration

pi.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "What this tool does",
  parameters: Type.Object({ ... }),
  async execute(toolCallId, params, onUpdate, ctx, signal) { ... },
  renderCall?(args, theme) { ... },
  renderResult?(result, options, theme) { ... },
})

Message Injection

// Custom message
pi.sendMessage({ customType: "my-ext", content: "...", display: true, details: {} }, {
  triggerTurn: true,
  deliverAs: "steer" | "followUp" | "nextTurn"
});

// User message (always triggers turn)
pi.sendUserMessage("text" | [{ type: "text", text: "..." }], {
  deliverAs: "steer" | "followUp"  // required when streaming
});

State Persistence

pi.appendEntry("my-state", { count: 42 });  // Does NOT go to LLM

Session Metadata

pi.setSessionName(name)
pi.getSessionName()
pi.setLabel(entryId, label)

Command Registration

pi.registerCommand("name", {
  description: "...",
  getArgumentCompletions?: (prefix) => AutocompleteItem[] | null,
  handler: async (args, ctx) => { ... }
});

Message Rendering

pi.registerMessageRenderer("customType", (message, { expanded }, theme) => Component | undefined);

Shortcuts and Flags

pi.registerShortcut("ctrl+shift+p", {
  description: "...",
  handler: async (ctx) => { ... }
});

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

Shell Execution

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

Tool Management

pi.getActiveTools()      // ["read", "bash", "edit", "write"]
pi.getAllTools()         // [{ name, description }, ...]
pi.setActiveTools(names)

Model and Thinking

await pi.setModel(model)     // Returns false if no API key
pi.getThinkingLevel()        // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
pi.setThinkingLevel(level)

Provider Registration

pi.registerProvider("my-proxy", {
  baseUrl: "https://proxy.example.com",
  apiKey: "PROXY_API_KEY",
  api: "anthropic-messages",
  headers?: { ... },
  authHeader?: true,
  models?: [{ id, name, reasoning, input, cost, contextWindow, maxTokens, compat? }],
  oauth?: { name, login, refreshToken, getApiKey, modifyModels? },
  streamSimple?: (model, context, options) => AssistantMessageEventStream
});

See custom-provider.md for details.

Event Bus

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

State Management

Store state in tool result details for proper branching:

let items: string[] = [];

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(...) {
    items.push("new");
    return { content: [...], details: { items: [...items] } };
  },
});

Custom Tools

Tool Definition

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),  // Use StringEnum for Google compatibility
    text: Type.Optional(Type.String()),
  }),
  async execute(toolCallId, params, onUpdate, ctx, signal) {
    if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] };
    onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
    return { content: [{ type: "text", text: "Done" }], details: {} };
  },
  renderCall(args, theme) { ... },
  renderResult(result, { expanded, isPartial }, theme) { ... },
});

Overriding Built-in Tools

Register tool with same name (read, bash, edit, write, grep, find, ls). Built-in renderer used if no custom render functions. Must match exact result shape including details type.

Use --no-tools -e ./my-extension.ts to start with only extension tools.

Remote Execution

Built-in tools support pluggable operations:

import { createReadTool, type ReadOperations } from "@mariozechner/pi-coding-agent";

const remoteRead = createReadTool(cwd, {
  operations: {
    readFile: (path) => sshExec(remote, `cat ${path}`),
    access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}),
  }
});

Interfaces: ReadOperations, WriteOperations, EditOperations, BashOperations, LsOperations, GrepOperations, FindOperations

Output Truncation

Tools MUST truncate output. Built-in limit: 50KB / 2000 lines.

import { truncateHead, truncateTail, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";

const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
if (truncation.truncated) {
  // Write full to temp file, inform LLM
}

Custom Rendering

import { Text } from "@mariozechner/pi-tui";

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(theme.fg("warning", "Processing..."), 0, 0);
  let text = theme.fg("success", "✓ Done");
  if (expanded && result.details?.items) {
    for (const item of result.details.items) text += "\n  " + theme.fg("dim", item);
  }
  return new Text(text, 0, 0);
}

Use keyHint(action, description) for keybinding hints.

Custom UI

See tui.md for full component API.

Dialogs

const choice = await ctx.ui.select("Pick:", ["A", "B", "C"]);
const ok = await ctx.ui.confirm("Delete?", "Cannot be undone");
const name = await ctx.ui.input("Name:", "placeholder");
const text = await ctx.ui.editor("Edit:", "prefill");
ctx.ui.notify("Done!", "info" | "warning" | "error");

Dialogs support timeout (auto-dismiss with countdown) and signal (manual abort):

const ok = await ctx.ui.confirm("Title", "Message", { timeout: 5000 });
ctx.ui.setStatus("key", "text" | undefined);
ctx.ui.setWorkingMessage("Custom loading..." | undefined);
ctx.ui.setWidget("key", ["Line 1", "Line 2"], { placement: "aboveEditor" | "belowEditor" });
ctx.ui.setWidget("key", (tui, theme) => Component);
ctx.ui.setFooter((tui, theme, footerData) => Component | undefined);
ctx.ui.setHeader((tui, theme) => Component | undefined);
ctx.ui.setTitle("Window title");

Editor

ctx.ui.setEditorText("prefill");
ctx.ui.getEditorText();
ctx.ui.setEditorComponent((tui, theme, keybindings) => EditorComponent | undefined);

Theme

ctx.ui.theme.fg("accent", "text")
ctx.ui.getAllThemes()
ctx.ui.getTheme("light")
ctx.ui.setTheme("light" | themeObject)

Custom Components

const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) => {
  return { render(width) { ... }, handleInput(data) { ... }, invalidate() { ... } };
}, { overlay?: true, overlayOptions?: { anchor, width, margin, ... }, onHandle?: (handle) => {} });

Message Rendering

pi.registerMessageRenderer("my-ext", (message, { expanded }, theme) => {
  return new Text(theme.fg("accent", message.content), 0, 0);
});

Error Handling

  • Extension errors logged, agent continues
  • tool_call errors block the tool (fail-safe)
  • Tool execute errors reported to 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 Check ctx.hasUI

Examples Reference

All examples in examples/extensions/.

Example Description Key APIs
Tools
hello.ts Minimal tool registration registerTool
question.ts Tool with user interaction registerTool, ui.select
questionnaire.ts Multi-step wizard tool registerTool, ui.custom
todo.ts Stateful tool with persistence registerTool, appendEntry, renderResult
truncated-tool.ts Output truncation example registerTool, truncateHead
tool-override.ts Override built-in read tool registerTool
Commands
pirate.ts Modify system prompt registerCommand, before_agent_start
summarize.ts Conversation summary registerCommand, ui.custom
handoff.ts Cross-provider handoff registerCommand, ui.editor
qna.ts Q&A with custom UI registerCommand, ui.custom
send-user-message.ts Inject user messages registerCommand, sendUserMessage
shutdown-command.ts Graceful shutdown registerCommand, shutdown()
Events & Gates
permission-gate.ts Block dangerous commands on("tool_call"), ui.confirm
protected-paths.ts Block writes to paths on("tool_call")
confirm-destructive.ts Confirm session changes on("session_before_*")
dirty-repo-guard.ts Warn on dirty git repo on("session_before_*"), exec
input-transform.ts Transform user input on("input")
model-status.ts React to model changes on("model_select")
Compaction & Sessions
custom-compaction.ts Custom compaction summary on("session_before_compact")
trigger-compact.ts Trigger compaction manually compact()
git-checkpoint.ts Git stash on turns on("turn_end"), exec
auto-commit-on-exit.ts Commit on shutdown on("session_shutdown"), exec
UI Components
status-line.ts Footer status indicator setStatus
custom-footer.ts Replace footer setFooter
custom-header.ts Replace startup header setHeader
modal-editor.ts Vim-style editor setEditorComponent
rainbow-editor.ts Custom editor styling setEditorComponent
widget-placement.ts Widget positioning setWidget
overlay-test.ts Overlay components ui.custom, overlay options
overlay-qa-tests.ts Comprehensive overlay tests ui.custom, all overlay options
notify.ts Simple notifications ui.notify
timed-confirm.ts Dialogs with timeout ui.confirm, timeout/signal
Complex Extensions
plan-mode/ Full plan mode implementation All APIs
preset.ts Saveable presets (model, tools) registerCommand, registerShortcut, registerFlag, setModel, setActiveTools
tools.ts Toggle tools on/off registerCommand, setActiveTools, SettingsList
claude-rules.ts Load rules from files on("before_agent_start")
file-trigger.ts File watcher triggers sendMessage
Remote & Sandbox
ssh.ts SSH remote execution registerFlag, on("user_bash"), tool operations
interactive-shell.ts Persistent shell on("user_bash")
sandbox/ Sandboxed execution Tool operations
subagent/ Spawn sub-agents exec, tool registration
Games & Fun
snake.ts Snake game registerCommand, ui.custom
space-invaders.ts Space Invaders game registerCommand, ui.custom
doom-overlay/ Doom in overlay ui.custom, overlay
Providers
custom-provider-anthropic/ Custom Anthropic proxy registerProvider
custom-provider-gitlab-duo/ GitLab Duo integration registerProvider, OAuth
Misc
mac-system-theme.ts Auto-switch theme setTheme
antigravity-image-gen.ts Image generation registerTool, Google Antigravity
inline-bash.ts Inline bash execution on("tool_call")
with-deps/ Extension with npm deps Package structure