# Hook Reference Complete behavioral specification of every hook in pi's extension system. Covers timing, chaining semantics, return shapes, and edge cases not in the extending-pi docs. --- ## Hook Categories 1. **Input hooks** — intercept user input before the agent 2. **Agent lifecycle hooks** — control the agent loop boundary 3. **Per-turn hooks** — fire on every LLM call within an agent run 4. **Tool hooks** — intercept individual tool executions 5. **Session hooks** — respond to session lifecycle changes 6. **Model hooks** — respond to model changes 7. **Resource hooks** — provide dynamic resources at startup --- ## 1. Input Hooks ### `input` **When:** User submits text (Enter in editor, RPC message, or `pi.sendUserMessage` from an extension with `source: "extension"`). **Before:** Skill expansion, template expansion, command check (extension commands are checked before `input` fires, but built-in commands are checked after). **Chaining:** Sequential through all extensions. Each handler sees the text output of the previous handler's `transform`. First `handled` stops the chain and the pipeline. ```typescript pi.on("input", async (event, ctx) => { // event.text: string — current text (possibly transformed by earlier handler) // event.images: ImageContent[] | undefined // event.source: "interactive" | "rpc" | "extension" // Option 1: Pass through return { action: "continue" }; // or return nothing (undefined) — same as continue // Option 2: Transform return { action: "transform", text: "rewritten", images: newImages }; // Option 3: Swallow (no LLM call, no further handlers) return { action: "handled" }; }); ``` **Edge cases:** - Extension commands (`/mycommand`) are checked **before** `input` fires. If it matches, `input` never fires. - Built-in commands (`/new`, `/model`, etc.) are checked **after** `input` transforms. So `input` can transform text into a built-in command, or transform a built-in command into something else. - Images can be replaced via `transform`. Omitting `images` in the transform result preserves the original images. --- ## 2. Agent Lifecycle Hooks ### `before_agent_start` **When:** After input processing, skill/template expansion, and the user message is constructed — but before `agent.prompt()` is called. **Fires:** Once per user prompt. Does NOT fire on subsequent turns within the same agent run. **Chaining:** - **System prompt:** Chains. Extension A modifies `event.systemPrompt`, Extension B sees that modified version. If no extension returns a `systemPrompt`, the base prompt is used (resetting any previous turn's modifications). - **Messages:** Accumulate. All `message` results are collected into an array. Each becomes a separate `CustomMessage` with `role: "custom"` injected after the user message. ```typescript pi.on("before_agent_start", async (event, ctx) => { // event.prompt: string — expanded user prompt text // event.images: ImageContent[] | undefined // event.systemPrompt: string — current system prompt (may be chained from earlier extension) return { // Optional: inject a custom message into the session message: { customType: "my-extension", // identifies the message type content: "Text the LLM sees", // string or (TextContent | ImageContent)[] display: true, // controls UI rendering, NOT LLM visibility details: { any: "data" }, // for custom rendering and state reconstruction }, // Optional: modify the system prompt for this agent run systemPrompt: event.systemPrompt + "\nNew instructions", }; }); ``` **Critical detail:** The `display` field controls whether the message shows in the TUI chat log. The LLM **always** sees the message content regardless of `display`. All custom messages become `user` role messages in `convertToLlm`. **Error handling:** If a handler throws, the error is captured and reported via `emitError`. Other handlers still run. The pipeline is not stopped. ### `agent_start` **When:** The agent loop begins (after `before_agent_start`, after `agent.prompt()` is called). **Fires:** Once per agent run. Informational only — no return value. ```typescript pi.on("agent_start", async (event, ctx) => { // event: { type: "agent_start" } // Useful for: starting timers, resetting per-run state }); ``` ### `agent_end` **When:** The agent loop finishes (all turns complete, no more tool calls, no queued messages). **Fires:** Once per agent run. ```typescript pi.on("agent_end", async (event, ctx) => { // event.messages: AgentMessage[] — all messages produced during this run // Useful for: final summaries, state persistence, triggering follow-up actions }); ``` **Subtlety:** `event.messages` contains only the NEW messages from this agent run, not the full conversation history. Use `ctx.sessionManager.getBranch()` for the full history. --- ## 3. Per-Turn Hooks ### `turn_start` **When:** Each turn within the agent loop begins (before the LLM call). ```typescript pi.on("turn_start", async (event, ctx) => { // event.turnIndex: number — 0-based index of this turn within the agent run // event.timestamp: number — when the turn started }); ``` ### `context` **When:** Before each LLM call, after the turn starts. This is the last chance to modify what the LLM sees. **Fires:** Every turn. If the LLM calls 3 tools and loops back, `context` fires 4 times (once for initial call + once per loop-back). **Chaining:** Sequential. Each handler receives the output of the previous. First handler gets a `structuredClone` deep copy of the agent's message array. ```typescript pi.on("context", async (event, ctx) => { // event.messages: AgentMessage[] — deep copy, safe to mutate // Filter out messages const filtered = event.messages.filter(m => !isIrrelevant(m)); return { messages: filtered }; // Or inject messages return { messages: [...event.messages, syntheticMessage] }; // Or return nothing to pass through unchanged }); ``` **What `event.messages` contains:** - All roles: `user`, `assistant`, `toolResult`, `custom`, `bashExecution`, `compactionSummary`, `branchSummary` - The user message from the current prompt - Custom messages injected by `before_agent_start` - Tool results from earlier turns in this agent run - Steering/follow-up messages that became turn inputs - Historical messages from the session (including compaction summaries) **What it does NOT contain:** - The system prompt (use `before_agent_start` for that) - Tool definitions (use `pi.setActiveTools()` for that) ### `turn_end` **When:** After the LLM responds and all tool calls for this turn complete. ```typescript pi.on("turn_end", async (event, ctx) => { // event.turnIndex: number // event.message: AgentMessage — the assistant's response message // event.toolResults: ToolResultMessage[] — results from tools called this turn }); ``` ### `message_start` / `message_update` / `message_end` **When:** Message lifecycle events. `update` only fires for assistant messages during streaming (token-by-token). ```typescript pi.on("message_start", async (event, ctx) => { // event.message: AgentMessage — user, assistant, toolResult, or custom }); pi.on("message_update", async (event, ctx) => { // event.message: AgentMessage — partial assistant message (streaming) // event.assistantMessageEvent: AssistantMessageEvent — the specific token event }); pi.on("message_end", async (event, ctx) => { // event.message: AgentMessage — final message // Messages are persisted to the session file at this point }); ``` --- ## 4. Tool Hooks ### `tool_call` **When:** After the LLM requests a tool call, before it executes. **Chaining:** Sequential. If any handler returns `{ block: true }`, execution stops immediately. The block reason becomes an Error that is caught and returned as the tool result with `isError: true`. ```typescript pi.on("tool_call", async (event, ctx) => { // event.toolCallId: string // event.toolName: string // event.input: typed based on tool (use isToolCallEventType for narrowing) // Block execution return { block: true, reason: "Not allowed in read-only mode" }; // Allow execution (return nothing or undefined) }); ``` **Type narrowing:** ```typescript import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; pi.on("tool_call", async (event, ctx) => { if (isToolCallEventType("bash", event)) { event.input.command; // string — typed! } if (isToolCallEventType("write", event)) { event.input.path; // string event.input.content; // string } // Custom tools need explicit type params: if (isToolCallEventType<"my_tool", { action: string }>("my_tool", event)) { event.input.action; // string } }); ``` ### `tool_execution_start` / `tool_execution_update` / `tool_execution_end` Informational events during tool execution. No return values. ```typescript pi.on("tool_execution_start", async (event) => { // event.toolCallId, event.toolName, event.args }); pi.on("tool_execution_update", async (event) => { // event.partialResult — streaming progress from onUpdate callback }); pi.on("tool_execution_end", async (event) => { // event.result, event.isError }); ``` ### `tool_result` **When:** After a tool finishes executing, before the result is returned to the agent loop. **Chaining:** Sequential. Each handler can modify the result. Modifications accumulate across handlers. All handlers see the evolving `currentEvent` with content/details/isError updated by previous handlers. ```typescript pi.on("tool_result", async (event, ctx) => { // event.toolCallId: string // event.toolName: string // event.input: Record // event.content: (TextContent | ImageContent)[] // event.details: unknown // event.isError: boolean // Modify the result return { content: [...event.content, { type: "text", text: "\n\nAudit: logged" }], isError: false, // can flip error state }; // Return nothing to pass through unchanged }); ``` **Also fires for errors:** If tool execution throws, `tool_result` still fires with `isError: true` and the error message as content. Extensions can modify even error results. --- ## 5. Session Hooks ### `session_start` **When:** Initial session load (startup) and after session switch/fork. Also fires after `/reload`. **Use for:** State restoration from session entries, initial setup. ```typescript pi.on("session_start", async (_event, ctx) => { // Restore state from session for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === "my-state") { myState = entry.data; } } }); ``` ### `session_before_switch` / `session_switch` **When:** Before/after `/new` or `/resume`. ```typescript pi.on("session_before_switch", async (event) => { // event.reason: "new" | "resume" // event.targetSessionFile?: string (only for resume) return { cancel: true }; // prevent the switch }); ``` ### `session_before_fork` / `session_fork` **When:** Before/after `/fork`. ```typescript pi.on("session_before_fork", async (event) => { // event.entryId: string — the entry being forked from return { cancel: true }; // or return { skipConversationRestore: true }; // fork without restoring messages }); ``` ### `session_before_compact` / `session_compact` **When:** Before/after compaction (manual or auto). ```typescript pi.on("session_before_compact", async (event) => { // event.preparation: CompactionPreparation // event.branchEntries: SessionEntry[] // event.customInstructions?: string // event.signal: AbortSignal return { cancel: true }; // or provide custom compaction: return { compaction: { summary: "My custom summary", firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, } }; }); ``` ### `session_before_tree` / `session_tree` **When:** Before/after `/tree` navigation. ```typescript pi.on("session_before_tree", async (event) => { // event.preparation: TreePreparation // event.signal: AbortSignal return { cancel: true }; // or provide custom summary: return { summary: { summary: "Custom branch summary" }, label: "my-label", }; }); ``` ### `session_shutdown` **When:** Process exit (Ctrl+C, Ctrl+D, SIGTERM, `ctx.shutdown()`). ```typescript pi.on("session_shutdown", async (_event, ctx) => { // Last chance to persist state // Keep it fast — process is exiting }); ``` --- ## 6. Model Hooks ### `model_select` **When:** Model changes via `/model`, Ctrl+P cycling, or session restore. ```typescript pi.on("model_select", async (event, ctx) => { // event.model: Model — the new model // event.previousModel: Model | undefined // event.source: "set" | "cycle" | "restore" }); ``` --- ## 7. Resource Hooks ### `resources_discover` **When:** At startup and after `/reload`. Lets extensions provide additional skill, prompt template, and theme paths. **Not documented in extending-pi docs.** This is how extensions ship their own resources. ```typescript pi.on("resources_discover", async (event, ctx) => { // event.cwd: string // event.reason: "startup" | "reload" return { skillPaths: [join(__dirname, "skills", "SKILL.md")], promptPaths: [join(__dirname, "prompts", "my-template.md")], themePaths: [join(__dirname, "themes", "dark.json")], }; }); ``` **Behavior:** Returned paths are loaded by the resource loader and integrated into the system prompt (skills) and available commands (prompts/themes). The system prompt is rebuilt after resources are extended. --- ## 8. User Bash Hooks ### `user_bash` **When:** User executes a command via `!` or `!!` prefix in the editor. ```typescript pi.on("user_bash", async (event, ctx) => { // event.command: string // event.excludeFromContext: boolean (true if !! prefix) // event.cwd: string // Provide custom execution (e.g., SSH) return { operations: { execute: (cmd) => sshExec(remote, cmd) }, }; // Or provide a full replacement result return { result: { output: "custom output", exitCode: 0, cancelled: false, truncated: false }, }; }); ``` --- ## Execution Order Across Extensions All hooks iterate through extensions in **load order** (project-local first, then global, then explicitly configured via `-e`). Within each extension, handlers for the same event run in registration order. For hooks that chain (e.g., `context`, `before_agent_start.systemPrompt`, `input`, `tool_result`): - Extension A's handler runs first, Extension B sees A's output - Load order determines priority For hooks that short-circuit (e.g., `tool_call` with `block`, `input` with `handled`, session `cancel`): - First extension to return the short-circuit value wins - Remaining handlers are skipped