Codex CLI Hooks: A Practical Introduction
How Codex CLI hooks work: where hooks.json and [hooks] live, trust review with /hooks, a PreToolUse hook that blocks Bash commands, and what hooks can't stop.
- Codex hooks run scripts or MCP tools during the agentic loop. They're on by default, load from hooks.json or [hooks] tables in config.toml under ~/.codex/ and <repo>/.codex/, and every matching hook from every source runs.
- A non-managed hook runs only after you review and trust it with /hooks. Trust is recorded against the hook's hash, so a new or changed hook is skipped until you trust it again, and project hooks load only when the project's .codex/ layer is trusted.
- A PreToolUse hook blocks a call by returning permissionDecision: "deny" in JSON or by exiting 2 with the reason on stderr. Hosted tools such as WebSearch don't go through tool hooks, and the docs call hooks a useful guardrail, not a complete enforcement boundary.
Codex hooks let you run your own scripts or MCP tools at fixed points in the agent loop: before a tool runs, after it runs, when a turn stops, and more. This guide covers what hooks are, where Codex looks for them, the trust review you have to pass before a hook runs, a minimal PreToolUse hook that blocks risky Bash commands, one PostToolUse and one Stop example, a checklist for hooks that don’t fire, and the limits. Everything here comes from the official hooks page, checked 2026-11-03. We have not run the hook examples below in Codex. They follow the documented format but are unverified.
What Codex hooks are
Hooks are an extensibility framework for Codex. They let you run scripts or MCP tools during the agentic loop. The docs list uses such as sending the chat to a logging engine, scanning prompts for pasted API keys, and running a validation check when a turn stops.
Hooks are enabled by default. Three rules shape how they run:
- Matching hooks from multiple files all run
- Multiple matching command hooks for the same event are launched concurrently, so one hook can’t stop another from starting
- Non-managed hooks must be reviewed and trusted before they run
The events:
| When | Events |
|---|---|
| During a turn | PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, UserPromptSubmit, SubagentStop, Stop |
| For each model request attempt | PreModelRequest, PostModelRequest |
| When you interrupt an active turn | Interrupt (not for subagents) |
| When a session or subagent starts | SessionStart, SubagentStart |
| When the main thread ends | SessionEnd (not for subagents) |
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
If you know Claude Code hooks
Some things look familiar. Codex uses event names such as PreToolUse, PostToolUse, UserPromptSubmit and Stop, a PreToolUse hook can deny with hookSpecificOutput.permissionDecision, and exit code 2 blocks with the reason on stderr. Three Codex-specific points are worth knowing before you copy a Claude Code hook across:
- Hooks live in
hooks.jsonor[hooks]tables inconfig.tomlunder.codex/, not in a settings.json - Each non-managed hook has to be trusted in
/hooks, and trust is tied to the hook’s hash - Tool names differ: shell commands match as
Bash, and file edits go throughapply_patch, whichmatchercan also match asEditorWrite
We compared only what the two sets of docs state; we haven’t tested whether a given Claude Code hook script behaves the same in Codex. For Claude Code’s side, see Claude Code Hooks in Practice and the Claude Code hooks reference: every event and exit code on one page.
Where Codex looks for hooks
Codex reads hooks from hooks.json files or inline [hooks] tables in config.toml, next to its active config layers. The four most useful locations are:
~/.codex/hooks.json~/.codex/config.toml<repo>/.codex/hooks.json<repo>/.codex/config.toml
If more than one source exists, Codex loads all matching hooks; a higher-precedence layer doesn’t replace a lower one. If one layer has both hooks.json and inline [hooks], Codex merges them and warns at startup, so pick one form per layer. Enabled plugins can bundle hooks too, and those go through the same trust review.
Project-local hooks load only when the project’s .codex/ layer is trusted. In an untrusted project, Codex still loads your user and system hooks.
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
Review and trust hooks
Before a non-managed hook can run, you review and trust its exact definition. Codex records trust against the hook’s current hash, so a new or changed hook is marked for review and skipped until you trust it. Editing a trusted hook therefore turns it off until you review it again.
Run /hooks in the CLI to see hook sources, review new or changed hooks, trust them, or disable individual non-managed hooks. When hooks need review at startup, Codex prints a warning telling you to open /hooks. Managed hooks from system, MDM, cloud or requirements.toml sources are trusted by policy and can’t be disabled from that browser.
For one-off automation that vets hook sources some other way, --dangerously-bypass-hook-trust runs enabled hooks without persisted trust for that invocation.
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
A minimal PreToolUse hook that blocks risky Bash commands
A hook has three levels: an event, a matcher group that decides when the event matches, and one or more handlers. Put this in <repo>/.codex/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py\"",
"timeout": 30,
"statusMessage": "Checking Bash command"
}
]
}
]
}
}
Commands run with the session’s working directory, and Codex may be started from a subdirectory, so the docs recommend resolving repo-local scripts from the git root as above. The same hook in config.toml:
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = '/usr/bin/python3 "$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py"'
timeout = 30
statusMessage = "Checking Bash command"
Every command hook receives one JSON object on stdin. For Bash, the command is in tool_input.command. Plain text on stdout is ignored, so the script answers in JSON. Save it as .codex/hooks/pre_tool_use_policy.py (unverified example):
#!/usr/bin/env python3
"""PreToolUse hook: deny Bash commands that match a blocklist. Unverified example."""
import json
import re
import sys
event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")
# Patterns are examples; adjust them to your repository's policy.
BLOCKED = [
(r"\brm\s+-\w*r\w*f|\brm\s+-\w*f\w*r", "recursive forced delete"),
(r"\bgit\s+push\b.*\s(--force|-f)\b", "force push"),
]
for pattern, label in BLOCKED:
if re.search(pattern, command):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked by repository policy: {label}.",
}
}))
sys.exit(0)
sys.exit(0)
Exit 0 with no output counts as success, and the tool call goes ahead. Instead of JSON, you can exit with code 2 and write the reason to stderr. Codex also accepts the older {"decision": "block", "reason": "..."} shape.
Don’t return permissionDecision: "ask", continue: false, stopReason or suppressOutput from PreToolUse. They’re parsed but not supported yet, and Codex marks the hook run as failed and continues the tool call. A regex blocklist like this one is easy to get around, so test it against the inputs you care about. More blocklist patterns are in PreToolUse Hook Examples for Claude Code (Copy-Paste), and the holes we found in our first git guard are in Claude Code Hooks in Practice.
Then start Codex in the repository, open /hooks, review the new hook and trust it. Until you do, it’s skipped.
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
PostToolUse and Stop: one use each
PostToolUse runs after a supported tool produces output, including Bash commands that exit non-zero. It can’t undo what the tool did. Returning decision: "block" or exiting 2 replaces the tool result with your feedback, and the model continues from it. A typical use is flagging edits to generated files after a command ran (unverified example):
{
"decision": "block",
"reason": "The command changed generated files. Regenerate them instead of editing by hand.",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Files under gen/ are generated."
}
}
Stop runs when a turn is about to end, and its matcher is ignored. It must print JSON when it exits 0; plain text is invalid. decision: "block" doesn’t reject the turn: Codex continues and sends your reason as a new prompt. That suits a “run the tests before you finish” check (unverified example):
{
"decision": "block",
"reason": "Run one more pass over the failing tests."
}
The input includes stop_hook_active, which tells you whether this turn was already continued by Stop; check it so the hook doesn’t keep the turn going forever. If any matching Stop hook returns continue: false, that takes precedence over the others’ continuation decisions.
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
When a hook doesn’t run
Check these in order:
- Trust: is the hook trusted in
/hooks? New or changed hooks are skipped until they are - Project trust: for a hook in
<repo>/.codex/, is that.codex/layer trusted? - Matcher: does it match the tool name? Shell commands are
Bash, file edits areapply_patch(orEdit,Write), MCP tools are names likemcp__filesystem__read_file.UserPromptSubmit,StopandInterruptignorematcher, andPreModelRequest/PostModelRequestdon’t support it - Tool coverage: hosted tools such as
WebSearchnever reach PreToolUse or PostToolUse - Timeout:
timeoutis in seconds; most hooks default to 600, whileSessionEndandInterruptdefault to 1 and allow up to 3 - Output: PreToolUse and PostToolUse ignore plain text on stdout; Stop and SubagentStop need JSON when they exit 0
- Feature flag:
[features] hooks = falseinconfig.toml, or in an admin’srequirements.toml, turns hooks off.codex_hooksis a deprecated alias for the same key - Path: a relative path such as
.codex/hooks/...breaks when Codex starts from a subdirectory
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
For the same kind of checklist on the Claude Code side, see Claude Code Hooks Not Working? A Checklist.
Limits
PreToolUse and PostToolUse cover shell commands, apply_patch, MCP tools and most other local function tools. Hosted tools such as WebSearch don’t use the local function-tool hook path, so tool hooks never see them. Some specialized tool paths can also opt out of the default hook path. The docs say to treat tool hooks as a useful guardrail, not a complete enforcement boundary.
Two more limits from the same page:
- When several hooks match one event, they start concurrently. For
PermissionRequest, anydenywins; forStop,continue: falsetakes precedence - Background hooks (
"async": true) can’t block, approve or rewrite anything. Use synchronous hooks for policies
Source: https://developers.openai.com/codex/hooks (checked 2026-11-03)
To get Codex CLI set up first, see Getting Started with Codex CLI.
Summary
- Codex hooks are on by default and load from
hooks.jsonor[hooks]inconfig.toml, under~/.codex/and<repo>/.codex/. All matching hooks run - Non-managed hooks run only after you trust them in
/hooks. Trust follows the hook’s hash, so edits need a new review - Project hooks load only when the project’s
.codex/layer is trusted - A PreToolUse hook denies with
permissionDecision: "deny"or exit 2. Shell commands match asBash, file edits asapply_patch,EditorWrite - Stop’s
decision: "block"continues the turn with your reason as the next prompt - Hosted tools bypass tool hooks; treat hooks as a guardrail, not a boundary
- The examples here are unverified; test them before relying on them
Related articles
- Claude Code hooks reference: every event and exit code on one page
- Claude Code Hooks in Practice
- PreToolUse Hook Examples for Claude Code (Copy-Paste)
- Claude Code Hooks Not Working? A Checklist
- Getting Started with Codex CLI
FAQ
- Where do I put Codex CLI hooks?
- In hooks.json or in inline [hooks] tables in config.toml. The docs name four practical locations: ~/.codex/hooks.json, ~/.codex/config.toml, <repo>/.codex/hooks.json and <repo>/.codex/config.toml. If several exist, Codex loads all matching hooks.
- Why doesn't my Codex hook run?
- The usual causes are a hook you haven't trusted yet (new or changed hooks are skipped until you trust them in /hooks), a project .codex/ layer that isn't trusted, a matcher that doesn't match the tool name or is ignored for that event, a timeout, or hooks turned off with [features] hooks = false.
- How does a Codex PreToolUse hook block a command?
- Return JSON on stdout with hookSpecificOutput.permissionDecision set to "deny" and a permissionDecisionReason, or exit with code 2 and write the reason to stderr. Plain text on stdout is ignored.
- Are Codex hooks a security boundary?
- No. Hosted tools such as WebSearch don't use the tool hook path, some specialized tool paths can opt out, and the docs say to treat tool hooks as a useful guardrail, not a complete enforcement boundary.
- How long can a Codex hook run?
- timeout is in seconds. If you omit it, most hooks get 600 seconds. SessionEnd and Interrupt default to 1 second and support up to 3 seconds.