Hooks are event-driven interceptors that run alongside the agent loop. They are best used for cross-cutting concerns: safety policy, secret redaction, context pruning, audit logging. A hook module registers handlers via pi.on(event, handler) and can block tool execution, override tool output, or rewrite the message context before each LLM call.

Relationship to extensions: The hook subsystem (HookAPI) is the legacy API. The extension runner now handles everything hooks can do plus more. ExtensionAPI supports the hook event model plus extension-only events. Use ExtensionAPI for new work; use HookAPI only if you are maintaining an existing hook module.

Factory signature

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"
 
export default function myHook(omp: HookAPI): void {
  omp.on("tool_call", async (event, ctx) => {
    // intercept every tool call
  })
}

The default export must be a plain function (not async, not a class). It receives a HookAPI instance and must register all handlers synchronously during execution.

Alternatively, using ExtensionAPI (preferred):

import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"
 
export default function myExtension(pi: ExtensionAPI): void {
  pi.on("tool_call", async (event, ctx) => {
    /* ... */
  })
}

Event catalog

Tool lifecycle

EventFiresCan return
tool_callBefore every tool execution{ block?: boolean; reason?: string }
tool_resultAfter every tool execution{ content?; details?; isError?: boolean }

Session lifecycle

EventFiresCan return
session_startOn initial session loadβ€”
session_before_switchBefore session switch{ cancel?: boolean }
session_switchAfter session switchβ€”
session_before_branchBefore session branch{ cancel?: boolean; skipConversationRestore?: boolean }
session_branchAfter session branchβ€”
session_before_compactBefore compaction{ cancel?: boolean; compaction?: CompactionResult }
session.compactingDuring compaction (inject context){ context?: string[]; prompt?: string; preserveData?: Record<string, unknown> }
session_compactAfter compactionβ€”
session_before_treeBefore tree navigation{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
session_treeAfter tree navigationβ€”
session_shutdownOn session shutdownβ€”

Agent/turn lifecycle

EventFiresCan return
before_agent_startBefore agent starts a turn{ message?: { customType; content; display; details; attribution? } }
agent_startAgent streaming startsβ€”
agent_endAgent streaming endsβ€”
turn_startStart of a user→agent turn—
turn_endEnd of a user→agent turn—
contextBefore each LLM API call{ messages?: Message[] }
auto_compaction_startAuto-compaction beginsβ€”
auto_compaction_endAuto-compaction endsβ€”
auto_retry_startAuto-retry beginsβ€”
auto_retry_endAuto-retry endsβ€”
ttsr_triggeredTTSR (too-short response) triggeredβ€”
todo_reminderTodo reminder firesβ€”

Extension-only events such as tool_execution_start, tool_execution_update, tool_execution_end, input, user_bash, and user_python require ExtensionAPI.

Pre-tool blocking contract

Return { block: true, reason: "..." } from a tool_call handler to prevent execution:

omp.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash") {
    const cmd = String(event.input.command ?? "")
    if (/\brm\s+-rf\s+\//.test(cmd)) {
      return { block: true, reason: "Refusing to delete root filesystem" }
    }
  }
})

Contract:

  • If any handler returns { block: true }, execution stops immediately.
  • reason is returned to the LLM as the tool error text.
  • If a handler throws, the tool is also blocked (fail-closed).
  • Last non-blocking return wins for non-blocking results; first block: true short-circuits.

Post-tool override contract

Return { content, details, isError } from a tool_result handler to patch what the LLM sees:

omp.on("tool_result", async (event, ctx) => {
  if (event.toolName === "read" && !event.isError) {
    const redacted = event.content.map((chunk) => {
      if (chunk.type !== "text") return chunk
      return {
        ...chunk,
        text: chunk.text.replace(/(?:sk|pk)-[a-zA-Z0-9]{20,}/g, "[REDACTED_API_KEY]"),
      }
    })
    return { content: redacted }
  }
})

Contract:

  • Handlers run in registration order. For HookAPI, each handler receives the original tool result event, and the last returned override wins.
  • content replaces the full content array for the LLM.
  • details replaces the structured details object.
  • isError exists on the shared result type, but HookToolWrapper does not propagate it into a successful tool result; on a tool failure, the original error is rethrown after handlers complete.
  • On a tool failure, tool_result is still emitted with isError: true.

Context modification contract

Return { messages: [...] } from a context handler to rewrite the message list before each LLM API call:

omp.on("context", async (event, ctx) => {
  // Remove debug-only custom messages from LLM context
  const filtered = event.messages.filter(
    (msg) => !(msg.role === "custom" && msg.customType === "debug-only"),
  )
  return { messages: filtered }
})

Contract:

  • event.messages is the current accumulated list.
  • Handlers run in order; each receives the output of the previous handler.
  • Return undefined (or nothing) to pass messages through unmodified.

Three complete examples

1. rm-rf blocker

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"
 
export default function rmRfBlocker(omp: HookAPI): void {
  omp.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return
 
    const cmd = String(event.input.command ?? "")
    if (!/\brm\s+-rf\s+\//.test(cmd)) return
 
    // Allow if user explicitly confirms (interactive mode only)
    if (ctx.hasUI) {
      const allow = await ctx.ui.confirm(
        "Dangerous command",
        `This command deletes from root:\n${cmd}\n\nProceed?`,
      )
      if (allow) return
    }
 
    return { block: true, reason: "rm -rf / blocked by safety policy" }
  })
}

2. API-key redactor

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"
 
// Common API-key shapes. Not exhaustive β€” providers using bespoke formats
// (Anthropic `sk-ant-…`, JWT-style bearers, gateway-specific prefixes, etc.)
// need their own entries.
const SECRET_PATTERNS = [
  /\b(sk|pk)-[a-zA-Z0-9]{20,}\b/g,
  /\bAKIA[A-Z0-9]{16}\b/g,
  /\bghp_[a-zA-Z0-9]{36}\b/g,
  // Zhipu / GLM Coding Plan: `<id>.<secret>` (no `sk-` prefix).
  /\b[a-zA-Z0-9]{16,}\.[a-zA-Z0-9]{16,}\b/g,
  /\b[a-zA-Z0-9_-]{20,}\s*=\s*["']?[a-zA-Z0-9._/+=-]{20,}["']?/g,
]
 
export default function apiKeyRedactor(omp: HookAPI): void {
  omp.on("tool_result", async (event) => {
    if (event.isError) return
 
    let changed = false
    const redacted = event.content.map((chunk) => {
      if (chunk.type !== "text") return chunk
      let text = chunk.text
      for (const pattern of SECRET_PATTERNS) {
        const next = text.replace(pattern, "[REDACTED]")
        if (next !== text) {
          changed = true
          text = next
        }
      }
      return { ...chunk, text }
    })
 
    if (changed) return { content: redacted }
  })
}

3. Context filter

import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"
 
export default function contextFilter(omp: HookAPI): void {
  omp.on("context", async (event) => {
    const MAX_TOOL_OUTPUT_CHARS = 8_000
 
    const trimmed = event.messages.map((msg) => {
      // Truncate very large tool results to keep context manageable
      if (msg.role !== "toolResult") return msg
      const content = msg.content.map((chunk) => {
        if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT_CHARS) return chunk
        return {
          ...chunk,
          text:
            chunk.text.slice(0, MAX_TOOL_OUTPUT_CHARS) + "\n[... truncated by context-filter hook]",
        }
      })
      return { ...msg, content }
    })
 
    return { messages: trimmed }
  })
}

UI methods in hook context

ctx.ui is a HookUIContext. Available methods:

MethodDescription
notify(message, type?)Show an in-app notification
setStatus(key, text)Set footer status text (keyed, sorted by key)
select(title, options)Show a selection dialog
confirm(title, message)Show a yes/no dialog
input(title, placeholder?)Show a text input dialog
editor(title, prefill?, { signal }?, { promptStyle }?)Show a multi-line editor
setEditorText(text)Set the input editor content
getEditorText()Get current input editor content
custom(factory)Render a custom TUI component
themeCurrent theme object

Pass { promptStyle: true } as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and submits on the app.message.followUp chord (Ctrl+Q or Ctrl+Enter).

ctx.hasUI is false in headless/print/subagent mode β€” always guard interactive calls.

Further reading

  • docs/hooks.md β€” hook subsystem internals, ordering rules, error propagation
  • docs/extensions.md β€” ExtensionAPI (superset of HookAPI)
  • docs/skills/examples/safety-hook/ β€” complete working example