Inside Pi

Part IV · Coding agent · Chapter 10

Extensibility

11 min read · checked against pi@4954d76 on 2026-10-16

Pi's answer to "how do I add feature X" is almost never "change Pi". It offers five kinds of resource, each with a different cost and power: prompt templates (reusable text), skills (instructions loaded on demand), themes (colours), extensions (TypeScript code running in the Pi process) and Pi packages that bundle the others for distribution. On top of extensions sit two built-in integrations that change how a model reaches tools: MCP servers and codemode. This chapter explains each mechanism and the design principles behind it.

Learning goals. Choose the right extension mechanism for a need; explain progressive disclosure in skills; describe the extension lifecycle and event semantics (notify, transform, cancel); explain tool exposure levels and why most MCP tools are not declared to the model; describe what codemode changes about tool use.

10.1 A ladder of power

MechanismWhat it isRuns code?Typical use
Prompt templateMarkdown file that becomes a /commandNo/review expands to a review prompt
SkillDirectory with SKILL.md and supporting filesOnly what the model chooses to runDomain procedures: PDF handling, release process
ThemeJSON colour definitionsNoTerminal appearance
ExtensionTypeScript module with a factory receiving ExtensionAPIYes, in-processTools, commands, permission gates, providers, UI
Pi packagenpm or git package containing any of the aboveIf it contains extensionsSharing a bundle of customizations

The ladder is deliberate: use the least powerful mechanism that solves the problem. Text is easier to review, share and trust than code — the principle of least power.

10.2 Prompt templates

A template is a Markdown file; its filename becomes the command. From docs/prompt-templates.md:

---
description: Review staged git changes
argument-hint: "[focus]"
---
Review the staged changes. Focus on ${1:-correctness, security, and error handling}.

Substitution borrows shell syntax: $1, $@/$ARGUMENTS, ${1:-default}, ${@:N}, ${@:N:L}, and arguments are split with shell-like quoting. Expansion happens in AgentSession.prompt() after extension input handlers and before the run (Chapter 7), so the model only ever sees the expanded text. Templates are macros: purely syntactic substitution with no evaluation.

10.3 Skills and progressive disclosure

A skill is a directory with SKILL.md (YAML frontmatter with name and description, then instructions) plus scripts, references and assets. Pi implements the Agent Skills specification. The key mechanism is in the docs:

At startup, Pi scans configured skill locations and adds each skill's name, description, and path to the system prompt. It does not add the full instructions. When a task matches, the model reads SKILL.md and follows its instructions. This keeps detailed guidance out of context until it is needed.

This is progressive disclosure, an idea from interface design applied to context budgets: show a summary, reveal detail on demand. Ten skills cost ten lines of prompt, not ten documents. The trade-off is reliability — "A model might fail to load a relevant skill, so use /skill:name when you need to force it" — which is why the description must say both what the skill does and when it applies; it is effectively a routing rule written for a language model. The system prompt's skills section names the tool to use for reading (read, or bash when read is unavailable, Chapter 1). A skill with disable-model-invocation: true is reachable only by explicit command.

10.4 Extensions: code in the process

An extension is a module whose default export is a factory:

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

export default function (pi: ExtensionAPI) {
  pi.registerCommand("hello", {
    description: "Show a greeting",
    handler: async (name, ctx) => {
      ctx.ui.notify(`Hello, ${name || "world"}!`, "info");
    },
  });
}

Loading. Extensions are TypeScript files loaded at runtime with jiti, a just-in-time TypeScript loader, so "local TypeScript extensions do not need a separate compilation step". When an extension imports @earendil-works/pi-coding-agent, it must get the running Pi, not a second copy from its own node_modules. The loader therefore maps Pi's package names to the host's modules — path aliases in a Node install, bundled virtual modules in the standalone binary (loader.ts). This is how a plugin and its host share one instance of a library, the same problem browser module federation and Java class loaders solve.

Lifecycle. The factory may be async; Pi awaits it. It must only register things: "Do not start processes, sockets, watchers, or timers in the factory because some invocations load extensions without starting a session." Long-lived resources start in session_start and are released in an idempotent session_shutdown handler. Reload replaces the whole extension runtime.

Capabilities. ExtensionAPI offers, among others: pi.on(event, handler), registerTool, registerCommand, registerShortcut, registerFlag, sendUserMessage, sendMessage, appendEntry (state outside model context), registerProvider (new LLM providers), registerMcpServer, registerVirtualModel (per-request routing), renderer registration, pi.events for extension-to-extension messages, and session control (active tools, model, thinking level).

10.5 Events: notify, transform, cancel

The extension system is an event bus with typed events. ExtensionRunner.emit() shows the dispatch semantics:

for (const { ext, handlers } of snapshotEventHandlers(this.extensions, event.type)) {
	for (const handler of handlers) {
		try {
			const handlerResult = await handler(event, ctx);
			if (this.isSessionBeforeEvent(event) && handlerResult) {
				result = handlerResult as SessionBeforeEventResult;
				if (result.cancel) {
					return result as RunnerEmitResult<TEvent>;
				}
			}
		} catch (err) {
			…
			this.emitError({ extensionPath: ext.path, event: event.type, error: message, stack });
		}
	}
}

Four properties are worth naming:

  1. Deterministic order. "Handlers run in extension load and registration order."
  2. Snapshot iteration. Handlers are snapshotted before dispatch, so (un)subscribing during dispatch "do[es] not affect a dispatch already in progress" — the standard fix for mutating a collection while iterating it.
  3. Fault isolation. A throwing handler is reported and the next one runs. But isolation is chosen per event for safety: "A tool_call handler failure blocks the tool as a fail-safe" — when a permission check crashes, the safe default is deny. This is the fail-closed principle.
  4. Three kinds of event. "Some events notify; others transform data, replace results, or cancel an operation." tool_call can mutate input or return { block: true, reason }; tool_result handlers compose, "with each handler seeing prior changes" (a middleware chain, like Express or Redux middleware); session_before_* events can return { cancel: true }; for cache_warming_decision, "the last handler that returns an action wins".

The event list spans the whole system: resource discovery, project_trust, session_start/session_shutdown, input, before_agent_start (can rewrite the prompt or add a message), context (transform messages before each request), provider_stream_event (raw provider events, read-only), message and tool lifecycle, turn_end and agent_before_settle ("actionable boundaries" that may append entries and request one continuation), and agent_settled ("final and notification-only"). The docs warn about the obvious hazard of continuation hooks: "Guard continuation conditions because an unconditional continuation can loop."

A permission gate in a dozen lines, from docs/extensions.md, shows how much behaviour fits in this model:

pi.on("tool_call", async (event, ctx) => {
  const hints = pi.getAllTools().find((tool) => tool.name === event.toolName)?.annotations;
  const needsApproval =
    hints?.destructiveHint === true ||
    (!hints?.readOnlyHint && ((hints?.destructiveHint ?? true) || (hints?.openWorldHint ?? true)));
  if (needsApproval && !(await ctx.ui.confirm("Allow tool call?", event.toolName))) {
    return { block: true, reason: `${event.toolName} was not approved` };
  }
});

This is how Pi can ship without a permission system (Chapter 8) yet let anyone add one: the core exposes the policy hook, packages supply the policy.

10.6 Extension state follows the branch

Because sessions are trees (Chapter 9), extension state has to be branch-aware. The docs give a storage decision table: tool state that follows the active branch goes in tool-result details; durable data outside model context in pi.appendEntry() (a custom entry); content for the model in pi.sendMessage() (a custom_message entry). And: "Reconstruct branch-sensitive state from ctx.sessionManager.getBranch() during session_start. Do not rebuild it from every file entry because abandoned branches represent alternative histories." State is a fold over the active path, the same projection idea as model context.

10.7 Tool exposure: what the model sees versus what can be called

With dozens of MCP servers a session can have hundreds of tools. Declaring them all costs thousands of prompt tokens per request and degrades the model's tool choice. Pi separates two questions: is a tool declared to the model, and is it callable (from other tools via ctx.executeTool)? Each tool has an exposure:

ExposureDeclared to the modelCallable from other tools
direct (default)While activeWhile active
model-onlyWhile activeNever
codemodeOnly if explicitly activatedWhenever registered; listed by codemode
deferredAfter tool_search loads itYes; found by tool_search, not listed by codemode
hiddenNeverNever

Tools can be grouped into a namespace with a description and longer instructions (MCP servers become namespaces), and carry MCP-style annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) that permission extensions can consult — hints, the docs stress, that "are not verified". A tool that orchestrates others (codemode, tool_search) declares which tools a call may reach with reachesTools(input), so late-registering extensions can finish connecting the needed servers before it runs. Activating tools mid-session appends a system message rather than rewriting the prompt, preserving the prompt cache (Chapter 6).

10.8 MCP servers

The Model Context Protocol is an open protocol through which external programs offer tools, resources and prompts to AI applications (Chapter 18 covers the client library). Pi reads servers from mcp.json or from extensions (pi.registerMcpServer), connects them over stdio (a child process) or HTTP, and registers each server tool as mcp__<server>__<tool>. The default exposure for MCP tools is codemode, not direct:

codemode (default): Callable from codemode scripts, but neither declared to the model nor listed in the codemode description. Scripts find tools with searchTools(), describeTool(), or ALL_TOOLS.

The system prompt gets a compact mcp_servers section with one line per server, updated by appending ("so earlier messages stay cached"). toolExposure overrides exposure per tool with exact names or * patterns ("Exact names win over patterns; among patterns, the first match wins"). Large text results are cut in the middle around a …N chars truncated… marker with the full text in a temp file.

10.9 Codemode: tools called from code

The conventional tool loop costs one model round trip per tool call, and every result passes through the context window. Codemode inverts this. From docs/codemode.md:

The codemode tool lets the model write a JavaScript script that calls pi's other tools and runs non-LLM models, such as classifiers and image models. Only the script's output reaches the model, so a script can run calls in parallel and filter large results before the model sees them.

The script "runs as the body of an async function in a QuickJS sandbox … The sandbox has no Node APIs, file system, network, or timers; scripts reach the outside world only through tools and models." Globals include tools.<name>(args), text(), image(), console.log, store/load for small values across calls, ALL_TOOLS, searchTools(query) (ranked with BM25, the classic information-retrieval scoring function), describeTool(name) (returns a TypeScript declaration of the tool) and describeNamespace(name).

A model can now write:

const hits = await Promise.all(["auth", "session"].map((q) => tools.grep({ pattern: q, path: "src" })));
return hits.map((h) => h.split("\n").length);

— two searches in parallel and only two numbers back into context. The sandbox is a capability-based design: the script can do nothing except what the injected functions allow, and every injected tool call still passes through validation and the tool_call/tool_result hooks. The docs are honest about the limit of the sandbox: "Tool calls are real: calls made before a failure are not undone." Chapter 18 opens the sandbox implementation.

10.10 Pi packages and supply chain

Packages are installed from npm (npm:@example/pi-tools@1.0.0), git (git:github.com/example/pi-tools@v1), a URL, or a local path, and declared in settings (personal, or .pi/settings.json with --local, read only after project trust). A package exposes conventional directories (extensions/, skills/, prompts/, themes/) or an explicit pi manifest in package.json. "Versioned npm specifications are pinned. Git tags and commits are also pinned; package updates reconcile the checkout but do not move a configured ref." Version resolution uses semver.

Two engineering details from package-manager.ts. First, extension packages must not install their own copy of Pi: "Disable peer dependency resolution for managed installs … so package managers do not install or solve host-provided @earendil-works/pi-* peers." Pi is a peer dependency provided by the host, the standard pattern for plugins. Second, the managed install commands shown in that file (npm install … --prefix <root> --legacy-peer-deps and equivalents for pnpm and bun) do not pass --ignore-scripts, unlike Pi's own self-update path. Installing a package therefore trusts it at install time as well as at run time — consistent with the docs' warning: "Packages can execute extension code … Review third-party package source before installing it."

10.11 Concepts in this chapter

  • Principle of least power. Prefer the least expressive mechanism (text over code) that meets the need.
  • Plugin architecture and inversion of control. The host defines extension points; plugins register behaviour; the host calls them.
  • Event bus / publish-subscribe. Typed events, multiple subscribers, ordered dispatch.
  • Middleware chains. Each handler transforms the result of the previous one.
  • Fail-open vs fail-closed. On error in a safety check, deny.
  • Progressive disclosure. Summaries first, details on demand — here, to save context tokens.
  • Macro expansion. Text substitution before interpretation.
  • Dynamic module loading and shared singletons. Load code at runtime and make plugins share the host's library instances (aliases, virtual modules, peer dependencies).
  • Capability-based security. Code can only use the capabilities explicitly passed to it.
  • Information retrieval (BM25). Rank documents by term frequency, inverse document frequency and length normalization.
  • Semantic versioning and pinning. Version ranges express compatibility; pins express trust.

Exercises

  1. Write an extension that registers a todo tool storing items in tool-result details, and rebuilds its list from getBranch() on session_start. Navigate /tree and verify the list follows the branch.
  2. Write a tool_call handler that blocks write and edit outside the working directory. Why is this not a security boundary? (Hint: Chapter 8.)
  3. Turn a long checklist you use into a skill. Write two descriptions, one vague and one precise, and test which one the model loads reliably.
  4. Explain why a turn_end handler that always returns continue: true hangs the agent, and write a guarded version.
  5. A user has 12 MCP servers with 400 tools. Estimate the prompt tokens saved per request by codemode exposure if each tool declaration averages 150 tokens.
  6. Implement BM25 scoring for tool descriptions in 40 lines and compare its ranking with substring search on five queries.

Source files for this chapter

Every quote is checked against these files at 4954d76. A change under packages/coding-agent, packages/mcp, packages/codemode flags this chapter for review.