Guide 05 · Harnesses
Agent hooks: stop or change a tool call
An agent harness runs a hook before or after a tool. The hook sees the call, for example the command for the bash tool, and can stop it or change it.
What it is for
A git hook sees only commits and pushes. An agent hook sees every tool call the agent makes, including commands that git never sees. It covers only that agent's tool calls, and only the tools it checks.
What you need
- OMP (we use 18.14.1) or Claude Code (we use 2.1.281).
- For the Claude Code example, Node. We use 24.
Other agent CLIs: Codex and Gemini CLI document their own hook systems. We have not tested them.
Steps
-
OMP: where hooks live
OMP loads hooks from
.omp/hooks/pre/in the project and from~/.omp/agent/hooks/pre/for every project (post/works the same). The project folder is read only in the directory where omp starts, not in its parents, and a file placed directly inhooks/is not found.omp --hook <file>loads one file for a single run. Hooks load when a session starts. -
OMP: block a command
The
tool_callevent fires before the tool runs and before approval. Thereasonreaches the agent as the tool's error. A handler has 30 seconds, and an error or a timeout blocks the call. Hooks and extensions run inside the omp process, without a sandbox.illustrative · .omp/hooks/pre/block-force-push.ts · written for this guide, run on Nov 4, 2026 import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; // Stop the agent's bash tool from force-pushing. // This is a text check: it also matches the words inside an echo or a heredoc. const FORCE_PUSH = /\bgit\s+push\b.*\s(-f|--force[a-z-]*)(\s|$)/; export default function blockForcePush(pi: ExtensionAPI): void { pi.on("tool_call", event => { if (event.toolName !== "bash") return; const command = String(event.input.command ?? ""); if (!FORCE_PUSH.test(command)) return; return { block: true, reason: "Force push is blocked by a hook. Ask the user to push." }; }); }It misses combined flags such as
-uf, and it blocks a heredoc that only mentions a force push. -
OMP: change a command before it runs
Our logging hook blocks nothing. It returns a new
input, the same command with a line in front that loads a shell wrapper, andadditionalContext, a note the agent reads after the tool results. Approval then runs on the new command. The wrapper writes the line tologs/session.logonly when the shell reachesgit commit, and adds it to that same commit. Exhibit 03 walks through it.adapted from · OMP hooks/pre/git-commit-log.ts import { homedir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; // Before a bash command that mentions git and commit, load a shell wrapper. // The wrapper writes the log line only when the shell reaches git commit. const SCRIPT = join(homedir(), ".omp", "agent", "hooks", "lib", "git-commit-log.sh").replace(/\\/g, "/"); export default function gitCommitLog(pi: ExtensionAPI): void { pi.on("tool_call", event => { if (event.toolName !== "bash") return; const command = String(event.input.command ?? ""); if (!/\bgit\b/.test(command) || !/\bcommit\b/.test(command) || command.includes(SCRIPT)) return; return { input: { ...event.input, command: `. '${SCRIPT}' # hook git-commit-log\n${command}` }, additionalContext: "The first line of this bash command was added by a hook. It logs the commit. Do not add it yourself and do not report it as an error.", }; }); }Comments and the note for the agent translated from Czech. The wrapper script it loads is not shown; exhibit 03 walks through it.
-
OMP: a fixed rule without code
bash.patternsis an ordered list of rules. The first match wins, and*is the only wildcard.denyalso applies to each part of a compound command, such as the parts around&&,;or|. It is an approval rule, not isolation, and it covers only the bash tool. That is why our delete hook also watches theevaltool, and reads the command the way a shell does instead of matching text. Our current rules are on Settings.adapted from · OMP config.yml # ~/.omp/agent/config.yml (excerpt). The first matching rule wins. bash: patterns: - match: rm -rf /tmp/agent-scratch/* approval: allow - match: rm -rf * approval: deny - match: rm -fr * approval: denyPath changed. Our real list has twelve deny rules for other spellings of the same delete.
-
Claude Code: register the hook
Put it in the project's
.claude/settings.json, which is shared with the repo, in~/.claude/settings.jsonfor all your projects, or in.claude/settings.local.jsonfor yourself only. The matcherBashis the tool name.${CLAUDE_PROJECT_DIR}is the root of the project.illustrative · .claude/settings.json · written for this guide { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/block-force-push.js"] } ] } ] } } -
Claude Code: the script
The call arrives as JSON on stdin, with
tool_name,tool_input,cwdandsession_id. Exit 2 blocks the call, and Claude gets the stderr text. Any other non-zero exit does not stop the call, so the script also exits 2 when it cannot read its input. The reference lists anonFailuresetting for that, but it needs a newer version than 2.1.281. Instead of an exit code, a hook can print JSON withpermissionDecision: "deny". This is from the Claude Code hooks reference, checked Nov 4, 2026.illustrative · .claude/hooks/block-force-push.js · written for this guide, run on Nov 4, 2026 // PreToolUse hook for the Bash tool: stop a force push. // Input: the tool call as JSON on stdin. Exit 2 blocks the call; stderr goes to Claude. // This is a text check: it also matches the words inside an echo or a heredoc. const FORCE_PUSH = /\bgit\s+push\b.*\s(-f|--force[a-z-]*)(\s|$)/; let raw = ""; process.stdin.on("data", chunk => (raw += chunk)); process.stdin.on("end", () => { let command; try { command = String(JSON.parse(raw).tool_input?.command ?? ""); } catch { process.stderr.write("Hook could not read its input, so the call is blocked.\n"); process.exit(2); } if (FORCE_PUSH.test(command)) { process.stderr.write("Force push is blocked by a hook. Ask the user to push.\n"); process.exit(2); } process.exit(0); });
Other hook points
- After the tool: OMP
tool_result, Claude CodePostToolUse. It can add a note for the agent or replace the result. It cannot undo the call. - Rewrite the input in Claude Code:
PreToolUsecan returnupdatedInput, which replaces the whole tool input. - End of session: OMP
session_shutdown, Claude CodeSessionEnd. For logs and summaries. It cannot stop anything.
Our hooks as they are set today are on Settings.
How to check it works
-
The Claude Code script, without Claude. From the project root, run:
illustrative · run on Nov 4, 2026 echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \ | node .claude/hooks/block-force-push.js; echo "exit $?"illustrative · expected output Force push is blocked by a hook. Ask the user to push. exit 2 - In Claude Code,
/hookslists the hook underPreToolUse, according to the hooks reference. - In OMP, start a new session in the project and, in a test repo, ask the agent to run
git push --force origin main. You should see the call end with an error that containsForce push is blocked by a hook.
What to watch out for
-
observed Oct 19 – Nov 4, 2026
A hook reacts to text, not intent
Our delete hook blocked a heredoc that only wrote a comment containing
rm -rf, and a code cell whose task text mentionedgit rm. The logging hook wraps even agrepthat searches git's documentation for commit options. Write text with such words through the file tool, not the shell. -
observed Nov 1, 2026
An agent got around a string check
An agent got past a check that matched a string by building the blocked word from parts, and we found out only by reading the terminal. A hook is a guardrail. Both the block and any attempt to get around it should be visible.
-
observed Nov 3, 2026
A hook that lets a command through does not override a deny rule
Our delete hook allowed a cleanup in the scratch folder, but a
denyrule still stopped it. The exception also needed anallowrule placed before thedenyrules. -
observed Nov 3, 2026
A changed hook applies only in a new session
After we edited a hook, the running session kept using the old version.
-
observed Oct 15, 2026
A hook covers only the tools it watches
A commit run through a tool other than bash did not reach our log.
-
observed Nov 3, 2026
Text checks miss other spellings
rm -rwithout-fgot through, and a check on how a path starts would have let a delete through a link that points outside the folder. Our hook now resolves the real path. -
observed Oct 18 – Nov 1, 2026
Blocked deletes left the cleanup to us
Each blocked delete left temporary files for a person to remove. The agent needs a scratch folder where deleting is allowed.
-
observed Nov 4, 2026
A hook that fails before its check lets the call through
The first draft of the Claude Code example read its input with
jq, which was not installed. It exited 0 for every input, so evengit push --forcewent through.
Sources
- OMP documentation, version 18.14.1:
hooks.md,extensions.md,extension-loading.md,settings.md - Claude Code hooks reference, checked Nov 4, 2026, against version 2.1.281
- Our OMP hooks
hooks/pre/git-commit-log.tsandhooks/pre/guard-recursive-delete.ts, and OMPconfig.yml - K-B sprint notes and session notes, Oct 15 to Nov 3, 2026
- Our tests, Nov 4, 2026
- Codex hooks documentation and Gemini CLI hooks documentation, checked Nov 4, 2026