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.
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.
commandandmcp_toolhandlers run.promptandagenthandlers 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:
- Create
~/.codex/hooks.jsonif it doesn’t exist. - Paste this in:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff >/dev/null 2>&1 & echo '{}'",
"timeout": 10
}
]
}
]
}
}
- Start Codex. If a hook needs review, Codex prints a warning telling you to open
/hooks. - Run
/hooks, review the new hook and trust it. - 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 anycwd: the session’s working directoryhook_event_name: the event that firedmodel: 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”.
- 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
}
]
}
]
}
}
- Save, start Codex and trust all three hooks in
/hooks. - 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.osascriptbanners go through Script Editor. - 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:
StopandInterruptboth print{}, since plain text is invalid for them.Interrupthooks default to a one-second timeout and allow at most three, so the sound runs in the background and the hook returns at once.PermissionRequestprints 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.
notifycurrently fires only foragent-turn-complete. Hooks cover the full event list above. - Input.
notifypasses one JSON argument with fields such astype,thread-id,turn-id,cwd,input-messagesandlast-assistant-message. Hooks read JSON on stdin. - Control.
notifycan only react. Hooks can block a tool call, deny an approval or keep a turn going. - Trust. Hooks need review in
/hooks.notifyhas no review step, but it’s ignored in a project’s.codex/config.tomland 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
- Nothing runs. Open
/hooks. New or edited hooks are skipped until you trust them. - Project hooks don’t load. The project’s
.codex/layer has to be trusted. - Hooks are off. Look for
hooks = falseunder[features]in your config. An admin can also force hooks off, or setallow_managed_hooks_only = trueinrequirements.toml, which skips user, project and plugin hooks. - “Invalid output” on Stop or Interrupt. Something printed plain text. Redirect the command’s own output to
/dev/nulland print only JSON. - Interrupt hook times out. It has one second by default and three at most. Run slow work in the background.
- A matcher does nothing.
Stop,InterruptandUserPromptSubmitignore matchers. - 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-toplevelfor repo hooks. - Duplicate hooks fire. Check for both
hooks.jsonand 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.