skills/pi-agent/references/extensions.md
Source: https://pi.dev/docs/latest/extensions
Extensions are TypeScript modules that extend Pi. They register tools, commands, shortcuts, CLI flags, providers, renderers, UI, event handlers, and persistent session entries. They run with the full permissions of the Pi process — only install extensions you trust.
~/.pi/agent/extensions/*.ts and ~/.pi/agent/extensions/*/index.ts (global).pi/extensions/*.ts and .pi/extensions/*/index.ts (project-local, loaded only after project trust)settings.json extensions / packagesUse pi -e ./my-extension.ts for quick tests only; auto-discovered extensions can be hot-reloaded with /reload. Extensions load via jiti, so TypeScript needs no compilation.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded", "info");
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({ name: Type.String() }),
async execute(_toolCallId, params) {
return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {} };
},
});
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => ctx.ui.notify(`Hello ${args || "world"}`, "info"),
});
}
@earendil-works/pi-coding-agent (extension types and APIs), typebox (tool parameter schemas), @earendil-works/pi-ai (StringEnum for Google-compatible enums), @earendil-works/pi-tui (TUI components), plus Node built-ins. Add a package.json next to the extension and run npm install for npm dependencies. Distributed packages must put runtime deps in dependencies — package installs use npm install --omit=dev.
The default export receives ExtensionAPI and may be async; Pi awaits it before session_start, resources_discover, and flushing queued pi.registerProvider() calls. Use async factories for one-time startup work such as dynamic model discovery. Do not start background resources (processes, sockets, watchers, timers) in the factory — factories can run in invocations that never start a session. Start them in session_start or on demand, and close them in an idempotent session_shutdown handler.
Startup: project_trust (user/global and CLI -e extensions only) → session_start { reason: "startup" } → resources_discover { reason: "startup" }.
Prompt: extension commands checked first (they bypass input) → input → skill/template expansion → before_agent_start → agent_start → message events → per turn: turn_start, context, before_provider_headers, before_provider_request, after_provider_response, then tool_execution_start, tool_call, tool_execution_update, tool_result, tool_execution_end → turn_end → agent_end → agent_settled.
Session replacement (/new, /resume): session_before_switch (cancellable) → session_shutdown → session_start { reason: "new" | "resume", previousSessionFile } → resources_discover. /fork and /clone use session_before_fork (with position: "before" | "at") then reason: "fork". /name emits session_info_changed. Compaction: session_before_compact → session_compact. /tree: session_before_tree → session_tree. Model changes: thinking_level_select then model_select. Exit: session_shutdown.
project_trust — must return { trusted: "yes" | "no" | "undecided", remember?: boolean }. First yes/no decision wins and suppresses the built-in prompt. ctx is a limited trust context (cwd, mode, hasUI, select/confirm/input/notify).resources_discover — return { skillPaths, promptPaths, themePaths }.before_agent_start — return { message } to inject a persistent custom message and/or { systemPrompt } to replace it for this turn (chained across handlers). event.systemPromptOptions exposes the structured inputs Pi used: customPrompt, selectedTools, toolSnippets, promptGuidelines, appendSystemPrompt, cwd, contextFiles, skills.context — event.messages is a deep copy; return { messages } to modify what the LLM sees.before_provider_headers — mutate event.headers in place; a string adds/overrides, null deletes. Fires once per request; retries reuse the headers.before_provider_request — inspect or replace event.payload; handlers run in load order and undefined keeps it unchanged. Payload-level system-instruction rewrites are not reflected by ctx.getSystemPrompt().after_provider_response — event.status and normalized event.headers before the stream body is consumed.tool_call — event.input is mutable and mutations affect execution (no re-validation); return { block: true, reason? } to block. Narrow with isToolCallEventType("bash", event), or isToolCallEventType<"my_tool", MyToolInput>(...) for custom tools.tool_result — middleware-style chain; return partial patches (content, details, isError, usage). Use isBashToolResult(event) for typed bash details and ctx.signal for nested async work.message_end — return { message } to replace the finalized message; the replacement must keep the same role.user_bash — intercept !/!!: return { operations } (optionally wrapping createLocalBashOperations()) or { result }.input — sees raw text before skill/template expansion. Return { action: "continue" | "transform" | "handled" }; event.source is "interactive" | "rpc" | "extension" and event.streamingBehavior is "steer" | "followUp" | undefined.In parallel tool mode, sibling tool calls are preflighted sequentially then executed concurrently, so tool_call is not guaranteed to see sibling tool results; tool_result/tool_execution_end may interleave in completion order while final toolResult message events stay in assistant source order.
ctx.ui (see Custom UI), ctx.mode ("tui" | "rpc" | "json" | "print"), ctx.hasUI (true in TUI and RPC), ctx.cwd, ctx.signal (agent abort signal; usually undefined outside active turns), ctx.isProjectTrusted(), ctx.sessionManager (read-only: getEntries, getBranch, buildContextEntries, getLeafId, …), ctx.modelRegistry (getProvider(id), getProviderAuth(id), find(...)), ctx.model, ctx.thinkingLevel, ctx.isIdle(), ctx.abort(), ctx.hasPendingMessages(), ctx.shutdown(), ctx.getContextUsage(), ctx.compact({ customInstructions, onComplete, onError }), ctx.getSystemPrompt().
Use the exported CONFIG_DIR_NAME instead of hardcoding .pi — rebranded distributions use a different name.
Command handlers additionally get session-control methods that would deadlock from event handlers: getSystemPromptOptions(), waitForIdle(), newSession({ parentSession, setup, withSession }), fork(entryId, { position: "before" | "at", withSession }), navigateTree(targetId, { summarize, customInstructions, replaceInstructions, label }), switchSession(path, { withSession }), reload().
withSession receives a fresh ReplacedSessionContext with async sendMessage()/sendUserMessage(). It runs only after the old session emitted session_shutdown and the new instance already received session_start, but still executes in the original closure — so captured old pi/ctx/sessionManager objects are stale and throw. Capture only plain data (strings, ids) across the boundary. Treat await ctx.reload() as terminal for that handler (await ctx.reload(); return;); tools cannot call it, so expose a command and have the tool queue it with pi.sendUserMessage("/my-reload", { deliverAs: "followUp" }).
Tools: registerTool(definition) (works during load and at runtime — new tools are callable without /reload), getActiveTools(), getAllTools() (returns name, description, parameters, promptGuidelines, sourceInfo), setActiveTools(names).
Messages and session: sendMessage(message, { deliverAs: "steer" | "followUp" | "nextTurn", triggerTurn }), sendUserMessage(content, { deliverAs }) (required while streaming), appendEntry(customType, data), setSessionName, getSessionName, setLabel(entryId, label).
Commands and input: registerCommand(name, { description, handler, getArgumentCompletions }) (duplicate names get :1/:2 suffixes in load order), getCommands() (extension → prompt → skill order, each with sourceInfo.scope/origin), registerShortcut(key, options), registerFlag(name, options) + getFlag(name).
Rendering: registerMessageRenderer(customType, renderer) (custom messages, in LLM context), registerEntryRenderer(customType, renderer) (custom entries, TUI only).
Model and provider: setModel(model) (returns false without an API key), getThinkingLevel(), setThinkingLevel(level), registerProvider(nameOrProvider, config?), unregisterProvider(name). Calls after the load phase take effect immediately. Dynamic providers can implement refreshModels, and a complete pi-ai Provider from createProvider(...) can be registered as the composition base with models.json overrides layered above.
Other: exec(command, args, { signal, timeout }) → { stdout, stderr, code, killed }, on(event, handler), events (inter-extension bus).
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does (shown to the LLM)",
promptSnippet: "One-line entry in the system prompt's Available tools section",
promptGuidelines: ["Use my_tool when the user asks to summarize generated text."],
parameters: Type.Object({ action: StringEnum(["list", "add"] as const), text: Type.Optional(Type.String()) }),
prepareArguments(args) { return args; },
async execute(toolCallId, params, signal, onUpdate, ctx) {
onUpdate?.({ content: [{ type: "text", text: "Working..." }], details: { progress: 50 } });
return { content: [{ type: "text", text: "Done" }], details: {}, terminate: true };
},
renderShell: "self",
renderCall(args, theme, context) { /* Component */ },
renderResult(result, options, theme, context) { /* Component */ },
});
StringEnum from @earendil-works/pi-ai for string enums; Type.Union/Type.Literal breaks Google's API.promptGuidelines bullets are appended flat with no tool-name prefix — always name the tool ("Use my_tool when…"), never "this tool".prepareArguments runs before schema validation; use it to fold legacy argument shapes from resumed sessions instead of loosening parameters.isError.terminate: true hints that the follow-up LLM call should be skipped, and only applies when every finalized result in the batch terminates.usage for nested LLM calls; Pi persists it and includes it in footer, /session, and RPC totals.@ from path arguments (some models add it), and wrap read-modify-write windows in withFileMutationQueue(absolutePath, fn) so the tool shares the per-file queue with built-in edit/write — tools run in parallel by default. Resolve to an absolute path first; the helper canonicalizes existing files through realpath().truncateHead (beginning matters), truncateTail (end matters), truncateLine, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, and tell the LLM where the full output was saved.Overriding built-ins (read, bash, edit, write, grep, find, ls) works by registering the same name; interactive mode warns. Renderer inheritance is per slot, so omitting renderCall/renderResult keeps the built-in UI. promptSnippet/promptGuidelines are not inherited. The result shape, including details, must match exactly.
Remote execution: built-in tool factories accept pluggable operations (ReadOperations, WriteOperations, EditOperations, BashOperations, LsOperations, GrepOperations, FindOperations). createBashTool(cwd, { spawnHook, exposeSessionEnvironment }) can rewrite command/cwd/env; session variables are injected before spawnHook (references/environment-variables.md).
Register every tool, keep only loader tools active, then call pi.setActiveTools([...current, ...matched]) during loader execution. The change must be purely additive. Pi records the added names on the loader's tool result and exposes the definitions before the next model request — natively via defer_loading/tool_reference on Anthropic Sonnet/Opus/Fable 4.5+ and via tool_search_call/tool_search_output on OpenAI gpt-5.4+, otherwise by sending the normal active tool list. Verified custom endpoints can opt in with compat.supportsToolReferences (anthropic-messages) or compat.supportsToolSearch (openai-responses / openai-codex-responses). Non-additive changes fall back to the full list. Lazily loaded tools should rely on description and omit promptSnippet/promptGuidelines, which rebuild the system prompt and can invalidate the cached prefix.
Store state in tool result details so branching works, and rebuild it in session_start by walking ctx.sessionManager.getBranch(). pi.appendEntry(customType, data) persists extension state that does not enter LLM context.
Dialogs: ctx.ui.select(title, options), confirm(title, message), input(prompt, placeholder), editor(title, prefill), notify(message, "info" | "warning" | "error"). Dialogs accept { timeout } (live countdown; select/input return undefined, confirm returns false) or { signal } when you need to distinguish timeout from user cancel.
Chrome: setStatus(key, text), setWidget(key, lines | factory, { placement: "aboveEditor" | "belowEditor" }), setFooter(factory), setHeader(...), setTitle(text), setEditorText / getEditorText / pasteToEditor, setWorkingMessage, setWorkingVisible, setWorkingIndicator({ frames, intervalMs }), setToolsExpanded / getToolsExpanded, setEditorComponent(factory) / getEditorComponent(), addAutocompleteProvider(current => provider), getAllThemes / getTheme / setTheme / theme, and custom(factory, { overlay, overlayOptions, onHandle }).
Custom-component details, overlays, built-in components, and copy-paste patterns are in references/tui.md. Syntax highlighting helpers: highlightCode(code, lang, theme), getLanguageFromPath(path). Keybinding hints: keyHint(id, description), keyText(id), rawKeyHint(key, description) with namespaced ids (app.* for the coding agent, tui.* for shared TUI).
Extension errors are logged and the agent continues; tool_call errors block the tool (fail-safe); tool execute errors must be thrown and are reported to the LLM with isError: true.
| Mode | ctx.mode | ctx.hasUI | Notes |
|---|---|---|---|
| Interactive | "tui" | true | Full TUI |
| RPC | "rpc" | true | Dialogs/notifications over the JSON protocol; custom() returns undefined |
| JSON | "json" | false | UI methods are no-ops |
Print (-p) | "print" | false | Extensions run but cannot prompt |
Guard TUI-only features with ctx.mode === "tui"; guard dialogs and notifications with ctx.hasUI.