Extensions
Primary guide for authoring runtime extensions in packages/coding-agent.
This document covers the current extension runtime in:
src/extensibility/extensions/types.tssrc/extensibility/extensions/runner.tssrc/extensibility/extensions/wrapper.tssrc/extensibility/extensions/index.tssrc/modes/controllers/extension-ui-controller.ts
For discovery paths and filesystem loading rules, see docs/extension-loading.md.
What an extension is
An extension is a TS/JS module exporting a default factory:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// register handlers/tools/commands/renderers
}Extensions can combine all of the following in one module:
- event handlers (
pi.on(...)) - LLM-callable tools (
pi.registerTool(...)) - slash commands (
pi.registerCommand(...)) - keyboard shortcuts and flags
- custom message rendering
- session/message injection APIs (
sendMessage,sendUserMessage,appendEntry)
Runtime model
- Extensions are imported and their factory functions run.
- During that load phase, registration methods are valid; runtime action methods are not yet initialized.
ExtensionRunner.initialize(...)wires live actions/contexts for the active mode.- Session/agent/tool lifecycle events are emitted to handlers.
- Every tool execution is wrapped with extension interception (
tool_call/tool_result).
Extension lifecycle (simplified)
load paths
│
▼
import module + run factory (registration only)
│
▼
ExtensionRunner.initialize(mode/session/tool registry)
│
├─ emit session/agent events to handlers
├─ wrap tool execution (tool_call/tool_result)
└─ expose runtime actions (sendMessage, setActiveTools, ...)Important constraint from loader.ts:
- calling action methods like
pi.sendMessage()during extension load throwsExtensionRuntimeNotInitializedError - register first; perform runtime behavior from events/commands/tools
Quick start
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
pi.setLabel("Safety + Utilities");
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info");
});
pi.on("tool_call", async (event) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
return { block: true, reason: "Blocked by extension policy" };
}
});
pi.registerTool({
name: "hello_extension",
label: "Hello Extension",
description: "Return a greeting",
parameters: Type.Object({ name: Type.String() }),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}` }],
details: { greeted: params.name },
};
},
});
pi.registerCommand("hello-ext", {
description: "Show queue state",
handler: async (_args, ctx) => {
ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info");
},
});
}Extension API surfaces
1) Registration and actions (ExtensionAPI)
Core methods:
on(event, handler)registerTool,registerCommand,registerShortcut,registerFlagregisterMessageRenderersendMessage,sendUserMessage,appendEntrygetActiveTools,getAllTools,setActiveToolssetModel,getThinkingLevel,setThinkingLevelregisterProviderevents(shared event bus)
Also exposed:
pi.loggerpi.typeboxpi.pi(package exports)
Message delivery semantics
pi.sendMessage(message, options) supports:
deliverAs: "steer"(default) — interrupts current rundeliverAs: "followUp"— queued to run after current rundeliverAs: "nextTurn"— stored and injected on the next user prompttriggerTurn: true— starts a turn when idle (nextTurnignores this)
pi.sendUserMessage(content, { deliverAs }) always goes through prompt flow; while streaming it queues as steer/follow-up.
2) Handler context (ExtensionContext)
Handlers and tool execute receive ctx with:
uihasUIcwdsessionManager(read-only)modelRegistry,modelgetContextUsage()compact(...)isIdle(),hasPendingMessages(),abort()shutdown()getSystemPrompt()
3) Command context (ExtensionCommandContext)
Command handlers additionally get:
waitForIdle()newSession(...)switchSession(...)branch(entryId)navigateTree(targetId, { summarize })reload()
Use command context for session-control flows; these methods are intentionally separated from general event handlers.
Event surface (current names and behavior)
Canonical event unions and payload types are in types.ts.
Session lifecycle
session_startsession_before_switch/session_switchsession_before_branch/session_branchsession_before_compact/session.compacting/session_compactsession_before_tree/session_treesession_shutdown
Cancelable pre-events:
session_before_switch→{ cancel?: boolean }session_before_branch→{ cancel?: boolean; skipConversationRestore?: boolean }session_before_compact→{ cancel?: boolean; compaction?: CompactionResult }session_before_tree→{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
Prompt and turn lifecycle
inputbefore_agent_startcontextagent_start/agent_endturn_start/turn_endmessage_start/message_update/message_end
Tool lifecycle
tool_call(pre-exec, may block)tool_result(post-exec, may patch content/details/isError)tool_execution_start/tool_execution_update/tool_execution_end(observability)
tool_result is middleware-style: handlers run in extension order and each sees prior modifications.
Reliability/runtime signals
auto_compaction_start/auto_compaction_endauto_retry_start/auto_retry_endttsr_triggeredtodo_reminder
User command interception
user_bash(override with{ result })user_python(override with{ result })
resources_discover
resources_discover exists in extension types and ExtensionRunner. Current runtime note: ExtensionRunner.emitResourcesDiscover(...) is implemented, but there are no AgentSession callsites invoking it in the current codebase.
Tool authoring details
registerTool uses ToolDefinition from types.ts.
Current execute signature:
execute(
toolCallId,
params,
signal,
onUpdate,
ctx,
): Promise<AgentToolResult>Template:
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "...",
parameters: Type.Object({}),
async execute(_id, _params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return { content: [{ type: "text", text: "Done" }], details: {} };
},
onSession(event, ctx) {
// reason: start|switch|branch|tree|shutdown
},
renderCall(args, theme) {
// optional TUI render
},
renderResult(result, options, theme, args) {
// optional TUI render
},
});tool_call/tool_result intercept all tools once the registry is wrapped in sdk.ts, including built-ins and extension/custom tools.
UI integration points
ctx.ui implements the ExtensionUIContext interface. Support differs by mode.
Interactive mode (extension-ui-controller.ts)
Supported:
- dialogs:
select,confirm,input,editor - notifications/status/editor text/terminal input/custom overlays
- theme listing/loading by name (
setThemesupports string names) - tools expanded toggle
Current no-op methods in this controller:
setFootersetHeadersetEditorComponent
Also note: setWidget currently routes to status-line text via setHookWidget(...).
RPC mode (rpc-mode.ts)
ctx.ui is backed by RPC extension_ui_request events:
- dialog methods (
select,confirm,input,editor) round-trip to client responses - fire-and-forget methods emit requests (
notify,setStatus,setWidgetfor string arrays,setTitle,setEditorText)
Unsupported/no-op in RPC implementation:
onTerminalInputcustomsetFooter,setHeader,setEditorComponentsetWorkingMessage- theme switching/loading (
setThemereturns failure) - tool expansion controls are inert
Print/headless/subagent paths
When no UI context is supplied to runner init, ctx.hasUI is false and methods are no-op/default-returning.
Background interactive mode
Background mode installs a non-interactive UI context object. In current implementation, ctx.hasUI may still be true while interactive dialogs return defaults/no-op behavior.
Session and state patterns
For durable extension state:
- Persist with
pi.appendEntry(customType, data). - Rebuild state from
ctx.sessionManager.getBranch()onsession_start,session_branch,session_tree. - Keep tool result
detailsstructured when state should be visible/reconstructible from tool result history.
Example reconstruction pattern:
pi.on("session_start", async (_event, ctx) => {
let latest;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "my-state") {
latest = entry.data;
}
}
// restore from latest
});Rendering extension points
Custom message renderer
pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
// return pi-tui Component
});Used by interactive rendering when custom messages are displayed.
Tool call/result renderer
Provide renderCall / renderResult on registerTool definitions for custom tool visualization in TUI.
Constraints and pitfalls
- Runtime actions are unavailable during extension load.
tool_callerrors block execution (fail-closed).- Command name conflicts with built-ins are skipped with diagnostics.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,shift+tab,shift+ctrl+p,alt+enter,escape,enter). - Treat
ctx.reload()as terminal for the current command handler frame.
Extensions vs hooks vs custom-tools
Use the right surface:
- Extensions (
src/extensibility/extensions/*): unified system (events + tools + commands + renderers + provider registration). - Hooks (
src/extensibility/hooks/*): separate legacy event API. - Custom-tools (
src/extensibility/custom-tools/*): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers.
If you need one package that owns policy, tools, command UX, and rendering together, use extensions.