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.

Part of:
Hooks overview
Related exhibit:
03 · A hook that keeps a log
Checked against the source:
Nov 4, 2026
Tools:
OMP 18.14.1 · Claude Code 2.1.281 · Node 24

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

Other agent CLIs: Codex and Gemini CLI document their own hook systems. We have not tested them.

Steps

  1. 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 in hooks/ is not found. omp --hook <file> loads one file for a single run. Hooks load when a session starts.

  2. OMP: block a command

    The tool_call event fires before the tool runs and before approval. The reason reaches 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.

  3. 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, and additionalContext, a note the agent reads after the tool results. Approval then runs on the new command. The wrapper writes the line to logs/session.log only when the shell reaches git 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.

  4. OMP: a fixed rule without code

    bash.patterns is an ordered list of rules. The first match wins, and * is the only wildcard. deny also 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 the eval tool, 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: deny

    Path changed. Our real list has twelve deny rules for other spellings of the same delete.

  5. Claude Code: register the hook

    Put it in the project's .claude/settings.json, which is shared with the repo, in ~/.claude/settings.json for all your projects, or in .claude/settings.local.json for yourself only. The matcher Bash is 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"]
              }
            ]
          }
        ]
      }
    }
  6. Claude Code: the script

    The call arrives as JSON on stdin, with tool_name, tool_input, cwd and session_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 an onFailure setting for that, but it needs a newer version than 2.1.281. Instead of an exit code, a hook can print JSON with permissionDecision: "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

Our hooks as they are set today are on Settings.

How to check it works

  1. 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
  2. In Claude Code, /hooks lists the hook under PreToolUse, according to the hooks reference.
  3. 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 contains Force push is blocked by a hook.

What to watch out for

Sources

← Hooks overview · ← All guides