Register a tool and a command from an extension
Load a TypeScript extension that registers a model-callable tool and a slash command, and watch the tool run through Pi's real pipeline.
An extension is a TypeScript module whose default export receives ExtensionAPI. Registering a tool
makes it active and declared to the model, while registering a command adds a / command beside the
built-ins. This page loads both the smallest command extension and the running example’s
review_scope tool; the only substitution is the model, a faux provider replaying a script.
Run it
Pi 1.0.4 loads local TypeScript with jiti, so there is no build step. The command extension
below is the whole file, reproduced exactly:
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");
},
});
}Load it with pi --extension ./hello.ts and run /hello.
The running example is a tool instead of a command. review_scope is declared with defineTool
and Type, and registered with pi.registerTool. Its base parameter is
Type.Optional(Type.String(...)), so a call with no arguments is valid and defaults to the working
tree; passing { base: "main" } names git diff --name-only main.
| Registered with | What it adds | How it appears |
|---|---|---|
pi.registerCommand("hello", …) |
a /hello command |
listed beside the built-ins |
pi.registerTool(reviewScopeTool) |
a model-callable tool | active and declared to the model |
Expected outcome
This is observed: first-extension.test.ts loads each extension into a real AgentSession
with a scripted faux provider and asserts the following.
| Extension | Step | Result |
|---|---|---|
| command | getRegisteredCommands() |
includes hello, beside the built-ins |
| tool | getActiveToolNames() |
includes review_scope — a direct tool activates on registration |
| tool | model calls review_scope with { base: "main" } |
isError: false; text names git diff --name-only main; details is { base: "main", diffCmd: "git diff --name-only main" } |
| tool | model calls review_scope with {} |
isError: false; text names git status --short |
Mechanism and limitations
Documented: the exported ExtensionAPI type defines the default-factory contract,
pi.registerCommand, pi.registerTool and defineTool. A direct tool (the default exposure)
is activated on registration and declared to the model, and a tool result carries model-facing
content plus a details field.
Observed: first-extension.test.ts runs the extension through the real runtime — real
extension loader, real built-in tools, an in-memory session — and asserts that a registered tool is
active, that a scripted call produces a toolResult whose content and details match what
execute() returned, and that the command is registered. It also pins a defect: base was first
declared as required Type.String(...) while its description called it optional, so a call with
no arguments failed the schema. It is now Type.Optional(Type.String(...)), with a test for the
empty call. The parameter description and the parameter schema are two statements, and only the
schema is checked.
Limitations: registering a tool makes it exist and be declared; it is not permission to run,
and the model may still ignore it (Chapter 17). A project extension under .pi/extensions/ does not
load at all until the project is trusted (Chapter 8), so until then a skill fires without the tool.
The test uses a faux model, so it shows the code around the model, not that a real model would
choose review_scope.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.