Unwait

How to get notified when Codex finishes

· 5 min read codex notify hooks macos workflow

Codex's notify key in config.toml runs a command when a turn ends, and it is still the simplest way to get a notification. The minimal setup, the JSON it passes as an argument, its sharp edges, and when to use Codex's newer lifecycle hooks instead.

notify is one key in ~/.codex/config.toml that runs a command when a turn completes. It is the smallest way to get "tell me when it is done", and it is what this post covers.

Updated September 13, 2026: when this post first went up, notify was Codex's only automation surface. Codex now also ships full lifecycle hooks, stable and on by default, with events for prompt submission, tool calls, permission requests, and more, delivered as JSON on stdin. If you need more than a finished-turn notification, read Codex hooks and config.toml. Everything below about notify itself is still accurate.

The smallest version that works on macOS:

notify = ["osascript", "-e", 'display notification "Codex finished" with title "Codex"']

Restart Codex, run a prompt, and you get a notification when the turn ends.

One TOML detail will silently eat your config: notify must be a top-level key. config.toml is full of [section] headers, and if you paste notify = [...] below one of them, it becomes [tui].notify or [projects].notify and does nothing. Put it on the first line of the file and you cannot get this wrong.

Why it is an array

Claude Code hooks take a shell command as a string. Codex notify takes an argv array and executes it directly, with no shell. That means:

If you want any shell behavior at all, point notify at a script and put the shell logic in the script:

notify = ["/Users/you/bin/codex-notify.sh"]

Remember chmod +x on the script. A non-executable script fails silently here, because Codex discards the error.

The payload arrives as an argument, not stdin

This is the part that trips up everyone who set up Claude Code hooks first. Claude Code writes its JSON to your hook's stdin. Codex appends the JSON as one extra argument at the end of your argv. In a script that is $1:

{
  "type": "agent-turn-complete",
  "thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
  "turn-id": "12345",
  "cwd": "/Users/you/project",
  "input-messages": ["Rename foo to bar and update the callsites."],
  "last-assistant-message": "Rename complete and verified cargo build succeeds."
}

Two fields are worth using. cwd tells you which project finished, which matters the moment you run more than one agent. last-assistant-message is a ready-made notification body.

Here is a script that uses both. It hands the JSON to Python and lets json.dumps produce the AppleScript string literals, so a quote in the message cannot break out of the osascript expression:

#!/bin/sh
payload="$1"
[ -n "$payload" ] || exit 0
printf '%s' "$payload" | /usr/bin/python3 -c '
import json, os, subprocess, sys
d = json.load(sys.stdin)
project = os.path.basename(d.get("cwd", "")) or "Codex"
msg = (d.get("last-assistant-message") or "Turn finished")[:120]
subprocess.run(["osascript", "-e",
    "display notification " + json.dumps(msg)
    + " with title " + json.dumps(project + " finished")])
' &
exit 0

Prefer a sound? Swap the osascript call for ["afplay", "/System/Library/Sounds/Glass.aiff"].

What notify will not tell you

The differences between notify and hook systems, Claude Code's and now Codex's own, are easy to summarize:

Hooks (Claude Code, and Codex's newer hooks) Codex notify
Config per event, in settings or hooks.json one top-level key in config.toml
Payload JSON on stdin JSON as the last argv argument
Events start, stop, permission prompt, more turn complete, and only that
Execution command with a timeout, output is read direct exec, fire and forget

Two of those rows have real consequences.

There is no start event. With notify you can know when a turn ended but never when it began, so anything that wants to measure the wait, or show state while Codex works, cannot be built on notify alone. We hit this building Unwait: the Claude Code integration shows cards during the wait, while the Codex integration, which runs on notify, can only announce that the wait is over. Codex's hooks now include UserPromptSubmit, which is the missing start signal.

notify does not fire on approval prompts. A turn that is blocked waiting for you to approve a command looks exactly like a turn that is still working. If you walk away trusting the notification, a blocked agent will sit there for as long as you do. Codex's hooks cover this with a PermissionRequest event, and the terminal notifications below cover it too.

Fire and forget also changes debugging. Codex spawns your command and moves on, with stdout and stderr discarded, so an echo in your script goes nowhere. If the hook seems dead, log to a file:

printf '%s\n' "$1" >> /tmp/codex-notify.log

Run a prompt, then check the file. If JSON shows up, Codex is calling you and the bug is in your script. If nothing shows up, the config line is wrong, and the first thing to check is whether notify sits under a [section] header.

The upside of fire and forget: a slow notify script cannot hang your session. Hooks run with a timeout and their output is read, so a slow hook costs you time; notify does not wait for you, so the backgrounding discipline that hooks force on you is optional here. Keep it anyway if the same script serves both.

The zero-setup alternative

Recent Codex builds can post notifications through the terminal itself:

[tui]
notifications = true

You can also filter to specific types:

[tui]
notifications = ["agent-turn-complete", "approval-requested"]

Note approval-requested in that list. The TUI path covers the blocked-agent case that notify cannot see, which makes it the better pick if a notification is all you want.

The catch is that it works through terminal escape codes, so it depends on your terminal. iTerm2, WezTerm, and Ghostty handle it; Apple's Terminal does not. And it can only notify. If you want the event to reach anything outside a notification bubble, a daemon, a log, another machine, you are back to notify.

One key means one owner

Because notify is a single key rather than a list of handlers per event, tools that integrate with Codex compete for it. Unwait sets it, and other tools do the same. Before you overwrite the line, look at what is there: we have seen a tool take over notify and chain the previous script behind its own, which works until either tool tries to clean up after itself.

If you hand-edit and something else later claims the key, your notification quietly stops. When a hook that worked for weeks goes silent, check notify first.

A last note on longevity: when this post was first written, notify was already implemented in the Codex source as a compatibility layer named legacy_notify on top of a newer lifecycle-hooks system, and we suggested checking back for a public config surface. That surface now exists: hooks are stable and on by default, each event takes its own list of handlers, and the one-key competition described above does not apply to them. notify still works; see Codex hooks and config.toml for the hooks side.

Further reading

Codex configuration is documented in the openai/codex repository. For hooks, trust, and config.toml layering, see Codex hooks and config.toml. For the Claude Code side of the same problem, see How to get notified when Claude Code finishes.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts