TypeBack

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:

.codex/hooks.json
1{
2"hooks": {
3"PostToolUse": [
4{
5"matcher": "^apply_patch$",
6"hooks": [{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5 }]
7}
8],
9"UserPromptSubmit": [
10{ "hooks": [{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5, "async": true }] }
11],
12"Stop": [
13{ "hooks": [{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5 }] }
14],
15"Interrupt": [
16{ "hooks": [{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 3 }] }
17],
18"SessionEnd": [
19{ "hooks": [{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 3 }] }
20]
21}
22}
  • The matcher is a regular expression. ^apply_patch$ is anchored so it matches that one tool name and nothing else.
  • timeout is in seconds. The default is 600. Interrupt and SessionEnd default to 1 and are capped at 3.
  • async: true runs 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:

.codex/hooks.json (TypeBack)
1{
2"hooks": {
3"PostToolUse": [
4{
5"matcher": "^apply_patch$",
6"hooks": [
7{
8"type": "command",
9"command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent codex",
10"timeout": 5,
11"async": true
12}
13]
14}
15],
16"UserPromptSubmit": [
17{
18"hooks": [
19{
20"type": "command",
21"command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent codex",
22"timeout": 5,
23"async": true
24}
25]
26}
27],
28"Stop": [
29{ "hooks": [{ "type": "command", "command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent codex", "timeout": 5 }] }
30],
31"Interrupt": [
32{ "hooks": [{ "type": "command", "command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent codex", "timeout": 3 }] }
33],
34"SessionEnd": [
35{ "hooks": [{ "type": "command", "command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent codex", "timeout": 3 }] }
36]
37}
38}

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.command holds the whole patch as one string.
  • tool_response is a string, not an object. Its first line is Exit code: N.

A payload, trimmed:

1{
2"session_id": "codex-e2e",
3"turn_id": "t1",
4"cwd": "/Users/you/my-app",
5"hook_event_name": "PostToolUse",
6"model": "…",
7"tool_name": "apply_patch",
8"tool_use_id": "call_1",
9"tool_input": {
10"command": "*** Begin Patch\n*** Update File: sample.ts\n@@\n- return a + b;\n+ return b + a; // commutative\n*** End Patch"
11},
12"tool_response": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\nSuccess. Updated the following files:\nM sample.ts\n"
13}

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):

*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch

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.

on-edit.js
1// on-edit.js: log each apply_patch Codex ran, with the files it touched.
2// Never write to stdout, and always exit 0.
3const fs = require('node:fs');
4const os = require('node:os');
5const path = require('node:path');
6 
7const LOG = path.join(os.homedir(), 'agent-edits.jsonl');
8const HEADER = /^\*\*\* (Add File|Update File|Delete File|Move to): (.+)$/;
9 
10let input = '';
11process.stdin.setEncoding('utf8');
12process.stdin.on('data', (chunk) => {
13input += chunk;
14});
15process.stdin.on('end', () => {
16try {
17const p = JSON.parse(input);
18const entry = { at: new Date().toISOString(), event: p.hook_event_name, session: p.session_id, turn: p.turn_id };
19if (p.tool_name === 'apply_patch') {
20const patch = String((p.tool_input || {}).command || '');
21const code = /^Exit code: (\d+)/.exec(String(p.tool_response || ''));
22entry.ok = code ? code[1] === '0' : undefined;
23entry.files = patch.split('\n').flatMap((line) => {
24const m = HEADER.exec(line);
25return m ? [{ change: m[1].split(' ')[0].toLowerCase(), file: path.resolve(p.cwd || '.', m[2].trim()) }] : [];
26});
27entry.patch = patch;
28}
29fs.appendFileSync(LOG, JSON.stringify(entry) + '\n');
30} catch {
31// A hook that logs must never break the agent.
32}
33process.exit(0);
34});

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:

printf '%s' '{"session_id":"s1","turn_id":"t1","cwd":"/tmp/p","hook_event_name":"PostToolUse","tool_name":"apply_patch","tool_input":{"command":"*** Begin Patch\n*** Update File: src/a.ts\n@@\n-let a = 1;\n+const a = 1;\n*** End Patch"},"tool_response":"Exit code: 0\nOutput:\nSuccess."}' \
| node /absolute/path/to/on-edit.js
echo "exit code: $?"
tail -n 1 ~/agent-edits.jsonl

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.

# from the repository root
git ls-files --error-unmatch .codex/hooks.json # prints the path if the team already commits it
echo '.codex/hooks.json' >> "$(git rev-parse --git-path info/exclude)"
git check-ignore -v .codex/hooks.json

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.

What TypeBack does with the edit