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
| Event | Runs | Can it change things? |
|---|---|---|
SessionStart | On startup, resume, clear, compact or fork | Adds context for the model |
UserPromptSubmit | Before your prompt is sent | Blocks the prompt or adds context |
PreToolUse | Before a shell command, file edit, MCP or local tool call | Blocks or rewrites the call |
PermissionRequest | When Codex is about to ask you for approval | Allows or denies it |
PostToolUse | After a tool returns | Sends feedback to the model. Can’t undo the tool. |
PreCompact, PostCompact | Around compaction | Stops the turn |
SubagentStart, SubagentStop | When a subagent starts or finishes | Adds context, or makes the subagent continue |
Stop | When a turn ends | Makes Codex keep going |
Interrupt | When you interrupt a turn | No |
SessionEnd | When the session closes | No |
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:
~/.codex/hooks.jsonfor every project~/.codex/config.tomlas inline TOML<repo>/.codex/hooks.jsonfor one project<repo>/.codex/config.tomlas inline TOML
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 field | Meaning |
|---|---|
type | command, or mcp_tool to call a tool on a connected MCP server. prompt and agent are parsed but skipped. |
command | The command line to run |
timeout | Seconds. Default 600. SessionEnd and Interrupt default to 1 and allow up to 3. |
statusMessage | Text shown while the hook runs |
async | true runs it in the background. Background hooks can’t block or approve anything. |
commandWindows | Replacement command on Windows (command_windows also works in TOML) |
additionalContextLimit | Roughly 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:
PreToolUse,PermissionRequest,PostToolUse: the tool name. Shell commands areBash, file edits areapply_patch(also matched byEditorWrite), MCP tools look likemcp__server__tool.SessionStart:startup,resume,clear,compactorfork.PreCompact,PostCompact:manualorauto.UserPromptSubmit,Stop,Interrupt: no matcher. Codex ignores any you set.
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:
| Field | Meaning |
|---|---|
session_id | The session. Subagent hooks get the parent’s id. |
transcript_path | The transcript file, or null. Its format isn’t stable, so don’t parse it in anything you care about. |
cwd | The session’s working directory |
hook_event_name | Which event fired |
model | The 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 does | Codex does |
|---|---|
| Exits 0 with no output | Carries on as if nothing happened |
| Exits 0 and prints JSON | Applies the decision or context in it |
| Exits 2 with a reason on stderr | PreToolUse, 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 stderr | Reports the hook as failed and doesn’t block |
| Any other non-zero exit, or a timeout | Reports 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
notify | Hooks | |
|---|---|---|
| Where | notify = [...] in your user ~/.codex/config.toml | hooks.json or [hooks], user or project |
| Fires on | Turn complete only (agent-turn-complete) | Twelve events, including approval requests |
| Input | One JSON argument (sys.argv[1]) | JSON on stdin |
| Can change what Codex does | No | Yes, depending on the event |
| Needs trust | No | Yes, 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:
- Not trusted. The most common cause. Open
/hooksafter every edit to a hook. - Codex didn’t load the file.
/hookslists every source. If yours is missing, check the path, the JSON (python3 -m json.tool ~/.codex/hooks.json), whether the project is trusted, and whetherhooks = falseis set. Startup warnings about hooks scroll past quickly;/warningsshows them again. - Matcher never matches. Tool names are case-sensitive:
Bash, notbashorshell.StopandUserPromptSubmitignore matchers. - Hook fails with odd output. Commands run in a login shell, so anything your login profile (
~/.zprofile,~/.bash_profile) prints to stdout can end up in the hook’s output, which breaks events that expect JSON. Keep those files quiet. - Exit 2 doesn’t block. Write a reason to stderr. Exit 2 with an empty stderr counts as a failure.
- Command works in your terminal but not in the hook. Use absolute paths for tools installed by version managers such as nvm or pyenv, and remember the working directory is the session’s, not the hook file’s.
- No notification on macOS.
osascriptalerts come from Script Editor. Runosascript -e 'display notification "test"'once and allow Script Editor in System Settings → Notifications.
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:
- Each Codex session becomes a named pet that walks along the bottom of every Chrome page, next to your Claude Code, OpenCode and omp sessions.
- When Codex asks for approval, its pet bounces. Click it and answer Allow or Deny right there.
- Read Codex’s latest answer and send its next message from the pet.
- In another app, a desktop notification names the pet that needs you, with Allow and Deny on it.
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.