pi.registerMcpServer(name, config) adds a server for the session with the mcp.json config shape; pi.unregisterMcpServer() removes it and pi.getMcpServers() lists registrations. Servers registered while loading connect on session_start, later ones right away. A server in mcp.json with the same name takes precedence and /mcp shows the override. Changes to registered servers in /mcp apply to the session only. The mcp_servers_change event lets any MCP extension connect registered servers; when none handles it, registrations are reported as extension errors.
18 KiB
Extensions
Extensions are TypeScript modules that add executable behavior to Pi. Use one when a workflow needs tools, commands, event handlers, model providers, session state, or terminal UI rather than instructions alone.
An extension runs inside the Pi process with the same operating-system permissions. It can inspect prompts, tool calls, files, credentials, and session history, so load extensions only from sources you trust.
Typical extensions add an agent tool, protect paths, confirm dangerous commands, react to session events, modify context, expose a command, or display persistent status.
Create and load an extension
An extension exports a default factory that receives ExtensionAPI. The factory registers capabilities for the current extension runtime.
Create ~/.pi/agent/extensions/hello.ts:
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:
pi --extension ./hello.ts
Pi uses jiti, so local TypeScript extensions do not need a separate compilation step. Use Pi packages for distributed extensions and dependencies.
Add it to Pi
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. See Configuration for conventional locations and Settings for additional paths.
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.
Respect the runtime lifecycle
The factory can 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_start or from the command or tool that needs them.
Close session-scoped resources from an idempotent session_shutdown handler.
A run proceeds from input and before_agent_start, through model, message, and tool events, to agent_end.
Automatic retries, recovery, compaction, or queued work can continue afterward.
agent_before_settle is the final actionable boundary: it can append entries and request one continuation.
agent_settled is final and notification-only; use it when an integration needs to know Pi will not continue automatically.
Choose an integration point
| 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() |
| Add terminal rendering | Renderer registration and ctx.ui |
| Communicate with another extension | pi.events |
Use the exported declarations in extensions/types.ts for exact event, context, tool, and result types.
Follow the extension contracts
Events and concurrency
Handlers run in extension load and registration order. pi.on() returns a function that unsubscribes that registration; changes do not affect a dispatch already in progress.
Some events notify; others transform data, replace results, or cancel an operation.
Use each event’s declared result type rather than assuming every return value has an effect.
Events cover resource discovery, sessions, agent and message lifecycle, providers, tools, and raw input.
before_agent_start exposes both the current prompt and its structured systemPromptOptions. Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning systemPrompt, or setting forceSystemPrompt, replaces the whole prompt for that run while the transcript continues recording the structured sections. Providers receive the forced text as their leading system prompt.
message_end can replace a finalized message while preserving its role. tool_call can mutate input or block execution. tool_result handlers compose, with each handler seeing prior changes.
provider_stream_event fires for each parsed provider stream event before Pi normalizes it. The event identifies the provider, API, and model; event.data is the earliest structured value available to Pi, not necessarily the original HTTP bytes or SSE frame. Treat it as read-only because mutation can affect normalization. The event is notification-only and is not persisted.
Handlers are awaited in stream order, so slow handlers delay stream consumption. Handler errors are reported without changing the provider response. See debug-provider.ts for an opt-in viewer that groups raw events by assistant message.
context transforms conversation messages without prompt and tool system messages; Pi restores that state afterward. Use context_with_system only when a request-local transformation must own the complete transcript, and keep a system message at index zero.
turn_end and agent_before_settle are actionable boundaries. Their handlers can chain proposed custom, custom_message, context_edit, or compaction entries and return continue: true for one next model request. Guard continuation conditions because an unconditional continuation can loop. Use the exported event declarations for the complete validation and ordering contract.
cache_warming_decision can override an idle prompt-cache refresh with { action: "warm" } or { action: "stop" }. The last handler that returns an action wins.
Tool calls from one assistant message can run in parallel.
Do not assume a sibling call or result exists when another tool event runs.
Use ctx.signal for nested work owned by an active turn; commands and idle session events often have no operation signal.
A user_bash handler that returns undefined passes the command to the next handler and then to local execution if no handler handles it. Returning operations or result stops propagation. A handler failure blocks the command rather than falling through to local execution.
Tools
A custom tool defines a name, model-facing description, TypeBox parameter schema, and execute() function.
Its result requires model-facing content and a details field for rendering or state reconstruction.
Use details: undefined when there are no structured details. If the tool makes nested model calls, include their usage in the result so session totals remain accurate.
Throw from execute() to produce a failed tool result.
Returning an object does not mark it as an error.
Return terminate: true only when the agent should skip its automatic follow-up after every completed tool in that batch agrees to terminate.
Use sequential execution when tools share mutable in-memory state.
File-mutating tools should wrap the complete read-modify-write operation with withFileMutationQueue().
Truncate large model-facing results and tell the model where to read the complete output.
Declare outputSchema and return a matching structuredContent when the result is data. The model still receives content; programmatic callers such as codemode scripts receive structuredContent instead of the text. Tools without outputSchema are passed to scripts as their text content. To report a failure that still carries data, return the result with isError: true instead of throwing: the model sees an error, and scripts still receive structuredContent.
A tool can run other tools with ctx.executeTool(name, args, { signal, onUpdate }). Nested calls go through argument validation and the tool_call and tool_result handlers like model-issued calls, and emit tool_execution_start, tool_execution_update, and tool_execution_end; all of these events carry parentToolCallId, and their toolCallId is assigned by pi as <parent id>/<n>. These ids do not appear as tool calls or tool results in the transcript. Nested calls do not add transcript entries: their results only reach the calling tool, which reports them itself, for example through onUpdate and details. The session keeps a bounded record of them (name, arguments, status, duration, error; never results) as nestedCalls on the calling tool's result message. It is used for compaction file lists and shown in HTML exports. Arguments over 8 KiB per call or 32 KiB per tool result are omitted, at most 256 calls are kept, and complete: false marks a record that lost anything. ctx.tools lists the tools ctx.executeTool() can call. tool_result handlers that redact content should also replace structuredContent; replacing only content drops it.
See hello.ts, todo.ts, dynamic-tools.ts, and truncated-tool.ts.
Tool exposure
exposure controls how the model reaches a tool. "Callable" means callable from other tools through ctx.executeTool() (ctx.tools), as the codemode tool's scripts do:
direct(default): declared to the model while active, and callable while active.model-only: declared to the model while active, never callable. Use it for tools that orchestrate other tools or ask the user.codemode: callable whenever registered, and listed by thecodemodetool. Not declared to the model unless activated explicitly.deferred: likecodemode, but codemode tools do not list it;tool_searchcan find and activate it.hidden: registered but unreachable. Re-register a tool withexposure: "hidden"to withdraw it, since tools cannot be unregistered.
namespace: { name, description } groups related tools, as MCP servers do. Codemode tools list a namespace under one heading.
Registering a direct or model-only tool activates it; the other exposures are not activated on registration. The active set (pi.getActiveTools(), pi.setActiveTools()) is the set of tools declared to the model. pi.getAllTools() reports each tool's exposure and namespace.
A tool that orchestrates other tools can adjust what the model sees while it is active with prepareLoadout(loadout). It runs whenever the active tools change and receives the declared tools, the callable tools, and every registered tool with its exposure and namespace. It returns replacement descriptions for declared tools (including its own) and hiddenDeclarations: active tools whose declarations requests leave out while they stay active and callable. codemode and tool_search use only this hook, exposure, and ctx.executeTool(), so another tool can implement the same behavior under a different name.
Activate tools dynamically
Register every tool first, keep optional tools inactive, and use pi.setActiveTools() from a loader tool to select the desired active tools. Names must already be registered; unknown names are ignored.
Pi records the initial prompt and tool set in the transcript's first system message, then appends tool and prompt changes before the next model request. Providers that cannot represent the transition receive a complete transcript checkpoint, which can invalidate the cached prefix.
MCP servers
pi.registerMcpServer(name, config) adds an MCP server for the current session. config has the shape of an mcpServers entry in mcp.json: command, args, env, and cwd for stdio servers, url, headers, and oauth for HTTP servers, plus exposure, enabled, and timeout.
pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode" });
pi.unregisterMcpServer("jira");
Servers registered while the extension loads connect when the session starts, together with the mcp.json servers; servers registered later connect right away, and pi.unregisterMcpServer() closes the connection and makes the server's tools unreachable. Registrations are not saved: register again on every load, for example based on the extension's own settings. A server in mcp.json with the same name takes precedence, and /mcp shows the override. Registering the same name again replaces the extension's earlier registration; names registered by another extension, invalid names, and invalid configs throw.
The built-in MCP support connects registered servers. When nothing does, because another extension replaced it (see MCP), each registration is reported as an extension error. Other MCP extensions can connect registered servers too: read them with pi.getMcpServers() on session_start and handle the mcp_servers_change event for later changes.
Context and session changes
ExtensionContext provides the working directory, mode, UI, session manager, model runtime, abort signal, context usage, and controls for compaction and shutdown.
Use ctx.modelRegistry.streamSimple() for provider-neutral nested model calls.
Command handlers receive ExtensionCommandContext, which adds operations for waiting until idle, reloading, tree navigation, and session replacement.
These operations are command-only because calling them from lifecycle handlers can deadlock the runtime.
Session replacement invalidates the old context. Capture only plain data before switching, then use the fresh context supplied to withSession for session-bound work.
State
Choose storage based on how state participates in the conversation:
| State | Storage |
|---|---|
| Tool state that follows the active branch | Tool-result details |
| Durable data excluded from model context | pi.appendEntry() |
| Custom content stored and sent to the model | pi.sendMessage() |
| Data outside one session | External storage |
Reconstruct branch-sensitive state from ctx.sessionManager.getBranch() during session_start.
Do not rebuild it from every file entry because abandoned branches represent alternative histories.
Register an entry or message renderer when custom stored content should appear in the transcript.
UI and modes
ctx.ui provides dialogs, notifications, status text, widgets, titles, editor access, and custom components.
Use ctx.ui.custom() only when the interaction needs its own rendering and input.
See Terminal UI for component, focus, overlay, theme, and performance guidance.
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.
Guard terminal-only behavior with ctx.mode === "tui" and use ctx.hasUI for interactions supported by interactive and RPC clients.
Keep tool and event behavior independent from rendering so non-interactive modes remain functional.
Errors and cleanup
Pi reports handler errors and continues where possible. A tool_call handler failure blocks the tool as a fail-safe; a tool execution failure becomes an error result for the model.
Release resources in session_shutdown even when normal operation attempted cleanup.
Keep cleanup idempotent because cancellation, reload, session replacement, and process exit can converge on the same path.
Use ctx.shutdown() to request an orderly process shutdown.
Examples and reference
The checked extension examples cover tools, lifecycle events, commands, flags, shortcuts, state, rendering, providers, OAuth, remote execution, and terminal components. Start with the smallest example matching your integration point.
Use Custom Providers for model-service integrations, Terminal UI for custom components, and Pi Packages to install or distribute extensions with other resources.