AgentUnionLocal № 001

Guide · Codex

Codex hooks: hooks.json, /hooks trust, and blocking prompts and tool calls

Hooks let you run your own program at fixed points in a Codex session: before a prompt reaches the model, before a tool runs, when a turn stops. This guide covers where Codex looks for them, what they receive, how they block, and the trust review that decides whether they run at all.

Updated 18 min readBy the AgentUnion team

Poster-style illustration: the AgentUnion robot at a desk reading a long contract through a magnifying glass, with a rubber stamp, an ink pad and a brass padlock beside it.

Key takeaways

  • Codex hooks are on by default. They live in ~/.codex/hooks.json, <repo>/.codex/hooks.json, or inline [hooks] tables in config.toml.
  • A hook blocks by exiting 2 with the reason on stderr, or by printing a JSON decision. UserPromptSubmit uses "decision": "block"; PreToolUse uses permissionDecision: "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_hooks still works as a deprecated alias. Use hooks in anything new.
  • Administrators can force hooks off with the same [features] setting in requirements.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:

FileScopeUse it for
~/.codex/hooks.jsonEvery session on your machinePersonal rules: working hours, secret checks
~/.codex/config.tomlEvery session on your machineThe same, if you'd rather keep one TOML file
<repo>/.codex/hooks.jsonSessions in that repositoryTeam rules committed with the code
<repo>/.codex/config.tomlSessions in that repositoryThe 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.

One format per layer

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 a hooks entry in .codex-plugin/plugin.json.
  • Managed hooks come from system, MDM or cloud configuration, or from requirements.toml with managed_dir (macOS and Linux) or windows_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

FieldWhat it does
typecommand or mcp_tool. prompt and agent are parsed but skipped.
commandThe command line to run. It receives the event as JSON on stdin.
commandWindowsAn optional alternative command for Windows.
statusMessageOptional text shown while the hook runs.
timeoutSeconds before Codex gives up on the hook. Most events default to 600.
asyncRun in the background. Background hooks can't block or rewrite anything.
additionalContextLimitCaps 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, PostToolUse and PermissionRequest match the tool name, such as Bash, apply_patch or mcp__fs__read. A matcher for Edit or Write also matches apply_patch.
  • SessionStart matches how the session began: startup, resume, clear, compact or fork.
  • PreCompact and PostCompact match manual or auto. SubagentStart and SubagentStop match the agent type.
  • UserPromptSubmit, Stop and Interrupt ignore matchers. Every prompt runs every UserPromptSubmit hook.

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

WhenEvents
During a turnUserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SubagentStop, Stop
On interruptInterrupt (not for subagents)
When a session or subagent startsSessionStart, SubagentStart
When the main thread endsSessionEnd (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:

FieldMeaning
session_idThe current Codex session. Subagent hooks get the parent session's ID.
transcript_pathPath to the session transcript, or null. Its format isn't a stable interface, so don't parse it in anything you depend on.
cwdThe session's working directory.
hook_event_nameWhich event fired.
modelThe 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.

Outputs that break a PreToolUse hook

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.

What a stable path does and doesn't promise

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.toml sources are trusted by policy. They don't need a review, and they can't be disabled from the /hooks browser.
  • 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-trust runs 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 = true makes Codex skip user, project, session and plugin hooks entirely. If your hooks never show up in /hooks on 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.commandExitOutput
rm -rf ~0deny: recursive delete of / or your home folder
git push --force origin main0deny: force push
git reset --hard HEAD~10deny: hard reset
rm -rf build/0nothing (allowed)
npm test0nothing (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 PreToolUse hook, and some specialised tool paths opt out.
  • write_stdin doesn't rerun PreToolUse. Input written into a process that's already running isn't checked again.
  • Failures can fail open. A PreToolUse hook 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 async can'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. prompt and agent handlers are parsed and skipped.
  • Enterprise cloud orchestration is narrower. There, only mcp_tool handlers 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

SymptomLikely cause and fix
The hook never runs, and Codex warns at startupIt isn't trusted yet. Open /hooks, review it and trust it.
It ran yesterday, and today it doesn'tThe definition changed, so its hash changed. Review it again in /hooks, and keep the command stable from now on.
Project hooks are ignoredThe project's .codex/ layer isn't trusted. Hooks in a repository only load in trusted projects.
A startup warning mentions both formatsOne layer has a hooks.json and inline [hooks]. Keep one.
The hook runs but nothing is blockedIt exits 1 or another non-2 code. Exit 2, or print a JSON decision and exit 0.
PreToolUse hook reports a failureIt returned an unsupported field such as ask, continue: false or stopReason. Use permissionDecision: "deny".
"Permission denied" in the hook errorThe script isn't executable. Run chmod +x on it, or call it through its interpreter (python3 script.py).
Repo hook breaks in subfoldersThe command uses a relative path. Resolve it from git rev-parse --show-toplevel.
Every command feels slowA 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 machineHooks 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.

Get AgentUnion

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.