singularity-forge/docs/context-and-hooks/02-hook-reference.md
2026-03-11 00:54:01 -06:00

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

  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.

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.
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_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.

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