Codex hooks: hooks.json guide

Updated 24 October 2026

Codex hooks run your own commands at fixed points in a session: before a shell command, when Codex is about to ask for approval, after a file edit, when a turn ends. A hook can just watch, or it can block a command, rewrite it, approve it, or tell Codex to keep going. This guide covers the file format, what your script gets and what it should return, the trust step that trips most people up, and five hooks you can copy.

The short answer

EventRunsCan it change things?
SessionStartOn startup, resume, clear, compact or forkAdds context for the model
UserPromptSubmitBefore your prompt is sentBlocks the prompt or adds context
PreToolUseBefore a shell command, file edit, MCP or local tool callBlocks or rewrites the call
PermissionRequestWhen Codex is about to ask you for approvalAllows or denies it
PostToolUseAfter a tool returnsSends feedback to the model. Can’t undo the tool.
PreCompact, PostCompactAround compactionStops the turn
SubagentStart, SubagentStopWhen a subagent starts or finishesAdds context, or makes the subagent continue
StopWhen a turn endsMakes Codex keep going
InterruptWhen you interrupt a turnNo
SessionEndWhen the session closesNo

New or edited hooks don’t run until you trust them with /hooks. That’s the first thing to check when a hook does nothing.

1. Where hooks.json lives

Codex reads hooks from a hooks.json file or an inline [hooks] table in config.toml, next to each config layer it loads. The four you’ll use:

Unlike most settings, hooks don’t override each other. Codex loads every matching hook from every source and runs them all, and hooks matching the same event start at the same time, so one can’t stop another from starting. If one folder has both hooks.json and inline [hooks], Codex merges them and warns you at startup. Pick one.

Project hooks load only when the project is trusted (trust_level = "trusted" for that path under [projects] in ~/.codex/config.toml). Your user hooks run either way. Enabled plugins can ship hooks too, in hooks/hooks.json inside the plugin, and they go through the same trust review.

Hooks are on by default. This turns them all off:

~/.codex/config.toml[features]
hooks = false

2. The shape of a hook

There are three levels: the event, a list of matcher groups, and the handlers each group runs:

~/.codex/hooks.json{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.codex/hooks/block_dangerous.py",
            "timeout": 30,
            "statusMessage": "Checking command"
          }
        ]
      }
    ]
  }
}

The same hook as inline TOML:

~/.codex/config.toml[[hooks.PreToolUse]]
matcher = "^Bash$"

[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 ~/.codex/hooks/block_dangerous.py"
timeout = 30
statusMessage = "Checking command"
Handler fieldMeaning
typecommand, or mcp_tool to call a tool on a connected MCP server. prompt and agent are parsed but skipped.
commandThe command line to run
timeoutSeconds. Default 600. SessionEnd and Interrupt default to 1 and allow up to 3.
statusMessageText shown while the hook runs
asynctrue runs it in the background. Background hooks can’t block or approve anything.
commandWindowsReplacement command on Windows (command_windows also works in TOML)
additionalContextLimitRoughly how many tokens of context the hook can add before Codex saves the rest to a file. Default 2,500.

Matchers are regular expressions. "*", "" or no matcher at all matches everything. What they match depends on the event:

A matcher made only of letters, digits, underscores and | is compared as exact names: Bash matches only Bash, and Edit|Write matches either. Anything else, such as mcp__github__.*, is treated as a regex that can match anywhere in the name, so anchor it with ^ and $ when that matters. Hosted tools such as web search don’t go through hooks at all.

How the command runs: in the session’s working directory, through your login shell ($SHELL -lc) on macOS and Linux and cmd.exe on Windows. Because Codex can start in a subfolder, a project hook should find its script from the Git root instead of a relative path:

"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/check.py\""

3. What your hook gets on stdin

Every command hook receives one JSON object on stdin. These fields are always there:

FieldMeaning
session_idThe session. Subagent hooks get the parent’s id.
transcript_pathThe transcript file, or null. Its format isn’t stable, so don’t parse it in anything you care about.
cwdThe session’s working directory
hook_event_nameWhich event fired
modelThe active model

Most events also get turn_id and permission_mode. The tool events add tool_name and tool_input; for Bash and apply_patch, the command or patch text is in tool_input.command. PostToolUse adds tool_response, and Stop adds last_assistant_message. A PreToolUse event for a shell command looks like this:

{
  "session_id": "019a…",
  "transcript_path": "/Users/you/.codex/sessions/…/rollout.jsonl",
  "cwd": "/Users/you/code/my-app",
  "hook_event_name": "PreToolUse",
  "model": "…",
  "permission_mode": "default",
  "turn_id": "…",
  "tool_name": "Bash",
  "tool_use_id": "call_…",
  "tool_input": { "command": "git push --force origin main" }
}

The full schemas are in the Codex repository under codex-rs/hooks/schema/generated. They track the main branch, so they can list fields your installed version doesn’t send yet.

4. Exit codes and decisions

Your hook doesCodex does
Exits 0 with no outputCarries on as if nothing happened
Exits 0 and prints JSONApplies the decision or context in it
Exits 2 with a reason on stderrPreToolUse, UserPromptSubmit: blocks it and shows the reason. PostToolUse: replaces the tool result with your reason. Stop, SubagentStop: keeps going, using the reason as the next prompt.
Exits 2 with nothing on stderrReports the hook as failed and doesn’t block
Any other non-zero exit, or a timeoutReports the hook as failed and carries on

Failures don’t block. A crashing safety hook lets the command through, so test yours by hand.

For PreToolUse, the JSON way to block is:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Force pushes are blocked."
  }
}

Return "permissionDecision": "allow" with "updatedInput": { "command": "…" } to rewrite the command instead. "ask" isn’t supported yet: Codex marks the hook as failed and runs the tool.

For PermissionRequest, return a decision. If several hooks answer, any deny wins. If none do, you get the normal prompt:

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": { "behavior": "deny", "message": "Blocked by repository policy." }
  }
}

Use "behavior": "allow" to approve without a prompt. Plain text on stdout is ignored for the tool events, but Stop, SubagentStop and Interrupt expect JSON or nothing: stray text makes the hook fail. For SessionStart and UserPromptSubmit, plain text becomes extra context for the model.

5. Trust your hooks with /hooks

Codex won’t run a hook you haven’t reviewed. It stores trust against a hash of each hook definition, so a new hook and any edit to an existing one are skipped until you approve them. When that happens Codex prints a startup warning telling you to open /hooks.

Type /hooks, pick an event, and trust, disable or re-enable each hook. Hooks your organization manages through requirements.toml or MDM are trusted by policy and can’t be turned off there. Admins can also set allow_managed_hooks_only = true, which skips your own hooks entirely.

For automation that already checks its hook files, --dangerously-bypass-hook-trust runs enabled hooks without stored trust for that one run. Don’t put it in a shell alias.

6. notify in config.toml vs hooks

notifyHooks
Wherenotify = [...] in your user ~/.codex/config.tomlhooks.json or [hooks], user or project
Fires onTurn complete only (agent-turn-complete)Twelve events, including approval requests
InputOne JSON argument (sys.argv[1])JSON on stdin
Can change what Codex doesNoYes, depending on the event
Needs trustNoYes, via /hooks

Codex ignores notify in a project’s .codex/config.toml and warns about it. Use notify for a simple “turn finished” ping and hooks for everything else. The built-in [tui] notifications setting is a third option; see How to get Codex notifications.

7. Recipes

Put scripts in ~/.codex/hooks/. The Python ones need python3 on your PATH; on Windows, use commandWindows with py -3 instead.

Get an alert when Codex needs approval

A PermissionRequest hook that prints nothing doesn’t approve or deny, so Codex shows its normal prompt right after the alert. On Linux, use notify-send 'Codex' 'Codex needs your approval' as the command.

~/.codex/hooks.json{
  "hooks": {
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Codex needs your approval\" with title \"Codex\"'"
          }
        ]
      }
    ]
  }
}

Block dangerous shell commands

~/.codex/hooks/block_dangerous.py#!/usr/bin/env python3
import json
import re
import sys

event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")

BLOCKED = [
    (r"\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(/|~|\$HOME)(\s|$)", "Refusing to delete a home or root directory."),
    (r"\bgit\s+push\b.*(--force(?!-with-lease)\b|\s-f\b)", "Force pushes are blocked. Push normally or ask me."),
    (r"\bgit\s+reset\s+--hard\b", "git reset --hard is blocked. Use git stash instead."),
    (r"\bgit\s+clean\s+-[a-zA-Z]*f", "git clean -f is blocked."),
    (r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b", "Piping a download into a shell is blocked."),
]

for pattern, reason in BLOCKED:
    if re.search(pattern, command):
        print(reason, file=sys.stderr)
        sys.exit(2)

sys.exit(0)

Register it with the PreToolUse example in section 2. Codex shows the reason to the model, which usually picks a safer approach. A regex list is a speed bump, not a wall: a command built at runtime or wrapped in another script gets past it. The Codex docs say the same, so keep the sandbox on as well.

Run a formatter after every edit

File edits arrive as a patch in tool_input.command. This script pulls out the paths and formats each file with a formatter you already have installed:

~/.codex/hooks/format_edits.py#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
import sys

event = json.load(sys.stdin)
patch = event.get("tool_input", {}).get("command", "")
cwd = event.get("cwd", ".")

files = []
for line in patch.splitlines():
    for prefix in ("*** Add File: ", "*** Update File: ", "*** Move to: "):
        if line.startswith(prefix):
            files.append(os.path.join(cwd, line[len(prefix):].strip()))

FORMATTERS = {
    ".py": ["ruff", "format"],
    ".go": ["gofmt", "-w"],
    ".rs": ["rustfmt"],
    ".js": ["prettier", "--write"],
    ".ts": ["prettier", "--write"],
    ".tsx": ["prettier", "--write"],
    ".css": ["prettier", "--write"],
    ".json": ["prettier", "--write"],
}

for path in files:
    formatter = FORMATTERS.get(os.path.splitext(path)[1])
    if formatter and shutil.which(formatter[0]) and os.path.exists(path):
        subprocess.run(formatter + [path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

sys.exit(0)
~/.codex/hooks.json{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "^apply_patch$",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.codex/hooks/format_edits.py",
            "timeout": 60,
            "statusMessage": "Formatting"
          }
        ]
      }
    ]
  }
}

It runs synchronously so the file is formatted before Codex’s next step. Codex sees the formatted version the next time it reads the file.

Log every shell command

~/.codex/hooks/log_commands.py#!/usr/bin/env python3
import json
import os
import sys
import time

event = json.load(sys.stdin)
entry = {
    "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
    "session": event.get("session_id"),
    "cwd": event.get("cwd"),
    "command": event.get("tool_input", {}).get("command"),
}
log = os.path.expanduser("~/.codex/hooks/commands.jsonl")
with open(log, "a") as f:
    f.write(json.dumps(entry) + "\n")
~/.codex/hooks.json{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.codex/hooks/log_commands.py",
            "async": true
          }
        ]
      }
    ]
  }
}

"async": true means Codex doesn’t wait for the log write. PostToolUse also runs for commands that exit non-zero, so failed commands get logged too. Read the log with tail -f ~/.codex/hooks/commands.jsonl.

Play a sound when a turn ends

~/.codex/hooks.json{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Glass.aiff"
          }
        ]
      }
    ]
  }
}

On many Linux desktops, paplay /usr/share/sounds/freedesktop/stereo/complete.oga does the same. The command must print nothing to stdout, because Stop treats plain text as an error.

To use several recipes, put them all under one "hooks" key. Two groups under the same event, like a formatter and a logger under PostToolUse, go in the same array.

8. Debugging a hook that doesn’t work

Before anything else, run the script by hand with a fake event and check the exit code:

$ echo '{"tool_input":{"command":"git push --force origin main"}}' | python3 ~/.codex/hooks/block_dangerous.py; echo "exit $?"
Force pushes are blocked. Push normally or ask me.
exit 2

If that works, go down this list:

Answer Codex from your browser with Agent Pets

An alert hook tells you Codex is waiting, but you still have to go back to the terminal to answer it. Agent Pets uses Codex hooks to bring the prompt to you:

npx getagentpets init adds its hooks to ~/.codex/hooks.json next to any you already have; open Codex once and trust them with /hooks. It runs entirely on your computer, and the terminal prompt keeps working.

Get Agent Pets — was $25, now $6Get Agent Pets — $25

Questions

Where is the Codex hooks.json file?

Use ~/.codex/hooks.json for hooks in every project, or .codex/hooks.json inside a repository for that project only. Codex loads all of them, plus inline [hooks] tables in config.toml. Project hooks load only when you trust the project.

Why aren’t my Codex hooks running?

Codex skips new or changed hooks until you trust them. Start Codex, type /hooks and trust them. If that’s not it, check for hooks = false under [features] in config.toml, broken JSON, or a project that isn’t trusted.

How do I block a command with a Codex hook?

Add a PreToolUse hook with the matcher Bash. When the command in tool_input.command should be blocked, write the reason to stderr and exit with code 2. Codex doesn’t run the command and shows the reason to the model.

What’s the difference between notify and hooks in Codex?

notify in config.toml runs one program when a turn finishes and can’t change anything. Hooks run at many points in a session, including approval requests, and some can block, rewrite or approve actions.

Can a Codex hook approve commands automatically?

Yes. A PermissionRequest hook can return an allow or deny decision, and any deny wins. A hook that prints nothing leaves the decision to you, so Codex shows its normal approval prompt.

Sources: Codex documentation on hooks, advanced configuration, the configuration reference, slash commands and the CLI reference; the generated hook schemas and command runner in the Codex repository.

More guides