Skip to content

agents

Codex CLI notifications: the notify hook and its blind spot

Codex CLI's notify hook only fires on turn completion. The real config, how to make Codex ding when it is waiting on you, and a wrapper for the gap.

Nadia Okonkwoupdated Aug 10, 202610 min read

Codex CLI has a notify setting in config.toml that runs a program of your choice every time the agent finishes a turn, passing it a JSON blob with the result. That covers "Codex is done." It does not cover "Codex is sitting at an approval prompt" — the notify hook only fires on turn completion, not on the interactive y/n that appears when a command needs your approval.

There is a second, separate mechanism that does cover the approval prompt: [tui] notifications, which makes Codex ding through your terminal rather than run a program. It is the right answer for most people and it is covered first, below. The polling wrapper after it is for the case that neither mechanism reaches — a run you have walked away from.

The notify hook

Add a notify array to ~/.codex/config.toml pointing at any executable. Codex CLI invokes it with a single JSON-encoded argument describing the event — OpenAI's configuration reference lists the payload as carrying type, thread-id, turn-id, cwd, input-messages and last-assistant-message:

~/.codex/config.toml
notify = ["/usr/local/bin/codex-notify.sh"]
/usr/local/bin/codex-notify.sh
#!/usr/bin/env bash
# Codex CLI calls this with one argument: a JSON blob for the event.
payload="$1"
message=$(echo "$payload" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("last-assistant-message","turn complete"))')
 
# macOS
osascript -e "display notification \"$message\" with title \"Codex CLI\""
# Linux — comment out the line above and use this instead
# notify-send "Codex CLI" "$message"
chmod +x /usr/local/bin/codex-notify.sh

That gets you a desktop banner the moment a turn finishes — a task completed, a question answered, an error surfaced back to you. For a foreground run where you're watching the terminal anyway, it's redundant. For anything you've alt-tabbed away from, it's the whole value of the feature.

testing the hook
codex "list the files in this repo"
# ... turn completes ...
# desktop banner: "Codex CLI — Listed 14 files across src/ and tests/"

How do you make Codex ding when it's waiting for a prompt response?

Use [tui] notifications, which is a completely different mechanism from notify and the one almost everybody actually wants. Rather than running a program, it emits a notification escape sequence at your terminal — and unlike notify, it accepts the approval-requested event as well as agent-turn-complete.

~/.codex/config.toml
notify = ["/usr/local/bin/codex-notify.sh"]   # root keys go before any table
 
[tui]
notifications = ["agent-turn-complete", "approval-requested"]
notification_method = "auto"
notification_condition = "unfocused"

notification_method takes auto, osc9 or bel. In auto, Codex prefers an OSC 9 escape sequence when the terminal looks capable and falls back to the plain bell (\x07) otherwise — so bel is the setting to force if your terminal rings but never shows a banner. notification_condition takes unfocused or always; always is what you want if the Codex pane is somewhere you are not looking even when the window has focus.

TOML ordering will silently eat your notify line

notify is a root key. TOML assigns every key after a [table] header to that table, so a notify line placed below [tui] becomes tui.notify and is ignored. Put root keys at the top of the file.

Two things break this, and both are worth checking before concluding it does not work. The terminal has to act on OSC 9 — iTerm2 and Ghostty do, Windows Terminal does not. And tmux drops the sequence entirely unless you turn on passthrough, which is the most common reason this "stops working" after someone starts running Codex in a session. Both are covered in terminal notifications.

Where the notify hook goes quiet

Codex CLI's default approval mode asks before running a command or touching a file outside the sandbox. That prompt is a foreground interaction — it blocks on your terminal, waiting for y/n/a — and it is not a turn completion. The notify program hook does not fire for it, and OpenAI's configuration reference is explicit that agent-turn-complete is currently the only event it emits. Kick off a run, walk away, and if Codex hits something it wants permission for, nothing you can script runs.

That matters because the escape-sequence route above only reaches the terminal in front of you. If you want the approval prompt to trigger a command — a sound, a curl to a push service, anything at all — there is no supported way to do it today; there is an open request to expose approval-requested through top-level notify. Claude Code, whose Notification hook fires specifically for permission prompts and runs an arbitrary command, does not have this split — see Claude Code notifications for that side of the comparison, and which agent tells you what for the whole landscape.

SignalFires on completion?Fires on approval prompt?Can run a command?
notify (root key)yesnoyes — that is its whole job
tui.notificationsyesyesno — escape sequence only
terminal bell fallbackyes, via notification_methodyesno
polling wrapper (below)yes, via notifyyesyes

A wrapper that covers the approval gap

Since no Codex event can run a command when it is waiting on you, the only option outside the tool itself is to watch the pane's actual output for the prompt text and act when it appears. This is the same technique worth keeping in your pocket for any CLI that doesn't expose a hook at all — poll, match, dedupe, notify.

The script below plays a sound as well as posting a banner, which is the difference between a signal you notice and one you find later. A banner on a screen you are not looking at is a log entry; a distinct tone reaches you across the room.

~/bin/codex-watch
#!/usr/bin/env bash
# Run this in a second pane pointed at the same tmux session as codex.
# It polls the pane's visible output for an approval prompt, then dings once.
target="${1:-codex:1.0}"
seen=""
 
ding() {
  # macOS
  afplay /System/Library/Sounds/Submarine.aiff 2>/dev/null &
  osascript -e 'display notification "Codex is waiting for approval" with title "Codex CLI"'
  # Linux — comment out the two lines above and use these instead
  # paplay /usr/share/sounds/freedesktop/stereo/dialog-warning.oga 2>/dev/null &
  # notify-send "Codex CLI" "Codex is waiting for approval"
}
 
while true; do
  snapshot=$(tmux capture-pane -p -t "$target" | tail -5)
  if echo "$snapshot" | grep -qE '\[y/n/a\]|approve\?' && [ "$snapshot" != "$seen" ]; then
    ding
    seen="$snapshot"
  fi
  sleep 5
done

The seen check is the part people skip and regret — without it, a prompt sitting on screen for three minutes fires a notification every five seconds. Dedupe on the last-seen snapshot, or on a timestamp file with a cooldown, and it fires once per event instead of once per poll.

capture-pane -p prints a pane's visible contents to stdout, and the session:window.pane target syntax in $target is the same one every other tmux command takes — both are in the tmux command cheat sheet if the addressing is unfamiliar.

Use a different sound here from whatever your completion notification plays. "Done" and "blocked" mean opposite things about how fast you need to move, and one tone for both throws that away — the same argument, with the exact per-platform commands, is in making Claude Code play a sound when it's done.

This is a workaround, not a fix

A polling loop is grep against a moving target — it breaks the moment the prompt's wording changes in a Codex CLI release. It's the right amount of engineering for a personal setup; it is not something you want three people on a team independently maintaining.

Where this stops being worth maintaining

The polling script works for one tool, on one machine, until Codex CLI's UI text changes. If you're only running Codex, that's a fair trade for a few minutes of setup. If you're also running Claude Code, aider, or Gemini CLI, you're now maintaining a different regex per tool, on top of a different native-hook config per tool where one exists. At that point it's worth asking what the notification was actually for: nine times out of ten it is "should I go look," and the cheaper fix is making looking free. mtmux serves the tmux session Codex is already running in straight to a browser on any device — the case for that with agents, and the quickstart if you want to try it — so a check costs a tab instead of a laptop. Whether that beats maintaining a regex depends on how many tools and machines you're actually watching — covered in more depth in stop babysitting coding agents.

Why the split exists at all

It's tempting to file the missing approval event on notify as an oversight, but the shape of the problem explains it. An approval prompt isn't an event Codex CLI emits and moves past — it's a blocking read on stdin, and the process is, from the outside, indistinguishable from one that's still thinking. Anything that reacts to it has to be wired into the prompt's own code path, which is exactly what tui.notifications is: the TUI already knows it is about to block, so emitting a sequence there is cheap. Handing the same moment to an external program is a bigger commitment — it has to survive the process being suspended, the user answering before the program returns, and every other place in the codebase that can block on you remembering to fire it.

Putting all three together

None of these are mutually exclusive, and a good setup uses each for what it is:

tui.notifications for the blocked state

["agent-turn-complete", "approval-requested"] with notification_condition = "always". Zero scripting, and it is the only native signal for an approval prompt.

notify for the completed state

One line pointing at a script, which gets the last assistant message and can therefore say what finished rather than merely that something did.

the polling wrapper only if you need a command on approval

Scoped to whatever prompt text your Codex CLI version actually uses, and only worth it if you need the blocked state to trigger a push or a sound rather than a terminal banner.

Test each with a throwaway session before trusting any of them on a run you plan to walk away from.

How do I make Codex CLI ding when it needs a prompt response?

Set notifications = ["agent-turn-complete", "approval-requested"] under [tui] in ~/.codex/config.toml, with notification_condition = "always" so it fires even when the window has focus. That is the only native signal for an approval prompt. If it rings but never shows a banner, your terminal isn't acting on OSC 9 — and if it works outside tmux but not inside, you need tmux's passthrough option turned on.

Does Codex CLI have a native notification for approval prompts?

Yes, but only through tui.notifications, which emits a terminal escape sequence. The notify program hook fires on turn completion only, so there is no supported way to run a command when Codex is waiting on an approval — that's the gap this post's wrapper script covers.

What does Codex CLI pass to the notify program?

A single JSON-encoded argument describing the event, including fields like the last assistant message. Parse it with python3 -c 'import json,sys; ...' or jq inside your notify script.

Can I use notify-send instead of osascript?

Yes — notify-send "title" "message" is the Linux equivalent, and it's what the sample script above uses when you swap the commented line in.

Why does my polling script fire the same notification repeatedly?

You're not deduping. Compare the current pane snapshot (or a hash of it) against the last one you already notified for, and skip if unchanged — otherwise a five-second poll fires a notification every five seconds for as long as the prompt sits on screen.

Is there a way to get Codex CLI notifications on my phone?

Not natively — the notify hook only runs a local program, so reaching a phone means having that program call out to a push service such as ntfy or Pushover. Getting the session onto the phone is a separate problem: that's what mtmux does, serving the live pane to a browser with no SSH client on the device.

Put this to work — mtmux attaches to the tmux server you already run.

Related reading