Use when writing, debugging, or configuring an omp hook or extension — blocking or rewriting tool calls, redacting tool output, reshaping LLM context, registering tools/commands, or wiring modules through config.yml. Covers ExtensionAPI (current) vs HookAPI (legacy), the event catalog, discovery and load order, and disable controls. Not for Claude Code hooks (~/.claude/settings.json) or the shared harness safety guard (~/.agents/harness/hooks/).
97%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
This machine runs three unrelated things called "hooks". Confirm which one the task means before editing anything:
| System | Lives in | Shape |
|---|---|---|
| omp hooks/extensions (this skill) | ~/.omp/agent/extensions/, <cwd>/.omp/extensions/ | TS/JS module, default-exported factory |
| Claude Code hooks | ~/.claude/settings.json hooks | JSON event → shell command |
| Shared harness guard | ~/.agents/harness/hooks/ | Normalized cross-harness deny policy |
ExtensionAPI, not HookAPIHookAPI is the legacy API. The runtime now initializes the extension
runner: --hook is an alias for --extension, discovered .ts/.js hook
factories load as extension modules, and tools are wrapped by
ExtensionToolWrapper rather than HookToolWrapper.
ExtensionAPI is a superset — same event model, plus extension-only events
(tool_execution_*, input, user_bash, user_python, mcp_notification,
before_provider_request, session_stop, …), tool/command/renderer/provider
registration.
Write new work against ExtensionAPI. Reach for HookAPI only when
maintaining an existing hook module.
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
if (!/\brm\s+-rf\s+\//.test(String(event.input.command ?? ""))) return;
return { block: true, reason: "rm -rf / blocked by policy" };
});
}The default export must be a function (not a class). It may return a promise; the loader awaits it.
tool_callReturns { block?, reason?, input? }.
block: true stops execution immediately (first block
short-circuits). reason becomes the error text the model sees.input replaces the raw execution arguments. Handlers do not see
each other's revisions (last non-blocking return wins), it is ignored when
block is true, and it never applies to computer calls.tool_resultReturns { content?, details?, isError? }.
ExtensionAPI this is middleware-style —
each handler sees prior handlers' modifications. Under HookAPI every
handler sees the original event and the last return wins.isError is typed but HookToolWrapper does not apply it; on tool failure
the original error is rethrown after handlers run.tool_result still fires with isError: true when the tool failed.contextReturns { messages? }, fires before each LLM call, chained (each handler
receives the previous handler's output). Return nothing to pass through.
Load order, deduped by absolute path, first seen wins:
<cwd>/.omp/extensions, ~/.omp/agent/extensions.ts/.js hook-capability factoriespackage.json omp.extensions)-e/--extension/--hook first, then
settings extensionsA module both auto-discovered and explicitly configured loads once, at the auto-discovered position.
Config (user ~/.omp/agent/config.yml, project <cwd>/.omp/config.yml):
extensions:
- ~/my-exts/safety.ts
- ./local/ext-pack
disabledExtensions:
- extension-module:foo # derived from entry path: /x/foo.ts -> fooDisable controls: --no-extensions (explicit -e/--hook paths still load),
disabledExtensions by capability id. Full precedence, profile paths, and
directory-resolution rules are in references/configuration.md.
pi.sendMessage()
from the factory throws ExtensionRuntimeNotInitializedError. Register in
the factory; act from events, commands, and tools.setInterval/setTimeout/detached-promise callback that throws escapes
handler dispatch, becomes an uncaughtException, and the postmortem handler
tears down the whole session. Use ctx.setInterval / ctx.setTimeout /
ctx.clearTimer: same signatures, contained failures, unref'd, and cleared
on session_shutdown.appendEntry customType is a global namespace. Use a reverse-domain key
(com.example.my-ext.state) and avoid core-reserved values.gitignore: true, hidden: false; explicit configured-directory scanning uses
readdir and applies no gitignore filtering.index.ts wins over index.js.ctrl+c, escape, enter, …) are ignored.ctx.hasUI is false in headless, print, and
subagent modes.references/event-catalog.md — every event, when it fires, what it returnsreferences/configuration.md — discovery roots, profiles, precedence,
disable ids, layout examplesAuthoritative upstream docs ship with the installed omp and are version-matched; read them directly rather than trusting the website (which is a client-rendered SPA and returns no content to a fetch):
omp://extensions.md — ExtensionAPI referenceomp://extension-loading.md — discovery and loadingomp://hooks.md — legacy hook subsystem internalsomp://skills/authoring-hooks.md — authoring guideomp://skills/examples/safety-hook/README.md — worked examplephatblat/dotfiles
4d65cab
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.