Summary of Agent Native System Notifications Implementation (Claude, Codex, etc.)
When using Codex, whenever a complete conversation turn ends or when execution permission is requested, macOS's Notification Center immediately pushes a notification to me.
At first, I didn't delve deeply into how this feature was implemented, until I used Codex through tmux and discovered that notifications had completely disappeared. This sparked my curiosity. Why is this happening?
How Codex Implements Native System Notifications
Let's start by understanding Codex's native system notification implementation mechanism.
Codex triggers system notifications through OSC 9 (Operating System Command sequences).
This feature initially appears to be a iTerm2-specific implementation, but I can also use it normally in Ghostty terminal. If you use other terminal emulators, you can test whether system notifications can be properly triggered with the following command:
printf '\033]9;%s\a' "This is the notification text"
Where \033] is the OSC (Operating System Command sequence) in ANSI Escape Sequences.
\a corresponds to ASCII BEL (bell, 0x07), which is often used as a terminator in OSC.
Why Does It Fail in tmux
This is because tmux's OSC parser only "recognizes/processes" a few OSC numbers, and treats all others as "unknown" and discards them (only logging debug messages), without forwarding them to the outer terminal.
Therefore, notifications cannot be delivered. However, tmux actually provides a solution. Let me explain in detail.
Compromise Solutions
Solution 1: DCS Passthrough
tmux provides a DCS passthrough feature. You just need to wrap the escape sequences to be passed through in the following format:
printf '\033Ptmux;\033%s\033\\' "$(printf '\033]9;%s\a' "This is the notification text")"
Where \033P is the DCS (Device Control String) in ANSI escape sequences, which uses \033\\ as a terminator.
Solution 2: Write Directly to Client TTY
By obtaining the current tmux client's TTY address, write escape sequences directly to it:
tty="$(tmux display-message -p '#{client_tty}' 2>/dev/null || true)"
printf '%b' "$(printf '\033]9;%s\a' "This is the notification text")" >"$tty"
If you need to define a notification title, you can use the OSC 777 sequence:
printf '\033]777;notify;%s;%s\a' "This is the title" "This is the text"
Now we just need to figure out how to make Codex call these methods.
TL;DR
Codex Native System Notification Configuration
Add a notify.sh script that is responsible for receiving calls from Codex and parsing its arguments.
#!/usr/bin/env bash
set -eo pipefail
_write() {
local msg="$1"
if [ -n "$TMUX" ]; then
# tmux inside:write into tty directly
local tty
tty="$(tmux display-message -p '#{client_tty}' 2>/dev/null || true)"
if [[ -n "$tty" && -w "$tty" ]]; then
printf '%b' "$msg" >"$tty"
else
# fallback: DCS passthrough + double ESC
printf '\033Ptmux;\033%s\033\\' "$msg"
fi
else
# tmux outside:direct OSC 9
printf '%b' "$msg"
fi
}
# OSC 9: notify with a single message string.
notify_osc9() {
local msg="$1"
_write $'\033]9;'"${msg}"$'\a'
}
# OSC 777 (Blink-style): notify;Title;Body
# Note: Title/Body must not contain unescaped ';' ideally.
notify_osc777() {
local title="$1"
local body="$2"
# Basic sanitation: replace newlines with spaces; replace ';' to avoid field split.
title="${title//$'\n'/ }"
body="${body//$'\n'/ }"
title="${title//;/:}"
body="${body//;/:}"
_write $'\033]777;notify;'"${title}"$';'"${body}"$'\a'
}
main() {
[[ $# -ge 1 ]] || exit 0
local json="$1"
# Fast type check
local type
type="$(jq -r '.type // empty' <<<"$json" 2>/dev/null || true)"
[[ "$type" == "agent-turn-complete" ]] || exit 0
local last_msg input_messages thread_id title message
last_msg="$(jq -r '.\"last-assistant-message\" // "Turn Complete!"' <<<"$json")"
input_messages="$(jq -r '.\"input-messages\" // [] | join(" ")' <<<"$json")"
thread_id="$(jq -r '.\"thread-id\" // ""' <<<"$json")"
title="Codex: ${last_msg}"
message="${input_messages}"
# Prefer OSC 777 (Title/Body)
notify_osc777 "$title" "$message"
# notify_osc9 "${title}\n${message}"
}
main "$@"
Then add the following configuration to Codex's configuration file (usually ~/.codex/config.yaml):
notifications:
enabled: true
command: /path/to/your/notify.sh
This way, when Codex completes a conversation turn, it will call your script to send system notifications.
Claude Native System Notification Configuration
Replace the main function in the notify.sh script above with the following code, and create ~/.claude/scripts/notify.sh.
main() {
local json
json="$(cat)"
# Fast type check
local type
type="$(jq -r '.hook_event_name // empty' <<<"$json" 2>/dev/null || true)"
case "$type" in
"Notification")
local notification_type title message
notification_type="$(jq -r '.\"notification_type\" // "Unknown"' <<<"$json")"
title="Claude: ${notification_type}"
message="$(jq -r '.message // empty' <<<"$json")"
;;
"Stop")
title="Claude: ${type}"
message="All done!"
;;
*)
exit 0
;;
esac
# Prefer OSC 777 (Title/Body)
notify_osc777 "$title" "$message"
# notify_osc9 "${title}\n${message}"
}
Then add the following configuration snippet to Claude's configuration file (usually ~/.claude/settings.json):
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/notify.sh"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/scripts/notify.sh"
}
]
}
]
}
}
This article is based on personal practical experience and was polished and optimized using Avante + DeepSeek V3.2.
This work by 林玮 (Jade Lin) is licensed under
CC BY-NC-ND 4.0