Get a notification or voice alert when Codex CLI finishes
By Aditya Vernekar · · 4 min read
Codex CLI has two ways to tell you a turn is done. Its own terminal notifications are on by default and fire when the terminal isn’t focused. For anything custom, set notify in ~/.codex/config.toml: Codex runs that command after every turn and passes a JSON description of the turn as the last argument.
Codex is good at long, quiet stretches of work: a migration, a refactor across a dozen files. That’s exactly when you stop watching it. Here is what Codex gives you out of the box, how notify works, and how to go from a plain “done” to a spoken summary. Field names below come from the Codex source on GitHub, so you can rely on them in scripts.
Built-in: terminal notifications
The Codex TUI sends a notification through your terminal when a turn finishes. Three settings under [tui] in ~/.codex/config.toml control it:
[tui]
notifications = true # default
notification_method = "auto" # "auto", "osc9" or "bel"
notification_condition = "unfocused" # or "always"osc9asks the terminal for a desktop notification, in terminals that support it.belrings the bell, which works almost everywhere.unfocusedonly notifies when you’re looking at another window.alwaysnotifies regardless.
That’s enough for one session. It won’t tell you which project finished or what happened, and it can’t run your own command. For that, there is notify.
How notify works
notify is a top-level key holding the command as a list of arguments, without the payload. After each completed turn, Codex runs the command with one extra argument: a JSON string describing the turn.
notify = ["notify-send", "Codex"]runs, after every turn, something like:
notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345", ...}'Two details that matter: it has to be a top-level key, so put it above any [section] in the file, or TOML will file it under that section. And Codex doesn’t wait for the command or show its output, so a broken script fails silently.
The payload
The only event type today is agent-turn-complete. The full payload looks like this:
{
"type": "agent-turn-complete",
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": "/Users/you/projects/shop",
"client": "codex-tui",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds."
}thread-ididentifies the conversation, so you can tell parallel sessions apart.turn-ididentifies the turn.cwdgives you the project.input-messagesis what you asked;last-assistant-messageis Codex’s final reply.
Note the keys use hyphens, not underscores. In jq that means bracket syntax: .["last-assistant-message"]. Codex’s source code (legacy_notify.rs) describes notify as the legacy integration, kept for backward compatibility. It still works and is still the simplest way to run a command per turn.
A notification with the project and the reply
Save this as ~/.codex/notify.sh and make it executable with chmod +x ~/.codex/notify.sh:
#!/bin/sh
# Codex passes the turn as JSON in the last argument.
payload="$1"
project=$(printf '%s' "$payload" | jq -r '.cwd | split("/") | last')
reply=$(printf '%s' "$payload" | jq -r '.["last-assistant-message"] // "Done" | .[0:180]')
osascript - "$project" "$reply" <<'EOF'
on run argv
display notification (item 2 of argv) with title ("Codex · " & item 1 of argv)
end run
EOFThen point Codex at it, using the full path:
notify = ["/Users/you/.codex/notify.sh"]Passing the text to osascript as arguments, not pasting it into the script, means quotes or backslashes in Codex’s reply can’t break the AppleScript. To hear it instead, replace the osascript block with printf '%s. %s' "$project" "$reply" | say.
The limits you’ll hit
- One slot.
notifyholds a single command. If another tool already uses it, yours replaces it. Chain them in one script instead. - Every turn, even short ones. The payload has no start time or duration, so a script can’t tell a five-second reply from a twenty-minute refactor without keeping its own state.
- Turn end only. notify doesn’t fire when Codex is waiting on an approval. For that, Codex’s newer lifecycle hooks (in
~/.codex/hooks.json, includingPermissionRequestandStop) are the way in. Codex ignores hooks you haven’t trusted, so run/hooksonce to approve them. - Noise from the Codex app. The desktop app runs a hidden turn to title each thread, and notify reports it like any other turn.
Or let Earpiece handle it
Earpiece plugs into the same notify slot and turns each turn into one spoken sentence, like “billing. Migrated the invoices table and updated the tests.” Specifically:
- It keeps your existing notify command. If you already have one,
earpiece install --chainruns Earpiece first and then yours, with a guard so a wrapper that calls Earpiece back can’t loop.earpiece uninstallputs your command back. - Each turn is spoken once, even if Codex delivers it twice, and the thread-title turns are ignored.
- Codex gets its own voice if you also run Claude Code:
earpiece voice <id> --agent codex. - Too chatty? Codex sends no turn length, so every turn is announced by default. Set
agents.codex.minTurnSecondsin~/.earpiece/config.json, or useearpiece quiet. - Approvals on the card. With the optional “Answer from the card” setting, Codex permission requests show up in the Mac app’s notch card with Allow and Deny. It uses Codex’s hooks, so trust them once with
/hooks.
Install the Mac app and click Connect on the Agents page, or run node bin/earpiece.mjs install from a clone of the repo. Running Claude Code too? See running several coding agents in parallel.