All articles
platform engineering·advanced··Updated

Codex hooks: enforce policy at lifecycle boundaries

Codex hooks run deterministic commands at 11 lifecycle events, with distinct contracts for tool policy, approval decisions, context injection, and completion gates.

codexcoding-agentsextensibilityhookspatterns

A prompt asks an agent to follow policy. A hook executes policy at a defined lifecycle boundary. The difference matters when a repository must block a tool call, inject current context, record an event, or prevent a turn from ending before verification.

Codex currently exposes 11 hook events. Each event has its own input and output contract; treating them as one universal callback API produces configurations that parse but do not enforce the intended behavior.

Resources
Resource Link
Codex hooks reference developers.openai.com/codex/hooks
Codex hooks schemas github.com/openai/codex
Claude Code hooks reference code.claude.com/docs/en/hooks

The released surface has 11 events

Codex lifecycle events
Boundary Events Typical use
Session SessionStart, SessionEnd Load context, save notes, clean up
Prompt UserPromptSubmit Validate or augment a submitted prompt
Tool PreToolUse, PermissionRequest, PostToolUse Block, approve, rewrite, or inspect supported calls
Compaction PreCompact, PostCompact Save state and restore critical context
Subagent SubagentStart, SubagentStop Add role context or continue delegated work
Turn Stop Gate completion and request another pass

Only type: "command" handlers run in the current Codex release. prompt and agent handlers are parsed but skipped.

Discovery is additive and trust-gated

Codex reads hooks.json or inline [hooks] tables next to active configuration layers. The common locations are:

  • ~/.codex/hooks.json and ~/.codex/config.toml for user configuration
  • <repo>/.codex/hooks.json and <repo>/.codex/config.toml for project configuration

Matching hooks from every active source accumulate. Multiple matching command handlers for one event launch concurrently, so one handler cannot depend on another handler finishing first.

Project hooks run only after the project configuration layer is trusted. Non-managed commands also require review of the exact hook definition. Use /hooks to inspect sources and approve new or changed hashes.

Hooks are enabled by default. The canonical feature key turns them off:

~/.codex/config.tomltoml
[features]
hooks = false

The older codex_hooks key remains a deprecated alias. It should not appear in new configuration.

Configuration has three levels

A configuration nests handlers under an event and an optional matcher group:

.codex/hooks.jsonjson
{
  "description": "Repository policy and completion gates.",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/block_destructive_git.py\"",
            "timeout": 10,
            "statusMessage": "Checking repository command"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/require_build.py\"",
            "timeout": 120,
            "statusMessage": "Verifying the production build"
          }
        ]
      }
    ]
  }
}

Hook commands run with the session working directory. A repository hook should resolve its script from the Git root because Codex may start in a subdirectory.

Most handlers default to a 600-second timeout. SessionEnd defaults to one second and permits at most three seconds. Set a tighter timeout on hot paths such as PreToolUse.

Matchers filter event-specific values

matcher is a regular expression. Its target depends on the event:

Matcher targets
Events Matcher target
PreToolUse, PermissionRequest, PostToolUse Tool name
SessionStart startup, resume, clear, or compact
PreCompact, PostCompact manual or auto
SubagentStart, SubagentStop Subagent type
SessionEnd End reason, currently other
UserPromptSubmit, Stop Matcher ignored

Shell commands and unified execution match Bash. File changes through apply_patch match apply_patch, Edit, or Write. MCP calls match their full tool name. Hosted tools such as WebSearch do not pass through the local tool-hook path.

Command hooks receive JSON on standard input

Every command handler receives one JSON object on stdin. The shared fields include session_id, transcript_path, cwd, hook_event_name, and model. Turn events add turn_id; several events also include permission_mode.

Tool events add tool_name, tool_input, and tool_use_id. PostToolUse also receives tool_response. Stop adds stop_hook_active and last_assistant_message.

An exit code of zero with no output means success. Event-specific JSON on stdout can steer Codex. Exit code two with a reason on stderr blocks or redirects supported events according to that event’s contract.

The transcript path is convenient, not stable API. Prefer the documented event fields when they contain the required information.

Block before a supported tool runs

PreToolUse is the correct boundary for rejecting a supported tool call before it produces side effects. This example blocks two destructive Git operations and leaves every other command unchanged:

.codex/hooks/block_destructive_git.pypython
#!/usr/bin/env python3
import json
import re
import sys

event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")

blocked = (
    r"(^|[;&|][;&|]?\s*)git\s+reset\s+--hard(?:\s|$)",
    r"(^|[;&|][;&|]?\s*)git\s+clean\s+-(?=[A-Za-z]*f)(?=[A-Za-z]*d)[A-Za-z]+(?:\s|$)",
)

if any(re.search(pattern, command) for pattern in blocked):
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": (
                "Repository policy blocks destructive Git history cleanup."
            )
        }
    }))

The deny response prevents the command from running. A successful hook can also return permissionDecision: "allow" with updatedInput to rewrite a supported call.

permissionDecision: "ask" is not supported for PreToolUse. Returning it marks the hook run as failed and lets normal tool processing continue. Use PermissionRequest when policy should decide an approval that Codex is already about to surface.

Decide an approval separately

PermissionRequest fires only when Codex is about to ask for approval. Its response shape differs from PreToolUse:

permission-request-output.jsonjson
{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "deny",
      "message": "Repository policy does not permit this escalation."
    }
  }
}

A decision of allow approves the request without showing the prompt. A decision of deny blocks it. If no matching hook decides, Codex continues through its normal approval flow. When several hooks decide, any denial wins.

Gate completion with a Stop hook

Stop fires when the main agent is ready to end a turn. Returning decision: "block" does not reject the turn; Codex creates a continuation prompt from the supplied reason.

This hook runs the repository’s production build. It requests one corrective pass, then stops with an explicit failure instead of looping forever:

.codex/hooks/require_build.pypython
#!/usr/bin/env python3
import json
import subprocess
import sys

event = json.load(sys.stdin)
result = subprocess.run(
    ["npm", "run", "build"],
    cwd=event["cwd"],
    capture_output=True,
    text=True,
    timeout=90,
)

if result.returncode == 0:
    print("{}")
elif event.get("stop_hook_active"):
    print(json.dumps({
        "continue": False,
        "stopReason": "The production build still fails after one continuation."
    }))
else:
    tail = (result.stdout + "\n" + result.stderr)[-3000:]
    print(json.dumps({
        "decision": "block",
        "reason": "Fix the failing production build, then verify it again.\n" + tail
    }))

stop_hook_active reveals whether Stop has already continued the turn. Every continuation policy needs a bound: one retry, an elapsed-time limit, or an external attempt counter.

PostToolUse cannot undo side effects

PostToolUse runs after a supported tool returns, including failed shell commands. It can replace the model-visible result with feedback or stop normal result processing, but it cannot reverse the tool’s filesystem, network, or service side effects.

Use PostToolUse for audit records, lint feedback, and result validation. Use PreToolUse, the sandbox, or approval policy when an operation must not happen.

Background handlers with "async": true are suitable for advisory logging. They cannot block, approve, or rewrite the event that triggered them, and Codex cancels unfinished background work when the session ends.

Port behavior, not configuration

Claude Code hooks belong inside its settings files and support a broader handler set. Codex hooks belong next to Codex configuration and currently execute command handlers only.

A cross-agent policy should share the underlying script or rule data, then provide a thin adapter for each product’s event input and output. Copying a hooks.json file between products preserves familiar names while silently changing behavior.

Takeaways

Choose the lifecycle boundary first

Block before a tool call, decide approvals at PermissionRequest, and gate completion at Stop.

Implement the released contract

Codex currently runs command handlers across 11 events; parsed prompt and agent handlers are skipped.

Bound every continuation

Use stop_hook_active or durable attempt state so a completion gate cannot create an infinite loop.

Keep hooks behind the sandbox

Hook coverage is broad but not universal, and PostToolUse cannot undo completed side effects.