Key takeaways
- Codex hooks are on by default. They live in
~/.codex/hooks.json,<repo>/.codex/hooks.json, or inline[hooks]tables inconfig.toml. - A hook blocks by exiting
2with the reason on stderr, or by printing a JSON decision.UserPromptSubmituses"decision": "block";PreToolUseusespermissionDecision: "deny". - Non-managed hooks don't run until you trust them in
/hooks. Trust is tied to the hook's hash, so any edit sends it back for review. - Hooks are a strong guardrail, not a sandbox. Hosted tools such as WebSearch don't trigger them, and a hook that errors or times out can fail open.
What Codex hooks are
A hook is a command (or an MCP tool call) that Codex runs at a named moment in a session, hands a JSON description of that moment, and listens to.
The interesting moments are the ones where you can still change the outcome. UserPromptSubmit fires after you press Enter and before your prompt reaches the model. PreToolUse fires after the model asks to run a tool, such as a shell command or a patch, and before Codex runs it. A hook at either point can let things through, add context, or refuse.
That makes hooks the right tool for policies you want enforced every time instead of politely requested in a prompt: never force-push, never paste an API key into a prompt, don't start new work after 6 PM. Instructions in AGENTS.md are advice the model weighs. A hook is a program that runs whether or not the model agrees.
Everything below comes from the official Codex hooks documentation, plus three example hooks we ran against sample inputs before publishing. Where the docs are silent, we say so rather than guess.
On by default, and how to turn them off
You don't need a feature flag to use hooks. They're enabled by default. To switch them off for yourself, set the hooks feature to false in ~/.codex/config.toml:
~/.codex/config.toml
[features]
hooks = false
Two details matter here:
- The older key
codex_hooksstill works as a deprecated alias. Usehooksin anything new. - Administrators can force hooks off with the same
[features]setting inrequirements.toml. If your hooks never run on a managed machine, check that first.
Where Codex finds hooks
Codex discovers hooks in a hooks.json file, or in inline [hooks] tables in config.toml, next to each active config layer. In practice you'll use one of four places:
| File | Scope | Use it for |
|---|---|---|
~/.codex/hooks.json | Every session on your machine | Personal rules: working hours, secret checks |
~/.codex/config.toml | Every session on your machine | The same, if you'd rather keep one TOML file |
<repo>/.codex/hooks.json | Sessions in that repository | Team rules committed with the code |
<repo>/.codex/config.toml | Sessions in that repository | The same, in TOML |
Hooks add up rather than override each other. Codex loads every matching hook from every source, so a higher-precedence layer doesn't replace the hooks in a lower one. If you define the same hook at user and project level, expect it to run twice.
If one layer has both a hooks.json and inline [hooks] tables, Codex prints a warning at startup. Pick one format per layer.
Three more sources exist, mostly for teams and tool authors:
- Project hooks only load in trusted projects. Hooks in
<repo>/.codex/are ignored until that project's.codex/layer is trusted, so cloning a repository can't quietly install commands on your machine. - Plugins can bundle hooks at
hooks/hooks.json, or point to them from ahooksentry in.codex-plugin/plugin.json. - Managed hooks come from system, MDM or cloud configuration, or from
requirements.tomlwithmanaged_dir(macOS and Linux) orwindows_managed_dir(Windows).
For hooks committed to a repository, don't use a relative path like .codex/hooks/check.py. Codex may start in a subdirectory, and the relative path breaks. The docs recommend resolving the path from the git root, for example python3 "$(git rev-parse --show-toplevel)/.codex/hooks/check.py".
The hooks.json shape
A hooks.json file has one top-level hooks object. Its keys are event names. Each event holds a list of matcher groups, and each group holds a list of handlers:
~/.codex/hooks.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.codex/hooks/block-dangerous.py",
"statusMessage": "Checking command",
"timeout": 10
}
]
}
]
}
}
The same hook as an inline table in config.toml uses arrays of tables: [[hooks.PreToolUse]] opens a matcher group and [[hooks.PreToolUse.hooks]] opens a handler inside it.
~/.codex/config.toml
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 ~/.codex/hooks/block-dangerous.py"
timeout = 10
We parsed both with Python's json and tomllib modules; they produce the same structure. Note that a few keys are spelled differently in TOML (the docs show command_windows for the JSON commandWindows), so check the docs before porting optional fields.
Handler fields
| Field | What it does |
|---|---|
type | command or mcp_tool. prompt and agent are parsed but skipped. |
command | The command line to run. It receives the event as JSON on stdin. |
commandWindows | An optional alternative command for Windows. |
statusMessage | Optional text shown while the hook runs. |
timeout | Seconds before Codex gives up on the hook. Most events default to 600. |
async | Run in the background. Background hooks can't block or rewrite anything. |
additionalContextLimit | Caps how much context the hook can add. 0 lets one hook fill the whole context window, so leave it alone unless you mean it. |
An mcp_tool handler calls a tool on an MCP server you already have configured. It takes server and tool (both required), an optional input object of argument templates, plus timeout (default 600) and statusMessage. Templates use ${field.nested} to read from the hook event; a placeholder that fills a whole value keeps its JSON type. MCP hooks run synchronously, don't ask for approval, and don't start or reconnect servers. Their errors don't block, so don't use them for anything that must refuse.
Matchers
A matcher is a regular expression. "*", an empty string, or no matcher at all matches everything. What it's matched against depends on the event:
PreToolUse,PostToolUseandPermissionRequestmatch the tool name, such asBash,apply_patchormcp__fs__read. A matcher forEditorWritealso matchesapply_patch.SessionStartmatches how the session began:startup,resume,clear,compactorfork.PreCompactandPostCompactmatchmanualorauto.SubagentStartandSubagentStopmatch the agent type.UserPromptSubmit,StopandInterruptignore matchers. Every prompt runs everyUserPromptSubmithook.
Because matchers are regular expressions, anchor them. Bash without anchors matches any tool whose name merely contains "Bash". ^Bash$ matches exactly one.
Events and when they run
| When | Events |
|---|---|
| During a turn | UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SubagentStop, Stop |
| On interrupt | Interrupt (not for subagents) |
| When a session or subagent starts | SessionStart, SubagentStart |
| When the main thread ends | SessionEnd (not for subagents, always synchronous) |
Most policies need only two of these. UserPromptSubmit decides whether new work starts. PreToolUse decides whether work already underway may take its next step. Together they cover a session from both ends: no new prompts, and no further tool calls from a prompt that was already running.
The others are useful for different jobs. PostToolUse can replace a tool's result with feedback (the command has already run, so this doesn't undo anything). A Stop hook that returns "decision": "block" keeps the turn going, and its reason becomes a new user prompt. That's handy for "run the tests before you stop", and easy to turn into an infinite loop, so test it. A PermissionRequest hook can answer an approval prompt for you; if any hook denies, the denial wins.
What a hook receives
Every command hook gets one JSON object on stdin. These fields are always present:
| Field | Meaning |
|---|---|
session_id | The current Codex session. Subagent hooks get the parent session's ID. |
transcript_path | Path to the session transcript, or null. Its format isn't a stable interface, so don't parse it in anything you depend on. |
cwd | The session's working directory. |
hook_event_name | Which event fired. |
model | The active model slug. This one is Codex-specific. |
UserPromptSubmit adds turn_id, permission_mode and prompt, the text about to be sent. PreToolUse adds turn_id, permission_mode, tool_name, tool_use_id and tool_input. For Bash and apply_patch, the command or patch is in tool_input.command.
Here's the input we used to test the first example below. The field names are the documented ones; the values are made up.
Sample PreToolUse input
{
"session_id": "s1",
"transcript_path": null,
"cwd": "/tmp/repo",
"hook_event_name": "PreToolUse",
"model": "gpt-5",
"turn_id": "t1",
"permission_mode": "default",
"tool_name": "Bash",
"tool_use_id": "c1",
"tool_input": { "command": "git push --force origin main" }
}
A useful habit while developing: point a hook at a script that copies stdin to a file, run one turn, and read the real payload your Codex version sends. The docs point to generated schemas in the Codex repository for exact types, and warn that schemas on the main branch can include fields your installed release doesn't have yet.
How a hook blocks
A hook can refuse in two ways. Use one per hook, not both.
Exit code 2 and stderr
Exit 0 with no output means "carry on". Exit 2 blocks, and whatever you wrote to stderr is the reason. This is the simplest form and works for both UserPromptSubmit and PreToolUse. Codex documents exit 2 as the blocking code; don't rely on any other non-zero code to stop anything.
A JSON decision on stdout
For more control, exit 0 and print a single JSON object. The shape depends on the event.
UserPromptSubmit: refuse the prompt
{
"decision": "block",
"reason": "This prompt looks like it contains an API key (sk-...). Remove the key and send it again."
}
PreToolUse: refuse the tool call
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked a force push. Ask the user to run this command themselves if it is intended."
}
}
For PreToolUse, Codex still accepts the older "decision": "block" with a "reason", but new hooks should use hookSpecificOutput. A PreToolUse hook can also return additionalContext, or permissionDecision: "allow" with an updatedInput that rewrites the tool's arguments.
permissionDecision: "ask", continue: false, stopReason and suppressOutput aren't supported on PreToolUse, and using them makes the hook fail. Claude Code accepts some of these, so hooks ported from Claude Code are the usual source of this bug. The fields updatedMCPToolOutput, updatedPermissions and interrupt are reserved on every event and also fail.
Plain text on stdout
Plain, non-JSON stdout is added to the model's context as developer context for SessionStart, UserPromptSubmit and SubagentStart. It's ignored for PreToolUse and PostToolUse, and invalid for Stop, SubagentStop and Interrupt. In other words, echo in a UserPromptSubmit hook talks to the model, while echo in a PreToolUse hook goes nowhere.
Long output is spilled to a file. Anything over about 2,500 tokens goes to <temp_dir>/hook_outputs/, and the model sees a preview. Never print secrets from a hook, even by accident in a debug line.
Timeouts
timeout is in seconds. Most hooks default to 600. SessionEnd and Interrupt default to 1 second and allow at most 3. For hooks on the hot path (every prompt, every tool call), set a short explicit timeout such as 10 seconds and keep the hook itself well under it. A slow PreToolUse hook slows every command the agent runs.
The /hooks trust model
This is the part people trip over. Writing a hook into hooks.json doesn't make it run.
Codex requires you to review and trust every non-managed hook before it runs. Until then, the hook is skipped, and Codex prints a warning at startup telling you to open /hooks. In the CLI, /hooks lets you see where each hook came from, review new or changed hooks, trust them, and disable individual non-managed hooks.
Trust is tied to the hook's hash
Codex records trust against each hook's current hash. Change anything in the definition and the hash changes, so Codex marks the hook for review and skips it until you trust it again. That's deliberate: a hook you approved last week can't be silently swapped for a different command.
It also means routine edits cost a review. Rename the script, add a flag, or move the file to a new folder, and the next session starts with that hook switched off until someone opens /hooks. For a personal hook that's a minor annoyance. For a hook that enforces something, such as a working-hours rule, it's a silent gap: the policy simply isn't running until you notice the warning.
Keep the command stable
The practical fix is to keep the hook definition identical across updates and change what sits behind it instead:
- Point the command at a fixed path, such as
~/.codex/hooks/policy, rather than a path that includes a version number. - Ship updates by replacing the file at that path (atomically, with a rename), not by editing
hooks.json. - Keep flags and environment variables in the definition constant. Put anything that changes in a config file the script reads.
This is how AgentUnion handles its own hooks. Every hook it installs runs ~/.config/agentunion/runtime/current/agentunion, and an app update swaps the binary at that path instead of rewriting the hook. The definition in hooks.json, and with it the command Codex has on record, stays the same from one release to the next. You trust the hooks once in /hooks, and an update doesn't send them back for review.
Codex records trust against the hook's hash. Keeping the command identical avoids the re-review a changed command definitely triggers. The docs don't spell out exactly what goes into the hash, so if Codex does ask again after an update, review and trust the hooks as usual.
Managed hooks, plugins and automation
- Managed hooks from system, MDM, cloud or
requirements.tomlsources are trusted by policy. They don't need a review, and they can't be disabled from the/hooksbrowser. - Plugins don't bring trust with them. Installing or enabling a plugin doesn't trust its hooks; they go through review like any other.
--dangerously-bypass-hook-trustruns enabled hooks without a stored trust record, for that one invocation. It exists for automation that already vets hook sources some other way, such as CI with a pinned repository. Don't add it to a shell alias.allow_managed_hooks_only = truemakes Codex skip user, project, session and plugin hooks entirely. If your hooks never show up in/hookson a work machine, this setting may be why.
Three tested examples
Each example below was run against sample stdin before publishing. We didn't run them inside Codex itself, so trust them in /hooks and try one prompt before relying on them. The Python examples need python3 on your PATH.
1. Block destructive shell commands
A PreToolUse hook on the Bash tool that refuses three commands you rarely want an agent to run unattended. It answers with a JSON deny, so Codex passes the reason back to the model.
~/.codex/hooks/block-dangerous.py
#!/usr/bin/env python3
"""PreToolUse hook: refuse a few destructive shell commands."""
import json
import re
import sys
event = json.load(sys.stdin)
command = (event.get("tool_input") or {}).get("command") or ""
DANGEROUS = [
(r"\brm\s+-[a-zA-Z]*[rf][a-zA-Z]*\s+(/|~|\$HOME)(\s|$)", "recursive delete of / or your home folder"),
(r"\bgit\s+push\b.*\s(--force|-f)(\s|$)", "force push"),
(r"\bgit\s+reset\s+--hard\b", "hard reset"),
]
for pattern, label in DANGEROUS:
if re.search(pattern, command):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked a {label}. Ask the user to run this command themselves if it is intended.",
}
}))
break
sys.exit(0)
Register it with the hooks.json (or TOML) from the shape section above. What we saw when we fed it sample events:
tool_input.command | Exit | Output |
|---|---|---|
rm -rf ~ | 0 | deny: recursive delete of / or your home folder |
git push --force origin main | 0 | deny: force push |
git reset --hard HEAD~1 | 0 | deny: hard reset |
rm -rf build/ | 0 | nothing (allowed) |
npm test | 0 | nothing (allowed) |
Pattern lists like this are a seatbelt, not a lock. The same command wrapped in a script file, a shell variable or an encoded string walks straight past it. Use it to stop mistakes, not a determined adversary.
2. Stop prompts that contain an API key
A UserPromptSubmit hook that refuses any prompt containing something shaped like an OpenAI-style secret key. Matchers are ignored for this event, so there's no matcher in the config.
~/.codex/hooks/guard-secrets.py
#!/usr/bin/env python3
"""UserPromptSubmit hook: stop prompts that contain something shaped like an API key."""
import json
import re
import sys
event = json.load(sys.stdin)
prompt = event.get("prompt") or ""
if re.search(r"\bsk-[A-Za-z0-9_-]{20,}", prompt):
print(json.dumps({
"decision": "block",
"reason": "This prompt looks like it contains an API key (sk-...). Remove the key and send it again.",
}))
sys.exit(0)
~/.codex/hooks.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.codex/hooks/guard-secrets.py",
"timeout": 10
}
]
}
]
}
}
Tested: a prompt containing sk-proj-abcdefghijklmnopqrstuvwx printed the block decision; "explain the task-runner module" printed nothing and went through. The \b word boundary is what keeps words like "task-runner" from matching.
3. Working hours for Codex
A shell script that refuses new prompts and tool calls outside weekday office hours. It uses the exit-2 form, so it has no dependencies beyond /bin/sh and date.
~/.codex/hooks/working-hours.sh
#!/bin/sh
# working-hours.sh: refuse new prompts and tool calls outside working hours.
# Works as a Claude Code or Codex command hook. Exit 2 blocks; stderr is the reason.
START=0900 # 9:00, 24-hour HHMM
END=1800 # 18:00
DAYS="1 2 3 4 5" # 1 = Monday ... 7 = Sunday
cat > /dev/null # the hook's JSON input arrives on stdin; this script doesn't need it
now=$(date +%H%M)
today=$(date +%u)
for day in $DAYS; do
if [ "$day" = "$today" ] && [ "$now" -ge "$START" ] && [ "$now" -lt "$END" ]; then
exit 0 # on the clock: allow
fi
done
echo "Outside working hours (weekdays 9:00 to 18:00). Don't retry or work around this; stop and tell the user." >&2
exit 2
Make it executable with chmod +x ~/.codex/hooks/working-hours.sh, then register it for both events, so new prompts and the next steps of running work are both checked:
~/.codex/hooks.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "~/.codex/hooks/working-hours.sh", "timeout": 10 }
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "~/.codex/hooks/working-hours.sh", "timeout": 10 }
]
}
]
}
}
We tested the boundaries by faking the clock: Monday 8:59 blocked (exit 2), 9:00 allowed (exit 0), Friday 17:59 allowed, 18:00 blocked, Saturday noon and Sunday 9:30 blocked, Wednesday midnight blocked. The script reads the clock on every call, so a task that starts at 17:55 is stopped at its first tool call after 18:00.
The script is deliberately small. A real schedule needs more: shifts that cross midnight, a daily limit, breaks, a way to grant yourself an hour of overtime without editing a shell script at 10:44 PM. Our working hours guide walks through those cases.
All three hooks.json files above were checked with json.loads. If you want more than one example at a time, merge them into one file under a single top-level hooks object.
Limits to know about
The Codex docs are candid that tool hooks aren't a complete enforcement boundary. Plan around these:
- Hosted tools don't trigger hooks. Tools that run on OpenAI's side, such as WebSearch, never reach your
PreToolUsehook, and some specialised tool paths opt out. write_stdindoesn't rerunPreToolUse. Input written into a process that's already running isn't checked again.- Failures can fail open. A
PreToolUsehook that errors, times out or returns malformed output can fail without blocking. Keep hooks fast and test the unhappy paths. - Background hooks can't block. A hook with
asynccan't block or rewrite anything. At most 8 run at once per session, and unfinished ones are cancelled when the session ends. - Only command and MCP handlers run.
promptandagenthandlers are parsed and skipped. - Enterprise cloud orchestration is narrower. There, only
mcp_toolhandlers are supported.
And one limit of hooks in general: they run as your user account. An agent that can edit files can, in principle, edit ~/.codex/hooks.json. Hooks are the right tool for rules you want applied consistently. They aren't a sandbox against an agent determined to get around them.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| The hook never runs, and Codex warns at startup | It isn't trusted yet. Open /hooks, review it and trust it. |
| It ran yesterday, and today it doesn't | The definition changed, so its hash changed. Review it again in /hooks, and keep the command stable from now on. |
| Project hooks are ignored | The project's .codex/ layer isn't trusted. Hooks in a repository only load in trusted projects. |
| A startup warning mentions both formats | One layer has a hooks.json and inline [hooks]. Keep one. |
| The hook runs but nothing is blocked | It exits 1 or another non-2 code. Exit 2, or print a JSON decision and exit 0. |
PreToolUse hook reports a failure | It returned an unsupported field such as ask, continue: false or stopReason. Use permissionDecision: "deny". |
| "Permission denied" in the hook error | The script isn't executable. Run chmod +x on it, or call it through its interpreter (python3 script.py). |
| Repo hook breaks in subfolders | The command uses a relative path. Resolve it from git rev-parse --show-toplevel. |
| Every command feels slow | A PreToolUse hook is slow, or close to its timeout. Set a short timeout and make the common path fast. |
| Nothing runs at all on a work machine | Hooks may be off in requirements.toml, or allow_managed_hooks_only is set. |
Rather not maintain this yourself?
AgentUnion is a free Mac app that installs and maintains the Codex and Claude Code hooks for you: working hours, breaks, a daily limit, and overtime passes you take from the menu bar. Its hook command never changes between versions, so you trust it once in /hooks. The download is on its way.
FAQ
Are Codex hooks enabled by default?
Yes. Set hooks = false under [features] in config.toml to turn them off. codex_hooks still works as a deprecated alias, and administrators can enforce the setting in requirements.toml.
Why does Codex skip my hook?
Usually because it isn't trusted yet. Trust is recorded against each hook's current hash, so a new or edited hook is skipped until you trust it in /hooks. Project hooks also need the project's .codex/ layer to be trusted.
How does a Codex hook block a tool call?
Exit with code 2 and write the reason to stderr, or exit 0 and print hookSpecificOutput with permissionDecision: "deny" and a permissionDecisionReason. The older "decision": "block" shape still works.
Can Codex hooks guarantee an agent never runs a command?
No. Hosted tools such as WebSearch don't trigger hooks, write_stdin doesn't rerun PreToolUse, and a hook that errors or times out can fail without blocking. Treat hooks as a strong guardrail, not a sandbox.
Do I have to trust my hooks again after every change?
Yes, because any change to the definition changes its hash. Point the command at a fixed path and update the file behind it, so routine updates don't change the definition.