Tutorial · Updated
Codex CLI hooks: capture apply_patch edits
A Codex CLI hooks.json that captures every apply_patch edit and each turn's start and end, with the trust prompts, timeouts and payload quirks of codex-cli 0.154.
The Codex CLI can run a shell command at fixed points in a session: when you submit a prompt, before and after each tool call, when a turn stops or is interrupted, and when the session ends. The command gets a JSON payload on stdin. Codex makes file edits with its apply_patch tool, so one PostToolUse hook on that tool sees every edit, with the full patch text.
This tutorial builds that hook plus the turn hooks. Everything below was checked against codex-cli 0.154.0 in September 2026. The reference is the official Codex hooks documentation. Codex changes fast, so check it against your version.
Where hooks live
Codex loads hooks from all of these, and runs every one that matches:
~/.codex/hooks.json, or inline[hooks]tables in~/.codex/config.toml, for every project.<repo>/.codex/hooks.json, or<repo>/.codex/config.toml, for one project.
The current docs say hooks are on by default. If yours never fire, check config.toml for [features] with hooks = false, or the older codex_hooks key.
A minimal config
The file has the same shape as Claude Code's settings: an event name maps to a list of groups, each with an optional matcher and a list of handlers. This one logs every patch and each turn's start and end:
- The matcher is a regular expression.
^apply_patch$is anchored so it matches that one tool name and nothing else. timeoutis in seconds. The default is 600.InterruptandSessionEnddefault to 1 and are capped at 3.async: trueruns a hook in the background. The turn start uses it so it never holds up the model call. The docs say Codex runs up to eight background hooks at once per session.- Stop ends a normal turn, Interrupt a turn you stopped, and SessionEnd the session. They run in the foreground, for the reason in the gotchas.
For a worked example, this is the file TypeBack's installer writes on a Mac with node on the PATH:
It differs in one place: the edit hook runs in the background too. Each Codex hook costs a login shell start, the patch text is already in the payload, and the model's next reply takes longer than the hook does.
What the script receives
Every event sends session_id, transcript_path, cwd, hook_event_name, model and permission_mode. Events inside a turn add turn_id. PostToolUse adds tool_name, tool_use_id, tool_input and tool_response.
For apply_patch, in codex-cli 0.154.0:
tool_input.commandholds the whole patch as one string.tool_responseis a string, not an object. Its first line isExit code: N.
A payload, trimmed:
The patch format is plain text. Each file starts with a header line. In an update, @@ starts a chunk, and each line after it starts with a space (context), - (removed) or + (added):
Paths are as the model wrote them: relative to the session's cwd, or absolute. A chunk's context and removed lines are the before text. Its context and added lines are the after text. That gives you exact hunks with no git diff.
A script that logs every patch
This script appends one JSON line per event to ~/agent-edits.jsonl. For a patch it records whether it succeeded, the files it touched (a rename appears as its source with update and its destination with move), and the patch text. It writes nothing to stdout and exits 0 whatever happens.
It is CommonJS. Keep it outside any folder whose package.json says "type": "module", or name it on-edit.cjs. Try it before you wire it up:
Gotchas
Nothing runs until you trust it
Codex runs project hooks only in a trusted project, and each hook only after you trust it. Start codex once in the project, trust the folder, and trust the hooks when it lists them at startup. /hooks manages trust later. Trust is tied to the hook's command, matcher, timeout and async flag, so changing any of them asks again.
codex exec skips untrusted hooks without a word
A one-shot run does not ask. It skips every hook you have not trusted and prints nothing about it. If a script or CI job uses codex exec, trust the hooks in an interactive session first.
One-shot runs kill background hooks
codex exec exits right after its last turn and kills any background hook still running. An async Stop hook may never finish. Keep the turn-end hooks in the foreground.
Each hook starts a login shell
On macOS and Linux, Codex runs the command with $SHELL -lc, so your shell profile loads every time. With a heavy profile that is a noticeable delay on every hook. Keep the script itself fast, and use async where a late result does no harm.
Interrupt and SessionEnd get 3 seconds
Codex caps these two at 3 seconds and warns when a hook asks for more. We had 5 and got the warning. Keep these hooks short and set their timeout to 3 or less.
Read the exit code, not the words
In our runs, a patch that failed verification fired no PostToolUse at all. Check the exit code anyway, from the Exit code: N first line. Do not search the response for words like "error": the list of changed files after it can name a file such as error-handler.ts. We dropped a good edit that way before we fixed it.
apply_patch through the shell fires no hook
Sometimes the model runs apply_patch as a shell command instead of calling the tool. No apply_patch PostToolUse fires for that edit. If you must see every change, run git diff at the turn's end as a backstop.
Stay off stdout, and exit 0
On exit code 0, Codex reads stdout, and JSON there is taken as the hook's result. Exit code 2 blocks, with your stderr as the reason. A logging hook should print nothing and always exit 0.
Windows uses cmd.exe
On Windows, Codex runs the command with cmd.exe /C. Quote paths with double quotes, not single quotes. The docs also offer a commandWindows field for a Windows-only command.
Keep the hook file out of git
A .codex/hooks.json with your absolute paths runs a script your teammates do not have. Keep it out with .git/info/exclude, which works like .gitignore but lives in your clone and is never committed.
First check whether the team already commits the file. If it does, do not add your machine's paths to it. Put your hook in ~/.codex/hooks.json instead, and have the script ignore payloads whose cwd is outside the projects you care about.
git rev-parse --git-path info/exclude finds the right file in a linked worktree too. Paths in it are relative to the repository root.