name: authoring-hooks description: Use when creating a new omp hook. Covers HookAPI, event catalog, blocking/overriding tool calls, and context modification.
Authoring Hooks
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.ExtensionAPIsupports the hook event model plus extension-only events. UseExtensionAPIfor new work; useHookAPIonly 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
| Event | Fires | Can return |
|---|---|---|
tool_call |
Before every tool execution | { block?: boolean; reason?: string } |
tool_result |
After every tool execution | { content?; details?; isError?: boolean } |
Session lifecycle
| Event | Fires | Can return |
|---|---|---|
session_start |
On initial session load | — |
session_before_switch |
Before session switch | { cancel?: boolean } |
session_switch |
After session switch | — |
session_before_branch |
Before session branch | { cancel?: boolean; skipConversationRestore?: boolean } |
session_branch |
After session branch | — |
session_before_compact |
Before compaction | { cancel?: boolean; compaction?: CompactionResult } |
session.compacting |
During compaction (inject context) | { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } |
session_compact |
After compaction | — |
session_before_tree |
Before tree navigation | { cancel?: boolean; summary?: { summary: string; details?: unknown } } |
session_tree |
After tree navigation | — |
session_shutdown |
On session shutdown | — |
Agent/turn lifecycle
| Event | Fires | Can return |
|---|---|---|
before_agent_start |
Before agent starts a turn | { message?: { customType; content; display; details; attribution? } } |
agent_start |
Agent streaming starts | — |
agent_end |
Agent streaming ends | — |
turn_start |
Start of a user→agent turn | — |
turn_end |
End of a user→agent turn | — |
context |
Before each LLM API call | { messages?: Message[] } |
auto_compaction_start |
Auto-compaction begins | — |
auto_compaction_end |
Auto-compaction ends | — |
auto_retry_start |
Auto-retry begins | — |
auto_retry_end |
Auto-retry ends | — |
ttsr_triggered |
TTSR (too-short response) triggered | — |
todo_reminder |
Todo 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. reasonis 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: trueshort-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. contentreplaces the full content array for the LLM.detailsreplaces the structured details object.isErrorexists on the shared result type, butHookToolWrapperdoes 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_resultis still emitted withisError: 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.messagesis 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";
// Matches common API key patterns: sk-..., pk-..., AKIA..., ghp_..., etc.
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,
/\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 !== "tool") 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:
| Method | Description |
|---|---|
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 |
theme |
Current 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 Ctrl+Enter as submit.
ctx.hasUI is false in headless/print/subagent mode — always guard interactive calls.
Further reading
docs/hooks.md— hook subsystem internals, ordering rules, error propagationdocs/extensions.md—ExtensionAPI(superset ofHookAPI)docs/skills/examples/safety-hook/— complete working example