Pi Extensions: Location, Storage, and Loading
pi is a coding agent whose behavior can be extended through extensions. Extensions are TypeScript modules that hook into lifecycle events, register custom tools for the LLM, add commands, and more.
This article explains where extensions live on the system, how they are structured, and how pi knows to use and invoke one. The two different paths are compared: extensions from the npm route (installed packages) and extensions from the manual auto-discover route.
Table of Contents
- What an extension is
- The entry point determines the directory
- Route 1: Extensions from npm packages
- Route 2: Manual extensions in the auto-discover route
- How pi recognizes and invokes an extension
- Structure of an extension package
- Conclusion
What an extension is
An extension is a TypeScript module that by default exports a factory function. This function receives an ExtensionAPI instance and connects to it through pi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greets someone by name",
// ...
});
}
Important: pi loads extensions via jiti. This means TypeScript is executed directly, without a build step.
The entry point determines the directory
There is no mandated pi-standard directory for extension source code. pi only cares about the entry point you declare in package.json under pi.extensions. The directory is freely choosable.
The three styles described in the docs:
1
2
3
4
5
6
7
1. Single File 2. Directory + index.ts 3. Package with dependencies
~/.pi/agent/ ~/.pi/agent/ ~/.pi/agent/
extensions/ extensions/ my-ext/
my-extension.ts my-extension/ package.json
index.ts β Entry Point src/index.ts β Entry Point
tools.ts ...
utils.ts
Note on
src/: In the official example extensions (examples/extensions/), the entry point is almost always placed directly asindex.tsin the package root β not insrc/. Thesrc/in the docs only appears in the illustrative example of the βPackage with dependenciesβ style. It is a general TypeScript/npm convention, not a pi-specific requirement.
Route 1: Extensions from npm packages
Installed extensions usually do not live in a ~/.pi/agent/extensions/ folder, but in piβs npm folder:
1
2
3
4
5
6
7
8
9
10
11
12
13
~/.pi/agent/
βββ npm/
β βββ package.json β your dependencies
β βββ package-lock.json
β βββ node_modules/
β βββ pi-zentui/
β βββ pi-web-access/
β βββ pi-mcp-adapter/
β βββ @juicesharp/
β βββ rpiv-todo/
β βββ rpiv-ask-user-question/
βββ settings.json
βββ ...
The connection is created through a three-step chain:
1. settings.json β packages lists the packages:
1
2
3
4
5
6
7
{ "packages": [
"npm:pi-zentui",
"npm:pi-web-access",
"npm:pi-mcp-adapter",
"npm:@juicesharp/rpiv-ask-user-question",
"npm:@juicesharp/rpiv-todo"
]}
pi installs these packages on startup into ~/.pi/agent/npm/node_modules/.
2. Each package declares a pi.extensions entry in its package.json β this is the anchor pi uses to recognize that the package ships an extension:
1
2
3
4
5
6
7
8
// e.g. pi-zentui
{ "pi": { "extensions": ["./extensions"] } }
// e.g. rpiv-todo
{ "pi": { "extensions": ["./index.ts"] } }
// pi-mcp-adapter can declare both
{ "pi": { "extensions": ["./index.ts"], "skills": ["./skills"] } }
3. pi loads the specified entry point (file or directory with index.ts) via jiti.
A special case is pi-zentui: it packs everything into a subfolder and points at the folder:
1
{ "files": ["extensions"], "pi": { "extensions": ["./extensions"] } }
pi resolves ./extensions and finds extensions/zentui/index.ts.
Route 2: Manual extensions in the auto-discover route
The folder ~/.pi/agent/extensions/ (or .pi/extensions/ in the project) is the manual auto-discover route for extensions you place directly as files β i.e. without an npm package. It is clearly separate from the npm route.
| 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 can be added via settings.json:
1
2
3
4
5
6
{
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}
Extensions in the auto-discover route can be hot-reloaded with /reload. Extensions tested only with pi -e ./path.ts are active for that single run only.
How pi recognizes and invokes an extension
Not by a fixed folder name, but by three levels:
| Level | What | How pi recognizes it |
|---|---|---|
| Declaration | settings.json β packages |
Package is installed |
| Entry Point | package.json β pi.extensions |
Lists the .ts files/directories |
| Contract | Default-exported factory function | pi.on(...), pi.registerTool(...), pi.registerCommand(...) |
The actual βinvocationβ happens through the lifecycle events and registered tools/commands that pi processes during a run:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pi starts
β
βββΊ project_trust
βββΊ session_start
βββΊ resources_discover
β
βΌ
user sends a prompt
β
βββΊ (extensions: check commands first)
βββΊ input
βββΊ before_agent_start
βββΊ agent_start
β
β ββββ turn (repeats while the LLM calls tools) ββββ
β β βββΊ tool_call (can be blocked) β
β β βββΊ tool_result (can modify result) β
β β βββΊ ... β
β βββΊ turn_end
β
βββΊ agent_end
βββΊ agent_settled
The most important events:
| Event | Purpose |
|---|---|
session_start |
Session started / loaded / reloaded |
tool_call |
The LLM called a tool β can be blocked |
tool_result |
Tool result can be modified |
before_agent_start |
Inject a message or modify the system prompt |
input |
Intercept, transform, or process user input |
resources_discover |
Contribute skill/prompt/theme paths |
Structure of an extension package
A typical package has an index.ts as the entry point (default export = factory function) plus a modular remainder:
1
2
3
4
5
6
7
8
~/.pi/agent/npm/node_modules/@juicesharp/rpiv-todo/
βββ package.json β pi.extensions: ["./index.ts"]
βββ index.ts β Entry Point (factory)
βββ config.ts
βββ locales/
βββ state/ β store, selectors, reducer ...
βββ tool/ β registerTool logic
βββ view/ β TUI components
Available imports for extensions:
| Package | Purpose |
|---|---|
@earendil-works/pi-coding-agent |
Extension types (ExtensionAPI, ExtensionContext, events) |
typebox |
Schema definitions for tool parameters |
@earendil-works/pi-ai |
AI utilities (StringEnum for Google-compatible enums) |
@earendil-works/pi-tui |
TUI components for custom rendering |
npm dependencies also work: create a package.json next to the extension, run npm install, and imports from node_modules/ are resolved automatically. Node.js built-in modules (node:fs, node:path, β¦) are also available.
Conclusion
- There is no prescribed source-code directory for extensions. pi looks at the entry point declared in
pi.extensions. - npm packages land in
~/.pi/agent/npm/node_modules/and are installed via thepackagesentry insettings.json. The anchor ispi.extensionsin the packageβspackage.json. - Manual extensions are placed directly in
~/.pi/agent/extensions/(global) or.pi/extensions/(project-local). - pi recognizes an extension by the combination of declaration (
packages/pi.extensions) and contract (default-exported factory function withpi.on,pi.registerTool,pi.registerCommand). - On the
src/vsextensions/discussion: both are possible. For a pure pi extension,extensions/is even particularly close to the auto-discover path~/.pi/agent/extensions/.