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 ask the model to run scripts/run-checks.sh. It cannot offer a new operation, block a dangerous command, or show a status line. Those need code, and quickstart.md is unambiguous: executable tools, commands, or event handlers means an extension.
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 is instructions the model reads, bundled with files it may run. An extension is TypeScript Pi loads and calls. The moment you need registerTool, registerCommand or pi.on, you have crossed into the trust boundary."
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 what a custom tool is: a name, a model-facing description, a TypeBox parameter schema, and an execute() function. Its result requires model-facing content and a details field.
Here is the review guard’s first tool, using defineTool and Type exactly as the bundled examples/extensions/hello.ts does:
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.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 not documentation. It is the model’s only basis for choosing this tool over bash. “List the files changed in this repository and the checks this project defines. Use before reviewing a diff” tells the model both what it does and when.
content and details
The result carries two kinds of information: content for the model, details for rendering or state reconstruction. Use details: undefined when there is nothing structured to keep.
The example above is deliberately thin in content — it tells the model what to run rather than running it. That is honest for a first extension: a tool that shells out on every call earns nothing over bash. Chapter 17 replaces it with real 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 you grant it, the tool does not exist and the skill in chapter 13 fires without it — worth knowing when reviewing output.
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 |
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 — satisfies all three by doing nothing else.
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 has no UI at all, which means it works in all four modes. That is a property worth choosing on purpose.
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 knows, because a tool the model does not know about is a tool the model will work around with
bash.
Start at .pi/extensions/review-scope.ts, then /reload, then ask Pi to use the tool.