Your First Extension
An extension is a TypeScript module with a default factory receiving ExtensionAPI; here is the smallest working tool, how it loads, and the lifecycle rules for the factory.
The review guard can instruct the model to run scripts/run-checks.sh, but a skill cannot itself register a new model-callable operation, intercept tool execution, or register a status display. Those integrations need an extension; the script a skill asks the model to run is still executable code, but it is not an extension hook.
This chapter builds the smallest extension that does something a skill cannot.
What an extension is
extensions.md: extensions are TypeScript modules loaded into the Pi process, and their factory functions register tools, commands, shortcuts, providers, event handlers, renderers, and terminal UI.
Read that first sentence again, because it settles the security question: an extension runs inside the Pi process with the same operating-system permissions, and it can inspect prompts, tool calls, files, credentials, and session history. Load extensions only from sources you trust.
Wrong: "A skill with a script in it is an extension."
Correct: "A skill supplies instructions and files the model may use, including scripts. An extension is code Pi loads into its own process and calls through APIs such as registerTool, registerCommand or pi.on. Both deserve review, but they execute through different paths."
The contract
An extension exports a default factory that receives ExtensionAPI. Pi uses jiti, so local TypeScript extensions do not need a separate compilation step.
The smallest command extension, from the documentation:
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");
},
});
}
Start Pi and run /hello. During development, load a file directly without installing it:
pi --extension ./hello.ts
That one-command loop is the reason to write extensions in TypeScript rather than building and installing a package first.
The running example: a tool
extensions.md describes a custom tool as a name, a model-facing description, a TypeBox parameter schema, and an execute() function. The result supplies model-facing content; details can carry structured information for rendering or later inspection.
Here is the review guard’s first tool, using defineTool and Type. It is deliberately a registration example, not yet a working change-discovery tool: the implementation returns a suggested Git command rather than executing it.
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const reviewScopeTool = defineTool({
name: "review_scope",
label: "Review Scope",
description:
"List the files changed in this repository and the checks this project defines. " +
"Use before reviewing a diff so findings cover the files that actually changed.",
parameters: Type.Object({
base: Type.Optional(
Type.String({
description: "Optional git ref to compare against. Defaults to the working tree.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const base = params.base?.trim() || "";
const diffCmd = base ? `git diff --name-only ${base}` : "git status --short";
return {
content: [
{ type: "text", text: `Run \`${diffCmd}\` and report the changed files.` },
],
// details is for rendering or state reconstruction; keep it small.
details: { base: base || null, diffCmd },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(reviewScopeTool);
}
Save it as .pi/extensions/review-scope.ts and run /reload.
The five fields that matter
| Field | Purpose |
|---|---|
name |
What the model calls it |
label |
How it renders in the terminal |
description |
What the model reads to decide whether to call it |
parameters |
TypeBox schema for the arguments |
execute() |
The implementation |
The description is a model-facing selection signal, not merely documentation. It competes with the descriptions of other available tools and the task context. Here it overpromises: review_scope does not list changed files or inspect project checks; it returns a suggested command. Until the implementation changes, describe it as a command suggestion rather than a file-discovery operation.
content and details
The result carries two kinds of information: content for the model and optional details for rendering or state reconstruction. Omit details when there is nothing structured to keep.
The example above returns an instruction to run Git, not the changed-file list promised by its name and description. Treat it as a demonstration of tool registration, not a completed review operation. Running Git inside a tool can still be useful when the extension validates arguments, standardises outputs, or restricts what is executed; merely delegating a command back to the model adds no such benefit. Chapter 17 develops the tool contract further with execution, outputSchema and nested calls.
One discipline to carry into that chapter: throwing from execute() produces a failed tool result, and returning an object does not mark it as an error. A tool that cannot do the job should say so in text or throw, not return an object that reads like success.
Where to put it
extensions.md: place the extension in your user or project extensions directory. Pi loads direct TypeScript or JavaScript files, and subdirectories containing an index.ts or index.js entry point.
Use a single file for a small extension and a directory for a multi-file implementation. Put npm dependencies in a nearby package.json.
For the running example:
.pi/
├── extensions/
│ └── review-scope.ts # one file, one tool
├── skills/
│ └── review-guard/
└── prompts/
└── review.md
.pi/extensions/ requires project trust, as chapter 8 established. Until trust is granted, this project extension is not loaded. The project skill described in chapter 13 is also a trust-gated project resource; neither should be assumed available in an untrusted project. Context files such as AGENTS.md follow different rules.
Choosing an integration point
extensions.md gives this table, and it is the fastest map of what an extension can do:
| Capability | Main API |
|---|---|
| Observe or modify lifecycle behavior | pi.on() |
| Add a model-callable operation | pi.registerTool() |
Add a / command |
pi.registerCommand() |
| Add a shortcut or CLI flag | pi.registerShortcut() or pi.registerFlag() |
| Send user or custom messages | pi.sendUserMessage() or pi.sendMessage() |
| Persist non-context session data | pi.appendEntry() |
| Change active tools, model, or thinking level | Session control methods on pi |
| Add a model provider | pi.registerProvider() |
| Add an MCP server | pi.registerMcpServer() |
| Route each request to a model | pi.registerVirtualModel() |
| Add terminal rendering | Renderer registration and ctx.ui |
| Communicate with another extension | pi.events |
flowchart TD
LOAD["discovery"] --> TRUST{"project extension?"}
TRUST -->|"yes, not yet trusted"| SKIP["not loaded at all"]
TRUST -->|"no, or trust granted"| FAC["factory runs<br/>may be async; Pi waits"]
FAC --> REG["what you registered<br/>tools, commands, handlers, providers"]
REG --> START["session_start<br/>start processes, sockets, timers HERE"]
START --> RUN["the session runs"]
RUN --> SHUT["session_shutdown<br/>idempotent cleanup"]
REL["/reload"] -.->|"replaces the runtime"| FAC
CANCEL["cancel, session replace, exit"] -.->|"all converge here"| SHUT
Use the factory to register tools, commands and handlers, and defer long-lived processes, sockets, watchers and timers to a session lifecycle hook or the operation that needs them. The session_start arrow shows the usual place for session-scoped startup. /reload replaces the runtime, so state held by the old extension must not be reused. Cleanup must tolerate repeated or overlapping shutdown paths.
Lifecycle rules for the factory
This is the part that causes real bugs, and extensions.md states three constraints:
- The factory may be synchronous or asynchronous. Pi waits for an asynchronous factory before startup continues, allowing it to fetch configuration or register providers needed during startup.
- Do not start processes, sockets, watchers, or timers in the factory, because some invocations load extensions without starting a session. Start long-lived resources from
session_startor from the command or tool that needs them. - Close session-scoped resources from an idempotent
session_shutdownhandler.
Idempotent matters because extensions.md explains why: cancellation, reload, session replacement, and process exit can converge on the same cleanup path.
A factory that only registers — like ours — avoids starting long-lived work before a session exists. It has no session-scoped resources to clean up.
Reload invalidates state
extensions.md is unambiguous: reload replaces the extension runtime, so code after await ctx.reload() must not reuse state from the old runtime. Only personal and explicit command-line extensions can participate in the project_trust event that runs before project extensions load.
Modes are not uniform
Extensions load in interactive, RPC, JSON, and print modes. Interactive mode provides the complete terminal UI. RPC can forward supported dialogs and notifications through the RPC Extension UI protocol, but not custom terminal components; JSON and print modes have no UI at all.
Guard terminal-only behaviour with ctx.mode === "tui" and use ctx.hasUI for interactions supported by both interactive and RPC clients. Keep tool and event behaviour independent from rendering so non-interactive modes remain functional.
Our review-scope.ts makes no UI calls, so its implementation does not depend on a terminal UI. That is a useful prerequisite for working across interactive, RPC, JSON and print modes, although the tool still has to be loaded, active and invoked.
What was run
The extensions in this chapter are files in examples/ch15-first-extension/. They are compiled with the TypeScript compiler against the pinned 1.0.4 declarations, and then loaded into a real AgentSession from pi-coding-agent: real extension runtime, real built-in tools, an in-memory session. The only substitution is the model, which is pi-ai’s faux provider replaying a script. Nothing in the harness re-implements a Pi rule. Chapters 16 to 21 use the same harness, and chapters 20 and 21 also substitute the interface and, for MCP, the far end of the connection.
What the tests established here:
- registering a
directtool makes it active, and a scripted tool call runs through Pi’s own pipeline and produces a tool result with thecontentanddetailsthe code returned; - the command is registered beside the built-ins.
They also found a defect in this chapter’s first draft. base was declared Type.String(...) while its description said Optional, so a model that followed the description and omitted it would have sent arguments that do not match the schema. It is now Type.Optional(Type.String(...)), and a test calls the tool with no arguments. The description of a parameter and the schema of a parameter are two statements, and only the schema is checked.
The next three steps
The extension registers something and stops there. Three things are still missing, and each is missing for a different reason:
- 16 adds events, because a tool that only exists cannot intervene.
tool_callis the one hook that fires before a tool runs, and it is what blocks a force push. - 17 specifies the tool, because registering it and the model seeing it are different facts, and neither of them is permission.
- 18 changes what the model sees in context, because registration and activation do not guarantee that the model will choose the intended tool rather than
bash.
Start at .pi/extensions/review-scope.ts, then /reload, then ask Pi to use the tool.