Desktop notifications for Codex CLI and Claude Code

March 10, 2026

Context

This setup was tested on my own machine with:

  • Codex CLI 0.113.0
  • Claude Code 2.1.72
  • macOS 26.3.1 (25D2128)
  • arm64 Apple Silicon
A macOS notification from Codex CLI with the subtitle Notification setup

Start with Claude Code’s official setup

Claude Code already documents the two parts you need:

That is the right place to start. On macOS, the most obvious first implementation is also the simplest one: a tiny osascript wrapper.

File: $HOME/.claude/notify-osascript.sh

#!/bin/bash
set -euo pipefail

MESSAGE="${1:-Claude Code needs your attention}"
osascript -e "display notification \"$MESSAGE\" with title \"Claude Code\"" >/dev/null 2>&1 || true

And wire it into Claude’s hooks:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/notify-osascript.sh 'Task completed'"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/notify-osascript.sh 'Claude Code needs your attention'"
          }
        ]
      }
    ]
  }
}

This worked, but only technically.

  • Clicking the notification did not cleanly bring me back to the terminal app.
  • There was no grouping, so notifications piled up.
  • Once terminal-native notifications entered the picture, especially in Ghostty, duplicate alerts got annoying.

That was the point where terminal-notifier became the better base layer.

Why I switched to terminal-notifier

The official repo is here:

Install it with Homebrew:

brew install terminal-notifier

Then verify it:

which terminal-notifier
terminal-notifier -help | head

The three features that made it worth switching:

  • -activate, so clicking the notification can bring my terminal app to the front
  • -group, so I can keep one live notification per project instead of stacking old ones
  • better control over subtitle, sound, and macOS notification-center behavior

A shared notification helper

Before touching either tool, create one shared helper:

mkdir -p "$HOME/.local/bin"

File: $HOME/.local/bin/mac-notify.sh

#!/bin/bash
set -euo pipefail

TITLE="${1:?title is required}"
MESSAGE="${2:-}"
SUBTITLE="${3:-}"
GROUP="${4:-}"
SOUND="${5:-Submarine}"

case "${TERM_PROGRAM:-}" in
  ghostty) BUNDLE_ID="com.mitchellh.ghostty" ;;
  iTerm.app) BUNDLE_ID="com.googlecode.iterm2" ;;
  Apple_Terminal) BUNDLE_ID="com.apple.Terminal" ;;
  vscode) BUNDLE_ID="com.microsoft.VSCode" ;;
  cursor) BUNDLE_ID="com.todesktop.230313mzl4w4u92" ;;
  zed) BUNDLE_ID="dev.zed.Zed" ;;
  *) BUNDLE_ID="" ;;
esac

TERMINAL_NOTIFIER=""
if [ -x /opt/homebrew/bin/terminal-notifier ]; then
  TERMINAL_NOTIFIER="/opt/homebrew/bin/terminal-notifier"
elif command -v terminal-notifier >/dev/null 2>&1; then
  TERMINAL_NOTIFIER="$(command -v terminal-notifier)"
fi

if [ -n "$TERMINAL_NOTIFIER" ]; then
  ARGS=(
    -title "$TITLE"
    -message "$MESSAGE"
    -sound "$SOUND"
  )

  if [ -n "$SUBTITLE" ]; then
    ARGS+=(-subtitle "$SUBTITLE")
  fi

  if [ -n "$GROUP" ]; then
    ARGS+=(-group "$GROUP")
  fi

  if [ -n "$BUNDLE_ID" ]; then
    ARGS+=(-activate "$BUNDLE_ID")
  fi

  "$TERMINAL_NOTIFIER" "${ARGS[@]}"
  exit 0
fi

SAFE_MESSAGE="${MESSAGE//\\/\\\\}"
SAFE_MESSAGE="${SAFE_MESSAGE//\"/\\\"}"
SAFE_SUBTITLE="${SUBTITLE//\\/\\\\}"
SAFE_SUBTITLE="${SAFE_SUBTITLE//\"/\\\"}"

osascript -e "display notification \"$SAFE_MESSAGE\" with title \"$TITLE\" subtitle \"$SAFE_SUBTITLE\" sound name \"$SOUND\"" >/dev/null 2>&1 || true

Make it executable:

chmod +x "$HOME/.local/bin/mac-notify.sh"

I scope notification groups by tool and project, not by message. That gives me one live Claude Code notification and one live Codex CLI notification per repo instead of a growing stack.

How click-to-focus works

The key line is:

-activate "$BUNDLE_ID"

terminal-notifier accepts a macOS bundle id and activates that app when the notification is clicked.

I map the common values from TERM_PROGRAM:

  • com.mitchellh.ghostty
  • com.googlecode.iterm2
  • com.apple.Terminal
  • com.microsoft.VSCode
  • com.todesktop.230313mzl4w4u92 for Cursor
  • dev.zed.Zed

This does not target one exact split or tab. It just brings the app to the front, which is good enough for this workflow.


Claude Code: attention notifications and completion notifications

I split notifications into two categories:

  • Notification: Claude needs me to do something, like approve a permission request or answer a prompt

  • Stop: the main agent finished responding

  • Notification for permission prompts or other attention-needed states

  • Stop for completion

Claude notification script

File: $HOME/.claude/notify.sh

#!/bin/bash
set -euo pipefail

MESSAGE="${1:-Claude Code needs your attention}"
PROJECT_DIR="${PWD:-$HOME}"
PROJECT_NAME="$(basename "$PROJECT_DIR")"
[ "$PROJECT_NAME" = "/" ] && PROJECT_NAME="Home"
PROJECT_HASH="$(printf '%s' "$PROJECT_DIR" | shasum -a 1 | awk '{print $1}' | cut -c1-12)"
GROUP="claude-code:${PROJECT_HASH}"

"$HOME/.local/bin/mac-notify.sh" "Claude Code" "$MESSAGE" "$PROJECT_NAME" "$GROUP"
chmod +x "$HOME/.claude/notify.sh"

Claude hooks configuration

File: $HOME/.claude/settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/notify.sh 'Task completed'"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/notify.sh 'Permission needed'"
          }
        ]
      },
      {
        "matcher": "idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/notify.sh 'Waiting for your input'"
          }
        ]
      }
    ]
  }
}

If you do not care about different notification types, an empty matcher "" is enough.

One detail worth remembering: Claude snapshots hooks at startup. If changes do not seem to apply, restart the session. Also check macOS notification permissions if nothing shows up.


Codex CLI: completion notifications

For Codex CLI, the mechanism is not hooks. It is notify.

Official docs:

As of 2026-03-10, Codex documents external notify for supported events like agent-turn-complete.

So in practice:

  • completion notifications: yes
  • Claude-style permission notifications through the same external script: no

Approval reminders in Codex are a separate tui.notifications problem.

Codex notify script

File: $HOME/.codex/notify.sh

#!/bin/bash
set -euo pipefail

PAYLOAD="${1:-}"
[ -n "$PAYLOAD" ] || exit 0

python3 - "$PAYLOAD" <<'PY'
import json
import pathlib
import sqlite3
import subprocess
import sys
import zlib
from datetime import datetime, timezone

CODEX_HOME = pathlib.Path.home() / '.codex'


def log_skip(reason: str, payload: dict, **extra: object) -> None:
    log_path = CODEX_HOME / 'notify-filter.log'
    data = {
        'ts': datetime.now(timezone.utc).isoformat(),
        'reason': reason,
        'client': payload.get('client'),
        'thread-id': payload.get('thread-id'),
        'cwd': payload.get('cwd'),
    }
    data.update(extra)
    with log_path.open('a', encoding='utf-8') as fh:
        fh.write(json.dumps(data, ensure_ascii=True) + '\n')


def get_thread_originator(thread_id: str) -> tuple[str, str]:
    db_path = CODEX_HOME / 'state_5.sqlite'
    if not db_path.exists():
        return '', ''

    try:
        with sqlite3.connect(db_path) as conn:
            cur = conn.cursor()
            cur.execute('select rollout_path, source from threads where id = ?', (thread_id,))
            row = cur.fetchone()
    except Exception:
        return '', ''

    if not row:
        return '', ''

    rollout_path, source = row
    if not rollout_path:
        return '', source or ''

    try:
        first_line = pathlib.Path(rollout_path).read_text(encoding='utf-8', errors='ignore').splitlines()[0]
        payload = json.loads(first_line).get('payload', {})
    except Exception:
        return '', source or ''

    return (payload.get('originator') or '').strip(), source or ''


try:
    payload = json.loads(sys.argv[1])
except Exception:
    raise SystemExit(0)

if payload.get('type') != 'agent-turn-complete':
    raise SystemExit(0)

client = (payload.get('client') or '').strip().lower()
if client and ('app' in client or client == 'appserver'):
    log_skip('skip-app-client', payload)
    raise SystemExit(0)

thread_id = (payload.get('thread-id') or '').strip()
if thread_id:
    originator, source = get_thread_originator(thread_id)
    if originator == 'Codex Desktop':
        log_skip('skip-desktop-originator', payload, originator=originator, source=source)
        raise SystemExit(0)

cwd = payload.get('cwd') or ''
subtitle = pathlib.Path(cwd).name if cwd else 'Task completed'
message = (payload.get('last-assistant-message') or 'Task completed').replace('\n', ' ').strip()
if not message:
    message = 'Task completed'

if cwd:
    group = 'codex-cli:' + format(zlib.crc32(cwd.encode('utf-8')) & 0xFFFFFFFF, '08x')
else:
    group = 'codex-cli:' + (payload.get('thread-id') or 'default')

subprocess.run(
    [
        str(pathlib.Path.home() / '.local' / 'bin' / 'mac-notify.sh'),
        'Codex CLI',
        message[:180],
        subtitle,
        group,
    ],
    check=False,
)
PY
chmod +x "$HOME/.codex/notify.sh"

Codex config

File: $HOME/.codex/config.toml

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

Use any absolute path you want. I keep the script under ~/.codex/.


If you use Ghostty, disable terminal-native desktop notifications

I hit one more annoying edge case in Ghostty: duplicate notifications.

What happened was:

  • my script sent a notification through terminal-notifier
  • Ghostty also surfaced a terminal-native desktop notification

That produced two macOS notifications for one event.

On my machine, the clean fix was to keep terminal-notifier as the only notification channel and disable Ghostty’s terminal-native desktop notifications:

File: ~/Library/Application Support/com.mitchellh.ghostty/config

desktop-notifications = false

Why I prefer this setup:

  • terminal-notifier gives me -activate, so click-to-focus still works
  • terminal-notifier gives me -group, so notifications stay scoped per project
  • both Claude Code and Codex CLI behave the same way

Ghostty’s config docs describe desktop-notifications as the switch that lets terminal apps show desktop notifications via escape sequences such as OSC 9 and OSC 777. Turning it off avoids the extra notification layer.


If you also use Codex App

This is the part that bit me.

At first I assumed filtering by the client field would be enough. It was not.

On my machine, some sessions started from Codex App looked like this in local session metadata:

{
  "originator": "Codex Desktop",
  "source": "vscode"
}

That creates a duplicate-notification problem:

  • Codex App shows its own notification
  • the local CLI notify script can still fire
  • I get duplicate notifications for the same task

So the script does two things:

  1. fast path: skip obvious app-like client values
  2. fallback: read thread-id from the notify payload, query ~/.codex/state_5.sqlite, load the first session_meta line, and skip if originator == "Codex Desktop"

That is why the script above checks local thread metadata instead of trusting only client.

I also log skipped events to:

~/.codex/notify-filter.log

That makes debugging much easier if Codex changes its session metadata format later.

This part is based on observed local behavior, not on a stable public contract from the docs. If OpenAI changes how Codex App identifies local sessions in future versions, the filter may need a small update.


References

Posts