Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/earendil-works/pi/llms.txt

Use this file to discover all available pages before exploring further.

Extensions are TypeScript modules with a default export factory function that receives the ExtensionAPI object. They can register custom tools the LLM can call, intercept and block tool calls, add slash commands, bind keyboard shortcuts, and render custom TUI components — all without modifying Pi internals.
Extensions run with your full system permissions and can execute arbitrary code. Only load extensions from sources you trust.

Quick start

Create ~/.pi/agent/extensions/my-extension.ts:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  // React to lifecycle events
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });

  // Intercept tool calls
  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 the LLM can call
  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 slash command
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (args, ctx) => {
      ctx.ui.notify(`Hello ${args || "world"}!`, "info");
    },
  });
}
Test it with the --extension (or -e) flag without installing:
pi -e ./my-extension.ts

Extension locations

Pi auto-discovers extensions from these directories:
LocationScope
~/.pi/agent/extensions/*.tsGlobal (all projects)
~/.pi/agent/extensions/*/index.tsGlobal (subdirectory)
.pi/extensions/*.tsProject-local
.pi/extensions/*/index.tsProject-local (subdirectory)
Extensions in auto-discovered locations support hot-reload via /reload. The -e flag loads extensions for the current run only — useful for quick tests. You can also add paths directly in settings.json:
{
  "extensions": [
    "/path/to/local/extension.ts",
    "/path/to/local/extension/dir"
  ]
}
To share extensions with others, bundle them as a Pi package.

Extension styles

The simplest style. Place one .ts file in the extensions directory:
~/.pi/agent/extensions/
└── my-extension.ts
For multi-file extensions, create a subdirectory with an index.ts entry point:
~/.pi/agent/extensions/
└── my-extension/
    ├── index.ts
    ├── tools.ts
    └── utils.ts
For extensions that need npm packages, add a package.json and run npm install:
~/.pi/agent/extensions/
└── my-extension/
    ├── package.json
    ├── node_modules/
    └── src/
        └── index.ts
{
  "name": "my-extension",
  "dependencies": {
    "zod": "^3.0.0"
  },
  "pi": {
    "extensions": ["./src/index.ts"]
  }
}
Imports from node_modules/ resolve automatically after npm install.

Key capabilities

Custom tools

Register tools the LLM can call via pi.registerTool(). Tools appear in the system prompt and support custom TUI rendering.

Event interception

Block or modify tool calls, inject context into the system prompt, customize compaction, and intercept user input before it reaches the agent.

Custom UI

Prompt users via ctx.ui dialogs, add footer status lines, widgets above the editor, custom footers, and full TUI components via ctx.ui.custom().

Custom commands

Register slash commands like /mycommand via pi.registerCommand(). Commands support argument autocomplete.

Keyboard shortcuts

Bind any key combination via pi.registerShortcut().

Session persistence

Persist extension state across restarts via pi.appendEntry(). State survives /reload and session resume.

Events

Lifecycle overview

Pi emits events throughout the session lifecycle. Extensions subscribe with pi.on(eventName, handler).
pi starts

  ├─► session_start { reason: "startup" }
  └─► resources_discover { reason: "startup" }

user sends prompt
  ├─► input (can intercept, transform, or handle)
  ├─► before_agent_start (inject message, modify system prompt)
  ├─► agent_start

  │   ┌─── turn (repeats while LLM calls tools) ───┐
  │   ├─► turn_start
  │   ├─► context (can modify messages)
  │   ├─► before_provider_request
  │   ├─► after_provider_response
  │   │     ├─► 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" }

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

exit
  └─► session_shutdown

Session events

session_start — fired when a session starts, loads, or reloads.
pi.on("session_start", async (event, ctx) => {
  // event.reason — "startup" | "reload" | "new" | "resume" | "fork"
  ctx.ui.notify(`Session started: ${event.reason}`, "info");
});
session_before_switch — fired before /new or /resume. Return { cancel: true } to abort.
pi.on("session_before_switch", async (event, ctx) => {
  if (event.reason === "new") {
    const ok = await ctx.ui.confirm("Clear?", "Start a new session?");
    if (!ok) return { cancel: true };
  }
});
session_shutdown — fired before the extension runtime tears down. Use for cleanup.
pi.on("session_shutdown", async (event, ctx) => {
  // event.reason — "quit" | "reload" | "new" | "resume" | "fork"
});
session_before_compact / session_compact — fired on compaction. Return a custom summary or cancel.
pi.on("session_before_compact", async (event, ctx) => {
  return {
    compaction: {
      summary: "My custom summary...",
      firstKeptEntryId: event.preparation.firstKeptEntryId,
      tokensBefore: event.preparation.tokensBefore,
    }
  };
});

Agent events

before_agent_start — fires after the user submits a prompt, before the agent loop. Can inject a message or modify the system prompt.
pi.on("before_agent_start", async (event, ctx) => {
  return {
    message: {
      customType: "my-extension",
      content: "Additional context for the LLM",
      display: true,
    },
    systemPrompt: event.systemPrompt + "\n\nExtra instructions...",
  };
});
agent_start / agent_end — fire once per user prompt. turn_start / turn_end — fire for each turn (one LLM response plus its tool calls). context — fires before each LLM call. Modify the message list non-destructively.
pi.on("context", async (event, ctx) => {
  const filtered = event.messages.filter(m => !shouldPrune(m));
  return { messages: filtered };
});

Tool events

tool_call — fires before a tool executes. Can block. Mutate event.input in place to patch arguments.
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";

pi.on("tool_call", async (event, ctx) => {
  if (isToolCallEventType("bash", event)) {
    // event.input is typed as { command: string; timeout?: number }
    if (event.input.command.includes("rm -rf")) {
      return { block: true, reason: "Dangerous command" };
    }
  }
});
tool_result — fires after tool execution, before the result is sent to the LLM. Can modify the result.
pi.on("tool_result", async (event, ctx) => {
  // Return partial patches — omitted fields keep their current values
  return { content: [...event.content, { type: "text", text: " [audited]" }] };
});
tool_execution_start / tool_execution_update / tool_execution_end — fire for tool execution lifecycle updates.

Input events

input — fires when user input is received, before skill and template expansion. Can transform or handle the input.
pi.on("input", async (event, ctx) => {
  // Transform: rewrite input text
  if (event.text.startsWith("?quick "))
    return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };

  // Handle: skip the agent entirely
  if (event.text === "ping") {
    ctx.ui.notify("pong", "info");
    return { action: "handled" };
  }

  return { action: "continue" };
});

Model events

model_select — fires when the model changes via /model, Ctrl+P, or session restore.
pi.on("model_select", async (event, ctx) => {
  // event.model, event.previousModel, event.source
  ctx.ui.setStatus("model", `${event.model.provider}/${event.model.id}`);
});
thinking_level_select — fires when the thinking level changes (notification only).

Resource events

resources_discover — fires after session_start. Return additional skill, prompt, and theme paths.
pi.on("resources_discover", async (event, _ctx) => {
  return {
    skillPaths: ["/path/to/skills"],
    promptPaths: ["/path/to/prompts"],
    themePaths: ["/path/to/themes"],
  };
});

ExtensionAPI methods

pi.on(event, handler)

Subscribe to lifecycle events. See Events for all event names and return values.

pi.registerTool(definition)

Register a custom tool callable by the LLM. Tools appear in the system prompt when promptSnippet is set.
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: "List or add items in the project todo list",
  promptGuidelines: [
    "Use my_tool for todo planning instead of direct file edits."
  ],
  parameters: Type.Object({
    action: StringEnum(["list", "add"] as const),
    text: Type.Optional(Type.String()),
  }),
  async execute(toolCallId, params, signal, onUpdate, ctx) {
    onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
    return {
      content: [{ type: "text", text: "Done" }],
      details: { result: "..." },
    };
  },
});
Use StringEnum from @earendil-works/pi-ai for string enums instead of Type.Union/Type.Literal. Type.Union does not work with Google’s API.

pi.registerCommand(name, options)

Register a slash command. Commands can include argument autocomplete.
pi.registerCommand("stats", {
  description: "Show session statistics",
  handler: async (args, ctx) => {
    const count = ctx.sessionManager.getEntries().length;
    ctx.ui.notify(`${count} entries`, "info");
  },
});

pi.registerShortcut(shortcut, options)

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

pi.registerFlag(name, options)

Register a CLI flag that can be passed when starting Pi.
pi.registerFlag("plan", {
  description: "Start in plan mode",
  type: "boolean",
  default: false,
});

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

pi.sendUserMessage(content, options?)

Send a user message as if typed by the user. Always triggers a turn.
pi.sendUserMessage("What is 2+2?");

// During streaming, specify delivery mode
pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
pi.sendUserMessage("Then summarize", { deliverAs: "followUp" });

pi.appendEntry(customType, data?)

Persist extension state to the session file. Does not participate in LLM context. Survives restarts.
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 state from entry.data
    }
  }
});

pi.registerProvider(name, config)

Register or override a model provider. Useful for proxies, custom endpoints, or dynamically discovered models.
export default async function (pi: ExtensionAPI) {
  const response = await fetch("http://localhost:1234/v1/models");
  const payload = await response.json();

  pi.registerProvider("local-openai", {
    baseUrl: "http://localhost:1234/v1",
    apiKey: "LOCAL_OPENAI_API_KEY",
    api: "openai-completions",
    models: payload.data.map((model) => ({
      id: model.id,
      name: model.name ?? model.id,
      reasoning: false,
      input: ["text"],
      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
      contextWindow: model.context_window ?? 128000,
      maxTokens: model.max_tokens ?? 4096,
    })),
  });
}
An async factory function is awaited before Pi startup continues, so registered models are available immediately including to pi --list-models.

ExtensionContext

All event handlers receive ctx: ExtensionContext.

ctx.ui

UI methods for user interaction.
// 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");

// Non-blocking notification
ctx.ui.notify("Done!", "info");   // "info" | "warning" | "error"

// Footer status (persistent until cleared)
ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear

// Widget above or below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
ctx.ui.setWidget("my-widget", ["Line 1"], { placement: "belowEditor" });
ctx.ui.setWidget("my-widget", undefined); // Clear

// Terminal title
ctx.ui.setTitle("pi - my-project");
Dialogs support timeout for auto-dismissal with a live countdown:
const confirmed = await ctx.ui.confirm(
  "Timed Confirmation",
  "This dialog auto-cancels in 5 seconds.",
  { timeout: 5000 }
);

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

The current agent abort signal. Pass to fetch(), model calls, or other abort-aware operations.
pi.on("tool_result", async (event, ctx) => {
  const response = await fetch("https://example.com/api", {
    method: "POST",
    body: JSON.stringify(event),
    signal: ctx.signal,
  });
});

ctx.cwd

Current working directory.

ctx.hasUI

false in print mode (-p) and JSON mode. Check before calling UI methods in non-interactive contexts.

Custom tools

Tools are registered with a name, description, TypeBox schema for parameters, and an execute function. The LLM uses the description to decide when to call the tool.
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

pi.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "Manage a list of items",
  parameters: Type.Object({
    action: StringEnum(["list", "add", "remove"] as const),
    item: Type.Optional(Type.String({ description: "Item text for add/remove" })),
  }),

  async execute(toolCallId, params, signal, onUpdate, ctx) {
    if (signal?.aborted) {
      return { content: [{ type: "text", text: "Cancelled" }] };
    }

    // Stream progress
    onUpdate?.({ content: [{ type: "text", text: "Processing..." }] });

    return {
      content: [{ type: "text", text: `Action: ${params.action}` }],
      details: { items: [] },
    };
  },
});
To signal a tool error, throw from execute. The thrown error is caught, reported to the LLM with isError: true, and execution continues. Extensions can also override built-in tools (read, bash, edit, write, grep, find, ls) by registering a tool with the same name.

Custom UI

For complex interactions, use ctx.ui.custom(). This temporarily replaces the editor with your component until done() is called.
import { Text } 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;
});

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
Node.js built-ins (node:fs, node:path, etc.) are also available. Extensions load via jiti, so TypeScript works without compilation.

Skills

Package instruction sets the agent loads on demand.

Pi packages

Bundle and share extensions via npm or git.

Themes

Customize Pi’s terminal appearance.

Prompt templates

Reusable prompts that expand with a slash command.

Build docs developers (and LLMs) love