Codex hooks explained: setup, events and notifications

Codex hooks run your own command when Codex starts, stops or asks for approval. Where hooks.json lives, how to trust it, and a working sound when done.

By The IdlePal team · Published · Last checked against the official docs

Codex hooks run your own command at fixed points in a Codex session: when a session starts, when you submit a prompt, around tool calls, when Codex asks for approval, when a turn stops, and when you interrupt it. They’re on by default. You define them in ~/.codex/hooks.json, trust them once with /hooks, and Codex runs them from then on.

Below: where hooks live, the events, what goes in and out of a hook, and a setup that plays a sound when Codex finishes and shows a banner when it needs approval.

What are Codex hooks?

Codex hooks are an extensibility framework: a hook is a command (or an MCP tool call) that Codex runs when a lifecycle event fires. Typical uses: logging chats, blocking prompts that contain API keys, checks when a turn stops, and desktop alerts.

A few rules shape everything else:

  • Matching hooks from every file all run. A higher-precedence config layer doesn’t replace lower ones.
  • Multiple matching command hooks for the same event launch concurrently, so one can’t stop another from starting.
  • Non-managed hooks must be reviewed and trusted before they run.
  • Commands run with the session’s working directory as their cwd.
  • command and mcp_tool handlers run. prompt and agent handlers are parsed but skipped.

Hooks are enabled by default. To turn them off, add this to config.toml:

[features]
hooks = false

The older codex_hooks key still works as a deprecated alias.

Where do Codex hooks live?

Codex finds hooks next to its active config layers, either as a hooks.json file or as inline [hooks] tables in config.toml. The four locations you’ll actually use:

File Scope
~/.codex/hooks.json You, in every project
~/.codex/config.toml (inline [hooks]) You, in every project
<repo>/.codex/hooks.json One project
<repo>/.codex/config.toml (inline [hooks]) One project

Project hooks only load when the project’s .codex/ layer is trusted. User hooks load either way. If one layer has both a hooks.json and inline [hooks], Codex loads both and warns at startup, so pick one format per layer. Enabled plugins can also bundle hooks, which go through the same trust review.

Set up your first hook

Here is the whole loop, using a hook that plays a sound when a turn ends:

  1. Create ~/.codex/hooks.json if it doesn’t exist.
  2. Paste this in:
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Glass.aiff >/dev/null 2>&1 & echo '{}'",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
  1. Start Codex. If a hook needs review, Codex prints a warning telling you to open /hooks.
  2. Run /hooks, review the new hook and trust it.
  3. Send a prompt. You should hear Glass when the turn ends.

Two details in that command matter. afplay runs in the background with its output thrown away, and echo '{}' prints an empty JSON object, because Stop treats plain text on stdout as invalid. timeout is in seconds; without it, Codex allows 600 for most events.

Trust is stored against the hook’s current hash. Change one character of the command and the hook is skipped again until you re-trust it in /hooks. The same browser lets you inspect where each hook comes from and disable individual non-managed hooks. Hooks from managed sources (system, MDM, cloud or requirements.toml) are trusted by policy and can’t be disabled there.

Which events can you hook?

Event When it fires Matcher filters
SessionStart A session starts Source: startup, resume, clear, compact, fork
SessionEnd The main thread ends Reason (currently only other)
UserPromptSubmit You send a prompt Ignored
PreToolUse Before a tool call Tool name
PermissionRequest Codex is about to ask for approval Tool name
PostToolUse After a tool produces output Tool name
PreCompact / PostCompact Around context compaction manual or auto
SubagentStart / SubagentStop Around a subagent Subagent type
Stop A turn stops Ignored
Interrupt You interrupt an active turn on the main thread Ignored

The matcher is a regex. Use "*", "" or leave it out to match everything. Tool names include Bash, apply_patch (which also matches Edit and Write) and MCP names such as mcp__filesystem__read_file.

PermissionRequest doesn’t run for commands that don’t need approval. Interrupt and SessionEnd don’t run for subagents.

What goes in and out of a hook

Input on stdin

Every command hook gets one JSON object on stdin with these common fields:

  • session_id: the current session id (subagent hooks get the parent’s)
  • transcript_path: the transcript file, if any
  • cwd: the session’s working directory
  • hook_event_name: the event that fired
  • model: the active model slug

Most events add permission_mode and a turn_id. Events add their own fields on top: UserPromptSubmit adds prompt; tool events add tool_name and tool_input; Stop adds stop_hook_active and last_assistant_message. The transcript format isn’t a stable interface, so don’t build on parsing it.

Output on stdout

What you print matters, and it differs per event:

Event Plain text on stdout Notes
SessionStart, UserPromptSubmit, SubagentStart Added to the model’s context Print nothing unless you mean it
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact Ignored JSON can deny, allow or add context, per event
Stop, SubagentStop Invalid Must print JSON when exiting 0
Interrupt Invalid Exit 0 with no output, or JSON with an optional systemMessage

Exit 0 with no output counts as success and Codex carries on.

Exit code 2

Exit code 2 with a reason on stderr is a decision, not an error. For PreToolUse and UserPromptSubmit it blocks the call or prompt. For PostToolUse it feeds the reason back to the model. For Stop and SubagentStop it tells Codex to keep going, using your reason as a new prompt. So a notification hook on Stop must exit 0, or it will keep the turn running.

A Codex hooks setup for “done” and “needs you”

To know when Codex is finished and when it’s waiting on you, hook three events: Stop and Interrupt for “done”, and PermissionRequest for “needs you”.

  1. Open ~/.codex/hooks.json. If you added the first example, replace it with this:
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Glass.aiff >/dev/null 2>&1 & echo '{}'",
            "timeout": 10
          }
        ]
      }
    ],
    "Interrupt": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Pop.aiff >/dev/null 2>&1 & echo '{}'"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "afplay /System/Library/Sounds/Sosumi.aiff >/dev/null 2>&1 & osascript -e 'display notification \"Codex needs your approval\" with title \"Codex\"' >/dev/null 2>&1 &",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
  1. Save, start Codex and trust all three hooks in /hooks.
  2. If the banner never appears, run osascript -e 'display notification "test"' in Terminal once, then turn on Allow Notifications for Script Editor in System Settings → Notifications. osascript banners go through Script Editor.
  3. Test each one: let a turn finish (Glass), interrupt a turn (Pop), and ask for something that needs approval (Sosumi plus a banner).

Why the pieces look the way they do:

  • Stop and Interrupt both print {}, since plain text is invalid for them.
  • Interrupt hooks default to a one-second timeout and allow at most three, so the sound runs in the background and the hook returns at once.
  • PermissionRequest prints nothing. Plain text is ignored there, and with no decision Codex shows its normal approval prompt. Return a decision only if you want the hook to approve or deny for you.

Codex hooks vs the notify setting

Codex also has a notify setting in ~/.codex/config.toml that runs an external program:

notify = ["python3", "/path/to/notify.py"]

The differences:

  • Events. notify currently fires only for agent-turn-complete. Hooks cover the full event list above.
  • Input. notify passes one JSON argument with fields such as type, thread-id, turn-id, cwd, input-messages and last-assistant-message. Hooks read JSON on stdin.
  • Control. notify can only react. Hooks can block a tool call, deny an approval or keep a turn going.
  • Trust. Hooks need review in /hooks. notify has no review step, but it’s ignored in a project’s .codex/config.toml and only works in your user config.

There’s also a built-in option with no script at all. tui.notifications turns on terminal notifications and can be limited to event types such as agent-turn-complete and approval-requested. tui.notification_method picks auto, osc9 or bel, and tui.notification_condition picks unfocused (the default) or always. If your terminal shows OSC 9 notifications, that may be all you need.

When a hook doesn’t run or misbehaves

  1. Nothing runs. Open /hooks. New or edited hooks are skipped until you trust them.
  2. Project hooks don’t load. The project’s .codex/ layer has to be trusted.
  3. Hooks are off. Look for hooks = false under [features] in your config. An admin can also force hooks off, or set allow_managed_hooks_only = true in requirements.toml, which skips user, project and plugin hooks.
  4. “Invalid output” on Stop or Interrupt. Something printed plain text. Redirect the command’s own output to /dev/null and print only JSON.
  5. Interrupt hook times out. It has one second by default and three at most. Run slow work in the background.
  6. A matcher does nothing. Stop, Interrupt and UserPromptSubmit ignore matchers.
  7. A script isn’t found. Hooks run in the session’s cwd, which may be a subdirectory. Use absolute paths, or resolve from git rev-parse --show-toplevel for repo hooks.
  8. Duplicate hooks fire. Check for both hooks.json and inline [hooks] in the same layer.

Something to do while Codex works

The setup above tells you when Codex is done. IdlePal gives you something to do before then. It’s a small Mac app: when Codex starts a turn, Pip, a pet that lives in your MacBook notch, wakes up and a panel with a daily puzzle drops down. When the turn ends, the panel tucks itself away, or tells you if you’re mid-game.

It uses the same Codex hooks (UserPromptSubmit, Stop, Interrupt, SessionEnd and PermissionRequest). A free installer, which goes live with the app, merges them into ~/.codex/hooks.json and keeps your other hooks:

curl -fsSL https://raw.githubusercontent.com/initiums/idlepal-integrations/main/codex/install.sh | sh

Then run /hooks in Codex once to trust them. The hooks only send “started”, “finished” or “needs you”, plus a session id. They never read your prompts or code. The Codex setup guide walks through it, and the Claude Code notifications guide covers the same alerts for Claude Code.

Codex hooks: questions

Are Codex hooks enabled by default?

Yes. To turn them off, set hooks = false under [features] in config.toml. The old codex_hooks key still works as a deprecated alias.

Why aren't my Codex hooks running?

Usually because they aren't trusted yet. Codex skips new or changed hooks until you review and trust them in /hooks, and every edit to a hook's definition means trusting it again.

Where do I put Codex hooks for one project only?

In <repo>/.codex/hooks.json or inline [hooks] tables in <repo>/.codex/config.toml. Those project hooks only load when the project's .codex/ layer is trusted.

Why does my Codex Stop hook show an invalid output error?

Stop expects JSON on stdout when it exits 0, so plain text is invalid. Print an empty object with echo '{}' and send any other output to /dev/null.

What is the difference between Codex hooks and the notify setting?

notify runs one program with a JSON argument when a turn completes. Codex hooks cover many more events, read JSON on stdin, can block or continue actions, and need to be trusted in /hooks.

Get Pip first.

One email when IdlePal is on the Mac App Store. Nothing else.

Buttondown opens in a new tab to confirm. Unsubscribe any time.

macOS 14 or later. Best on a MacBook with a notch.