Extensions
An extension is optional TypeScript code loaded by Pi. It can register a purpose-built tool, connect to Pi lifecycle events, or provide deterministic behavior that should not depend on model judgment.
Use an extension when the recipe needs a stable operation such as parsing a domain file, enforcing a structured transformation, or wrapping an approved internal library. Use a skill when the work is primarily a procedure the model should reason through, and use MCP for tools provided by an external service.
Declare and lay out
The recipe manifest declares its extensions as paths or globs under
pi.extensions; each matched file is one extension module:
{
"name": "@acme/customer-support",
"type": "module",
"pi": {
"agents": ["agents/*.yaml"],
"extensions": ["extensions/*.ts"]
}
}customer-support/
├── agents/
├── extensions/
│ ├── current-date.ts
│ └── view-image.ts
└── package.jsonExtensions are never found by convention — executable code is declared or absent. Pi loads the TypeScript directly: nothing to compile, no bundler. Every agent session in the package loads every declared extension, including delegated children.
A minimal extension
An extension module default-exports a factory that receives Pi’s extension API. This one hooks a lifecycle event to append the current date to the system prompt — deterministic behavior a model cannot supply for itself:
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
const extension: ExtensionFactory = (pi) => {
pi.on("before_agent_start", async (event) => {
const today = new Intl.DateTimeFormat("en-AU", {
timeZone: "Australia/Sydney",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date());
return {
systemPrompt:
event.systemPrompt +
`\n\n# Current date\n\nToday is ${today}. ` +
'Use this as "now" for all tenure, recency, and date calculations.',
};
});
};
export default extension;Formatting to day granularity is a deliberate detail from the production recipe this example comes from: the rendered prompt is byte-identical for every turn on the same calendar day, so prompt-cache prefixes keep matching while the date still refreshes when a conversation crosses midnight.
Register a tool
The same factory can register tools. A tool declares its name, description,
and a typed parameter schema, then implements execute:
import { readFileSync } from "node:fs";
import {
type ExtensionFactory,
defineTool,
} from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "typebox";
const ViewImageParams = Type.Object({
path: Type.String({
description: "Local filesystem path to an image file.",
}),
});
const viewImageTool = defineTool({
name: "view_image",
label: "View Image",
description:
"View a local image file from the filesystem when visual inspection is needed.",
parameters: ViewImageParams,
execute: async (_id, params: Static<typeof ViewImageParams>) => {
const data = readFileSync(params.path).toString("base64");
return {
content: [
{ type: "image" as const, data, mimeType: "image/png" },
{ type: "text" as const, text: `Loaded image ${params.path}` },
],
details: { path: params.path },
};
},
});
const extension: ExtensionFactory = (pi) => {
pi.registerTool(viewImageTool);
};
export default extension;(The production version of this tool also validates the path against the workspace root and maps file extensions to MIME types — extension code is ordinary application code, so harden it like any other.)
Registering a tool does not expose it: an agent still has to list the tool in
its tools allowlist, so two roles can share one extension and see different
halves of it.
tools:
- read
- bash
- view_imageRecipe-owned tools receive the selected path and agent as PI_RECIPE_DIR and
PI_AGENT_NAME, so an extension can find files in its own package without a
hardcoded path.
Ask the user for input
An extension can pause for a question or approval with the portable interaction
helpers from @introspection-ai/recipes/interactions. They work in local Pi,
remote UIs, and headless hosts; the host decides how to render or resume the
request.
import {
type ExtensionFactory,
defineTool,
} from "@earendil-works/pi-coding-agent";
import { askUserQuestion } from "@introspection-ai/recipes/interactions";
import { Type, type Static } from "typebox";
const AskUserParams = Type.Object({
question: Type.String(),
options: Type.Optional(Type.Array(Type.String())),
});
const askUserTool = defineTool({
name: "ask_user_question",
label: "Ask user",
description: "Ask the user a clarifying question and wait for the answer.",
parameters: AskUserParams,
// Interaction tools must not run concurrently with other tools, otherwise
// a host pause can strand half-finished parallel tool calls.
executionMode: "sequential",
execute: async (
toolCallId,
params: Static<typeof AskUserParams>,
signal,
_onUpdate,
ctx
) => {
return await askUserQuestion(
{ question: params.question, options: params.options },
{ toolCallId, ctx, signal }
);
},
});
const extension: ExtensionFactory = (pi) => {
pi.registerTool(askUserTool);
};
export default extension;The helpers always return a finished tool result, so the tool can return them
directly. Pass the tool’s own signal parameter — not ctx.signal — so an
aborted turn dismisses any open dialog. askUserApproval() works the same way
for yes/no confirmation; see the
interaction contract for channel
resolution and the full request shape. On the Introspection platform, these
pauses surface as task interrupts: the task moves to awaiting_user and the
caller resumes it — see Tasks and runs.
Dependencies and scope
Extensions run as code in the Pi host, so keep them small, reviewable, and
limited to the capability the recipe actually needs. Declare runtime
dependencies in the recipe’s package.json dependencies (never
devDependencies — managed runtimes install production dependencies only)
and commit a lockfile for recipes distributed from Git. See
package.json manifest → Node dependencies.
See the extension documentation for the full contract: supported lifecycle events, the tool result shape, and loading and dependency behavior.