Codex hooks are commands that Codex runs at fixed points in a session, such as before a tool call (PreToolUse) or when a turn ends (Stop). You declare them in a hooks.json file next to your config.toml, or under a hooks table inside config.toml, and a new or changed hook has to be reviewed and trusted in /hooks before Codex runs it.
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/pre.py",
"timeout": 10,
"statusMessage": "checking"
}
]
}
]
}
}
Applies to: Codex CLI, openai/codex main branch as of October 2, 2026 (latest release tag rust-v0.160.0). Last checked: October 2, 2026.
The example above is the hooks.json shape from the repository’s own config tests: a PreToolUse hook that runs a Python script before every shell command. Every detail on this page links to the file in the openai/codex repository it comes from.
On this page
How Codex CLI hooks are switched on
Hooks are a stable feature with the key hooks, enabled by default; the older key codex_hooks is kept as an alias (features/src/lib.rs, features/src/legacy.rs). The feature’s description in the source reads “Enable Claude-style lifecycle hooks loaded from hooks.json files”, and the slash command list describes /hooks as “view and manage lifecycle hooks” (slash_command.rs).
Where do Codex hooks go?
Codex reads hooks from every configuration layer, from either a hooks.json file in that layer’s folder or a hooks table in that layer’s config.toml (discovery.rs, state.rs, config loader README):
| Layer | hooks.json location | config.toml location |
|---|---|---|
| User | ~/.codex/hooks.json (or under CODEX_HOME) | [hooks] in ~/.codex/config.toml |
| Project | <project>/.codex/hooks.json | [hooks] in <project>/.codex/config.toml |
| System (managed) | hooks.json next to /etc/codex/config.toml | [hooks] in /etc/codex/config.toml |
| Admin requirements | — | Hook events in requirements.toml, with optional managed_dir |
| Plugin | hooks/hooks.json inside the plugin | — |
If one layer has hooks in both places, Codex loads both and warns: “loading hooks from both … prefer a single representation for this layer”. In a linked Git worktree, hook discovery can use the .codex/ folder of the root checkout while the rest of the project config comes from the worktree (state.rs).
The same hook in config.toml
In TOML each event is an array of tables, and each handler is a nested array of tables. This is the form the repository’s tests load from a config.toml hooks table (mod_tests.rs, config_requirements.rs):
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/pre.py"
timeout = 10
statusMessage = "checking"
In hooks.json, events must sit inside the top-level "hooks" object. A file with "SessionStart" at the root is rejected as an unknown field (hooks_tests.rs). The file may also carry an optional "description" string.
Which hook events does Codex support?
Codex defines twelve events (hooks/src/lib.rs, hook_config.rs). The /hooks browser describes each one (hooks browser snapshot); the matcher input and control columns come from the event handlers in hooks/src/events.
| Event | When it runs | Matcher compared with | What a hook can do |
|---|---|---|---|
PreToolUse | Before a tool executes | Tool name | Block the call, rewrite its input, add context |
PermissionRequest | When permission is requested | Tool name | Return allow or deny |
PostToolUse | After a tool executes | Tool name | Block with feedback, stop, add context |
PreCompact / PostCompact | Before / after context compaction | manual or auto | Stop processing, show a message |
SessionStart | When a new session starts | startup, resume, clear, compact, fork | Add context, stop the session |
SessionEnd | Right before a session ends | End reason | Side effects only |
UserPromptSubmit | When you submit a prompt | No matcher | Block the prompt, add context |
SubagentStart / SubagentStop | A subagent starts / stops | Agent type | Add context / ask for another pass |
Stop | When a turn ends | No matcher | Ask Codex to keep working |
Interrupt | When a turn is interrupted | No matcher | Show a message |
A matcher on UserPromptSubmit, Stop or Interrupt is ignored: the source states that these events “do not dispatch against a tool, compaction trigger, session-start source, or session-end reason” (hooks/src/lib.rs).
How do matchers work?
A matcher is read in one of three ways (matcher.rs):
| Matcher value | Read as |
|---|---|
Omitted, "" or "*" | Matches everything |
Only ASCII letters, digits, _ and | | Exact names, split on |: Edit|Write matches Edit or Write, and Bash does not match BashOutput |
| Anything else | A regular expression, such as ^Bash$ |
A matcher that is not a valid regular expression is skipped with an “invalid matcher” warning, and its handlers do not load (discovery.rs).
Tool names in Codex
The tool names a hook sees differ from Claude Code’s, and Codex adds matcher aliases so Claude Code-style matchers still select the right tools (hook_names.rs):
| Codex tool | tool_name in hook input | Also matched by |
|---|---|---|
| Shell-like tools | Bash | — |
| File edits | apply_patch | Write, Edit |
| Spawning subagents | spawn_agent | Agent |
The alias only selects the hook; the JSON on stdin keeps the canonical name, so a script that checks tool_name == "Write" will not see a match. For shell tools tool_input is { "command": ... }; MCP tools pass their resolved JSON arguments (pre_tool_use.rs).
Handler types and their keys
Each matcher group holds a hooks list of handlers with a type (hook_config.rs, discovery.rs):
type | Keys | Notes |
|---|---|---|
command | command, commandWindows (or command_windows), timeout, async, statusMessage, additionalContextLimit | Runs a shell command with the event JSON on stdin |
mcp_tool | server, tool, input, timeout, statusMessage | Calls a tool on a configured MCP server; not supported on SessionEnd |
prompt, agent | — | Accepted in the file but skipped: “prompt hooks are not supported yet”, “agent hooks are not supported yet” |
Key details from the source:
timeoutis in seconds. The default is 600.SessionEndandInterrupthooks default to 1 second and are capped at 3 (discovery.rs, session_end.rs).async: trueruns the command without waiting. Async hooks cannot block or rewrite anything; only synchronous hooks apply control effects. An asyncSessionEndhook runs synchronously, with a warning (engine/mod.rs).additionalContextLimitis an approximate token threshold for moving a hook’sadditionalContextto disk. Unset means 2,500 tokens;0turns spilling off. It applies only toPreToolUse,PostToolUse,SessionStart,UserPromptSubmitandSubagentStart.commandWindowsreplacescommandon Windows. Commands run through$SHELL -lc(falling back to/bin/sh), orCOMSPEC(falling back tocmd.exe) on Windows, in the session’s working directory (command_runner.rs).
An mcp_tool handler from the tests, which passes the edited file’s path to a scanning tool after apply_patch (matched here through the Write|Edit aliases):
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "scan",
"input": {
"file_path": "${tool_input.file_path}",
"include_ignored": false
},
"timeout": 30,
"statusMessage": "Scanning file"
}]
}]
}
}
${field.nested} placeholders in input are filled from the hook event’s JSON before the call; a placeholder that is not found is an error (mcp_runner.rs). Values must be representable in TOML, so a literal null is rejected.
What JSON does a hook receive?
Command hooks get one JSON object on stdin. The fields are defined in the generated schemas in codex-rs/hooks/schema/generated:
| Field | Present on |
|---|---|
session_id, cwd, hook_event_name, transcript_path (string or null) | Every event |
turn_id, model | Every event except SessionStart (no turn_id) and SessionEnd |
permission_mode: default, acceptEdits, plan, dontAsk or bypassPermissions | Tool, prompt, session-start, stop, subagent and interrupt events |
tool_name, tool_input | PreToolUse, PermissionRequest, PostToolUse |
tool_use_id | PreToolUse, PostToolUse |
tool_response | PostToolUse |
prompt | UserPromptSubmit |
source | SessionStart |
trigger | PreCompact, PostCompact |
reason | SessionEnd |
stop_hook_active, last_assistant_message | Stop, SubagentStop |
agent_id, agent_type | Subagent events, and tool and prompt events inside a subagent |
agent_transcript_path | SubagentStop |
What do exit codes mean?
The event handlers read the exit code like this (pre_tool_use.rs, stop.rs, post_tool_use.rs):
| Exit code | Result |
|---|---|
0, empty stdout | Nothing to do |
0, JSON on stdout | Parsed as the hook’s output; stdout that looks like JSON but does not parse marks the hook as failed |
0, plain text on stdout | Becomes model context for SessionStart and SubagentStart; ignored for PostToolUse |
2 with a reason on stderr | Blocks: the tool call (PreToolUse), the prompt (UserPromptSubmit), or gives feedback (PostToolUse); on Stop the stderr text becomes the prompt for another pass |
2 with empty stderr | The hook is reported as failed, for example “PreToolUse hook exited with code 2 but did not write a blocking reason to stderr” |
| Any other code | Reported as “hook exited with code N”; the action goes ahead |
What JSON can a hook return?
Every output schema accepts systemMessage, a message shown as a warning. Most also accept continue, stopReason and suppressOutput, but not every event honors them (output_parser.rs).
Deny a tool call (PreToolUse). The reason is required; a deny without one is treated as invalid output:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}
Rewrite a tool call (PreToolUse). updatedInput works only together with permissionDecision: "allow":
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"echo rewritten"}}}
On PreToolUse, these outputs are reported as unsupported and ignored, so the call goes ahead: allow without updatedInput, ask, continue: false, stopReason, suppressOutput, and the older {"decision":"approve"}. The older {"decision":"block","reason":"..."} form still blocks (pre_tool_use.rs).
Add context for the model (PostToolUse; also PreToolUse, SessionStart, UserPromptSubmit, SubagentStart):
{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"Remember the bash cleanup note."}}
Keep Codex working (Stop). The reason becomes the continuation prompt:
{"decision":"block","reason":"retry with tests"}
Decide a permission request (PermissionRequest). hookSpecificOutput.decision.behavior is allow or deny, with an optional message. When several hooks answer, any deny wins; otherwise the highest-precedence allow is used. updatedInput, updatedPermissions and interrupt are not supported (permission_request.rs).
Why a hook needs review before it runs
Hooks from user, project and plugin files run only after you trust them. Codex hashes each hook’s normalized definition; a hook with no trusted hash is Untrusted, and one whose definition changed since you trusted it is Modified. Neither runs until you trust it again. Managed hooks (system config and requirements.toml) always run (discovery.rs).
Type /hooks to open the browser. It lists installed, active and to-review counts per event, shows a notice such as “1 hook needs review before it can run”, and offers t to trust all and enter to review. Inside an event you can turn single hooks on or off; “Your changes are saved automatically” (hooks browser snapshots). The choices are stored per hook in a state table:
[state."/tmp/hooks.json:pre_tool_use:0:0"]
enabled = false
trusted_hash = "sha256:abc123"
That snippet comes from the config tests. The key is the source file, the event, the matcher group index and the handler index, so the /hooks browser is the practical way to manage it.
Managed hooks only
For organizations, docs/config.md in the repository says: “Admins can set top-level allow_managed_hooks_only = true in requirements.toml to ignore user, project, and session hook configs while still allowing managed hooks from requirements and managed config layers. This setting is only supported in requirements.toml; putting it in config.toml does not enable managed-hooks-only mode.”
For the rest of Codex setup, see what Codex is and how to install it and the Codex guides. If you also use Claude Code, the Claude Code hooks guide covers its version of the same idea.





