15 KiB
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
- Input hooks — intercept user input before the agent
- Agent lifecycle hooks — control the agent loop boundary
- Per-turn hooks — fire on every LLM call within an agent run
- Tool hooks — intercept individual tool executions
- Session hooks — respond to session lifecycle changes
- Model hooks — respond to model changes
- 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.
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 beforeinputfires. If it matches,inputnever fires. - Built-in commands (
/new,/model, etc.) are checked afterinputtransforms. Soinputcan transform text into a built-in command, or transform a built-in command into something else. - Images can be replaced via
transform. Omittingimagesin 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 asystemPrompt, the base prompt is used (resetting any previous turn's modifications). - Messages: Accumulate. All
messageresults are collected into an array. Each becomes a separateCustomMessagewithrole: "custom"injected after the user message.
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.
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.
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).
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.
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_startfor that) - Tool definitions (use
pi.setActiveTools()for that)
turn_end
When: After the LLM responds and all tool calls for this turn complete.
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).
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.
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:
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.
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.
pi.on("tool_result", async (event, ctx) => {
// event.toolCallId: string
// event.toolName: string
// event.input: Record<string, unknown>
// 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.
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.
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.
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).
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.
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()).
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.
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.
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.
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