Get Notified When OpenAI Codex Finishes a Task
Codex CLI has a notify hook built in. Point it at a small script and your phone buzzes the moment a task finishes, with the last thing Codex said.

You hand Codex a real job. Port the test suite, chase down a flaky integration test, refactor a module that four other files import. It goes away and works. You go and do something else, because that is the entire proposition.
The failure mode is the same one every coding agent has: it finishes, and the only thing that changed in your world is a line of text in a terminal you are not looking at. Codex ships with the fix already built in, and almost nobody turns it on.
The notify hook
Codex CLI has a notify setting. Point it at an executable and Codex runs that program when a turn ends, passing a single argument: a JSON string describing what just happened. The interesting field is the last assistant message, which is Codex's own summary of what it did.
That is everything you need. The hook fires at exactly the right moment and hands you exactly the right text. All that is missing is the part that reaches your phone.
Step 1: Get your app key
Install TheNotificationApp on your iPhone, sign in with Apple, and create an application called something like Codex. Copy its app_key. That key is the only credential here, and it routes to your devices, so a notification sent with it lands on your phone and nowhere else.
Step 2: Write the notify script
Save this as ~/.codex/notify.py. Codex passes the JSON as argv[1]:
#!/usr/bin/env python3
import json, os, sys, urllib.request
APP_KEY = os.environ.get("TNA_APP_KEY", "your_app_key_here")
URL = "https://thenotification.app/api/sendNotification"
def main() -> int:
if len(sys.argv) < 2:
return 0
try:
event = json.loads(sys.argv[1])
except json.JSONDecodeError:
return 0
if event.get("type") != "agent-turn-complete":
return 0
summary = (event.get("last-assistant-message") or "Turn complete.").strip()
summary = " ".join(summary.split())[:180]
payload = json.dumps({"title": "Codex finished", "body": summary}).encode()
req = urllib.request.Request(
URL,
data=payload,
headers={"app_key": APP_KEY, "Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req, timeout=10).read()
except Exception as exc:
print(f"[notify] {exc}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())Standard library only, so there is nothing to install and nothing to keep in sync with a virtualenv. That matters here: this script is launched by Codex, not by your shell, so it will not necessarily see the Python environment you think it will.
Make it executable:
chmod +x ~/.codex/notify.pyThen run it by hand once, with a fake event, to prove the key works before you wire anything up:
~/.codex/notify.py '{"type":"agent-turn-complete","last-assistant-message":"test"}'If your phone buzzes, the rest is configuration.
Step 3: Register the hook
In ~/.codex/config.toml, add:
notify = ["/Users/you/.codex/notify.py"]Use the absolute path. A ~ is not expanded here, and a relative path resolves against whatever directory Codex happens to be in, which is the single most common reason this silently does nothing.
Step 4: Give the notification something to say
The default above sends Codex's closing message, which is usually the right thing. Sometimes it is a wall of prose. Two adjustments worth making:
Put the repo in the title, so a notification means something when you have three of these running:
repo = os.path.basename(os.getcwd())
payload = json.dumps({
"title": f"Codex finished in {repo}",
"body": summary,
}).encode()And add a link to jump straight to the diff:
payload = json.dumps({
"title": f"Codex finished in {repo}",
"body": summary,
"link": "https://github.com/you/repo/compare",
}).encode()Tapping the notification now opens the comparison view. The alert stops being a heads-up and becomes the first step of the review. The optional fields are listed in the API reference.
Telling the good runs from the bad ones
Every notification looking identical is its own problem. After a week you stop reading them, because "Codex finished" carries no information about whether you need to care.
The assistant message usually carries the signal, so cheap keyword matching on it goes a long way:
lowered = summary.lower()
if any(word in lowered for word in ("failed", "error", "could not", "unable to")):
title = f"Codex needs you ({repo})"
else:
title = f"Codex finished ({repo})"Crude, and it will occasionally mislabel one. It is still the difference between a lock screen you scan and a lock screen you ignore, and you can read the body to correct it in the half second it takes.
Running several at once
The hook comes into its own when you have three Codex sessions going in three checkouts. Without notifications that is unmanageable, because you are round-robin tabbing between terminals to see which one wants attention.
With the repo name in the title, the phone becomes the queue. You get "Codex finished (billing-service)" and you know exactly which window to go to. Add a working directory to the body if your repos have similar names:
cwd = os.getcwd()
body = f"{summary}\n\n{cwd}"[:180]One thing to know: the hook runs per turn, not per session. A conversation where you send five follow-up messages produces five notifications. That is usually right, since each turn is a point where Codex is waiting on you again, but it is worth knowing before you wonder why your phone buzzed four times.
When it stays silent
Three things account for nearly every case:
- The path is wrong or the file is not executable. Test the script by hand first, as above. That separates "Codex is not calling it" from "the call is failing."
- The event type is not the one you filtered on. Print
sys.argv[1]to a file for one run and read what actually arrives, rather than assuming. - The key is not in the environment. The script inherits Codex's environment, not your interactive shell's. If in doubt, hardcode the key in the file while you debug, then move it back out.
The honest part
The free tier is 100 notifications total, for the lifetime of the account, not per month. One ping per completed turn is a comfortable fit: a heavy day of agent work is maybe fifteen. What burns it is leaving the filter off, so every intermediate event fires, which is why the script checks the event type before doing anything.
Pro is $2.99 a month for 1,000 notifications if you get there. And this is iPhone only, since it rides Apple's push service.
The same idea, other agents
The hook is Codex-specific but the pattern is not: find the point where the agent knows it is done, and put one HTTP call there. Claude Code has the same shape through its Stop hook, written up in Claude Code push notifications.
Grab a key at thenotification.app, wire the hook once, and stop tabbing back to a terminal that finished ten minutes ago.
New to this? Start with what an MCP server is.



