Codex hooks that don't fire: where they load and how to check
A hook is a command or MCP tool call that Codex runs at a fixed point in its lifecycle: before a tool call, after one, when a session starts, when a turn stops. It runs because the event happened, not because the model chose to run it. MCP here means the Model Context Protocol, the way Codex connects to external tool servers.
When a hook "doesn't fire", people usually mean one of five different failures:
- Codex never discovered it.
- Codex discovered it but skipped it because it has not been trusted in its current form.
- The event or matcher is not the one you think.
- The handler started and failed.
- The handler ran, but its output never reached the model.
The order matters. /hooks answers the first two. The event table answers the third. A marker file and a nonce (a one-off string) answer the last two.
Checked against Codex's documentation on 26 October 2026.
Status, 26 October 2026: the documentation now describes project hooks, plugin-bundled hooks, and broad tool coverage, but open reports still describe loader gaps. A project-level hooks file is ignored, or resolved to the main checkout, when Codex runs from a linked Git worktree, with confirmation on Codex CLI 0.162.1 on 10 and 11 October 2026 (#27133, open). Plugin-bundled hooks were reported not to execute even though config-level hooks did, confirmed on CLI 0.130.0 on 16 May 2026 (#16430, open). Hooks written inline in a repo-local config.toml were reported not to fire in an interactive session, confirmed on CLI 0.132.0 on 21 May 2026 (#17532, open). If a fix has shipped in your version, the checks below still apply.
Where Codex looks#
Codex builds its settings from layers (config files or sources that override one another), from lowest to highest precedence: built-in defaults, a system layer, cloud-managed defaults, your user layer at ~/.codex, a profile file, a trusted project layer at <repo>/.codex, and command-line overrides. Hooks are discovered next to each active config layer, in either of two forms:
hooks.json- an inline
[hooks]table inconfig.toml
The four locations that cover most local work are ~/.codex/hooks.json, ~/.codex/config.toml, <repo>/.codex/hooks.json, and <repo>/.codex/config.toml. Codex can also load hooks bundled with an enabled plugin (an installable bundle of skills, MCP servers, and hooks), either from the plugin's default hooks/hooks.json or from a path in its manifest.
Two rules surprise people.
First, matching hooks from different layers all run. A higher-precedence layer does not replace a lower-precedence hook. If your user layer and project layer both define a SessionStart hook, both run. If one layer contains both hooks.json and inline [hooks], Codex merges them and warns at startup; the docs recommend one form per layer.
Second, the project layer loads only when the project is trusted. In an untrusted project, Codex skips the project's whole .codex/ layer, including project hooks, project config, and rules, and still loads user and system hooks. If /hooks shows nothing from the repository, the trust state is a better first guess than the JSON syntax.
1. Is it discovered?#
Run /hooks. It lists the hooks Codex knows about, grouped by event, with their source, and lets you review, trust, or disable the non-managed ones. If your hook is not listed, Codex did not discover it. The usual causes:
- The file is in the wrong place. The project location is
<repo>/.codex/hooks.json, not<repo>/hooks.jsonand not<repo>/.codex/hooks/hooks.json. - The project is untrusted, so the project
.codex/layer was skipped. - Hooks are turned off with
[features] hooks = false.codex_hooksstill works as a deprecated alias. A managed requirement can also force hooks off. - The JSON is invalid, or the inline TOML is in the wrong shape. The JSON needs a top-level
hooksobject whose keys are event names; the TOML needs[hooks]tables. - The hook is in a plugin that is installed but not enabled, or the plugin's manifest points at a path outside its root. Installing a plugin does not automatically trust its hooks.
- A managed policy sets
allow_managed_hooks_only = true, which skips user, project, session, and plugin hooks and keeps only managed ones.
/debug-config is useful here for a different reason: it shows which config layers are active and whether the project layer is enabled. It is not the hook browser, so it proves the layer, not the hook. If the hook is in a layer that never loads, which config.toml Codex is actually reading covers the precedence.
2. Is it trusted in its current form?#
This is the step people miss. Non-managed hooks must be reviewed and trusted before they run. Codex records trust against the hook's current hash, which means a hook is a new hook after you edit it. The old trust does not carry over, and Codex skips the changed hook until you review it again.
Run /hooks, find the hook marked for review, and trust it. If hooks need review at startup, Codex prints a warning telling you to open /hooks. Managed hooks from system, MDM (mobile-device management), cloud, or requirements.toml are trusted by policy and cannot be disabled from the user hook browser.
For one-off automation that already vets hook sources outside Codex, --dangerously-bypass-hook-trust runs enabled hooks without persisted trust for that invocation. That flag is for a controlled script, not a permanent fix. If you use it and the hook still does not run, the problem is discovery or the handler, not trust.
3. Is it the right event, and does the matcher match?#
The event names are literal. These are the current events:
| When | Events |
|---|---|
| During a turn | PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStop, Stop |
| When you interrupt a turn | Interrupt |
| When a session or subagent starts | SessionStart, SubagentStart |
| When the main thread ends | SessionEnd |
A hook on Stop will not run when you interrupt; Interrupt is a separate event. SessionEnd does not run for subagents. SessionStart runs on startup, resume, clear, compaction (older turns replaced by a summary), and fork, and the matcher value says which one.
The matcher is a regular expression. "*", an empty string, or an omitted matcher matches every occurrence of a supported event. Examples that work: Bash, ^apply_patch$, Edit|Write, mcp__filesystem__.*, startup|resume|clear|compact|fork, and manual|auto.
Not every event uses a matcher. UserPromptSubmit, Stop, and Interrupt ignore it; a matcher there is harmless but does nothing. For PostCompact and PreCompact, the matcher filters the trigger, manual or auto. For SessionEnd, the only reason today is other.
Tool coverage is broader than it used to be, but it is not universal. PreToolUse and PostToolUse can observe shell commands as Bash, unified exec as Bash, file edits through apply_patch (which also matches Edit or Write), MCP tools by their full name, and most other local function tools. Hosted tools such as WebSearch do not use this hook path. A matcher for Bash will never see apply_patch; a matcher for Edit sees apply_patch, and the hook input still reports tool_name: "apply_patch".
One more shape issue: command and mcp_tool handlers run. prompt and agent handlers are parsed but skipped. If your file uses one of those, /hooks may show it and nothing will execute.
4. Did the handler run?#
If the hook is listed and trusted, and the event matches, the handler (the command or MCP tool the hook runs) itself may be failing. Check:
- The command is executable, or it names an interpreter that exists.
- The path is absolute or resolved from the Git root, not a relative
.codex/hooks/...path. Commands run with the session working directory, which may be a subdirectory. A relative path that works from the repository root can fail after acd. - The timeout is long enough. Most hooks default to 600 seconds.
SessionEndandInterruptdefault to one second and accept at most three, so a slow cleanup script will be cut off. - The script can read the JSON event on stdin and writes valid JSON on stdout. A shell profile that prints a banner before your JSON will break parsing.
- For an MCP tool hook, the server is already connected and the tool exists. Hooks use an existing connection; they do not start or reconnect a server.
SessionEnddoes not support MCP tool hooks, and aSessionStartMCP hook can run before the server is ready, in which case it does not block the session. - A background hook has
"async": true, and you are not expecting it to block. Background hooks cannot approve, deny, rewrite, or stop the operation that triggered them. Their output arrives at the next safe point, and unfinished background hooks are cancelled when the session ends.
A marker file is the simplest proof that the process ran: have the hook append a line to /tmp/codex-hook-ran before it produces output. If the file stays empty, the handler did not start.
5. Did the output reach the model?#
This is the most common "it fired but nothing happened". Codex does not add every hook's stdout to the conversation. Plain stdout is added as developer context on SessionStart, UserPromptSubmit, and SubagentStart. On PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, Stop, SubagentStop, and Interrupt, plain stdout is ignored or invalid; SessionEnd output is advisory, not model context.
To add model-visible context on an event that supports it, return JSON with hookSpecificOutput.additionalContext:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "This file is generated. Edit src/schema.ts and run the generator."
}
}By default, each model-visible hook-output message is capped at roughly 2,500 tokens. Larger output is written under the temporary directory and the model gets a head-and-tail preview plus the file path. additionalContextLimit on a command handler raises or lowers that threshold; 0 passes the full context through, which the docs warn can consume the whole window. The setting applies only to additionalContext.
systemMessage is different: Codex surfaces it as a warning in the UI or event stream, not as context for the model. If you want the model to know something, use additionalContext; if you want the user to see a warning, use systemMessage.
Exit codes and decisions are event-specific. Exit 0 with no output is success. On PreToolUse, you can deny with permissionDecision: "deny" or with exit code 2 and stderr; permissionDecision: "ask" is parsed but not supported yet, and Codex marks that hook run as failed while continuing the tool call. PermissionRequest uses a JSON decision.behavior of allow or deny; any deny wins across matching hooks. For why that prompt appeared in the first place, see why Codex keeps asking for approval. On PostToolUse, a block cannot undo the tool that already ran; it replaces the result with your feedback. Stop and SubagentStop use decision: "block" to continue, not to reject.
When discovery is the bug#
Two open reports are about loading rather than your configuration. Both have workarounds that keep the hook outside the broken path.
For the linked-worktree report (#27133), a worktree is a second checkout of the same repository, usually on another branch. In the zero-hook case, /hooks shows no project hooks in the worktree even though the project layer is enabled and the file is valid. If the main checkout has a .codex/hooks.json, check the source paths in /hooks: you may be running its hooks, not none. That is the branch-skew case; the user-level copy with a cwd guard avoids both. Moving the hook to ~/.codex/hooks.json makes it discoverable; have the script read the cwd field from its input and exit 0 when it is not the worktree you meant. Running the same session from the main checkout is the other workaround.
For the plugin report (#16430), the plugin installs and its skills or MCP servers work, but its lifecycle hooks never run. The workaround in the thread is the same: move the hook definition to ~/.codex/hooks.json or the project hooks file and review it again in /hooks. If the config-level copy runs and the plugin copy does not, you are looking at the loader, not the script.
There is also a surface split. In one open report, hooks fire from the CLI but not from the desktop app after an update, including with a trusted user-level file (#21639, open, last activity 14 September 2026). If the CLI works and the app does not, compare /hooks in both and treat the app as the variable. Run the hook-dependent work in the CLI until the report closes.
Prove it with a nonce#
A marker file proves execution. A nonce proves delivery. Have the hook write hook-ran-7f3a to a file and return hook-nonce-7f3a as additionalContext. If the file appears, the process ran. If Codex can repeat the nonce, the text reached the model. If the file appears and Codex cannot repeat it, the problem is output shape or event support, not discovery.
For a SessionStart or UserPromptSubmit hook, codex debug prompt-input renders the exact model-visible prompt input as JSON. That is the direct check that the developer context made it in. For other events, the marker plus the model's response is the practical test, because the hooks page does not describe a hook-specific debug log.
The short version#
/hooksfirst. If it is not listed, Codex did not discover it; check the path, the project trust state, and the feature flag.- Trust is per hash. Editing a hook makes it untrusted again and it is skipped until reviewed.
- Event names and matchers are literal.
UserPromptSubmit,Stop, andInterruptignore matchers; hosted tools do not use the tool-hook path. - The handler runs with the session working directory. Use an absolute or Git-root path, and watch the short timeouts on
SessionEndandInterrupt. - Plain stdout only reaches the model on a few events. Everywhere else, return
additionalContextinsidehookSpecificOutput. - In a linked worktree or an installed plugin, loading can fail before your script runs. Move the hook to
~/.codex/hooks.jsonas the workaround.
This guide is part of Codex guides.
Where to go next#
- Why Codex keeps asking for approval — if the prompt you are seeing is not coming from the hook.
- How to undo in Codex — if a hook ran and something changed anyway.
- Which config.toml Codex is actually reading — if the hook sits in a layer that is not loading.