Wiring Claude Code into macOS Notifications + Bark Push

#工具 #Claude Code #macOS 1,554 words 4 min read

Origin story: spotted a Windows gist that wires Claude Code into system notifications , written in PowerShell, useless on a Mac. Rewrote it for macOS, then added a Bark channel — when a long job finishes and you’ve wandered off, the phone in your pocket should be the one that pings.

1. The shape of it

Claude Code’s hook system exposes a set of lifecycle events. For “notify me”, you only need two:

  • Stop — the model finished its turn. Time to ring the bell.
  • Notification — the model wants confirmation or input (permission prompts, questions). More urgent.

The hook command receives JSON on stdin: cwd (project path), transcript_path (full JSONL conversation), and Notification events also carry a message.

Design choices:

  • Single Python file — macOS ships python3 by default, so no jq / curl JSON-escaping ceremony.
  • Local toast prefers terminal-notifier — use it if installed (clickable, supports group dedup); fall back to osascript otherwise. Zero install required.
  • Bark is opt-in — only fires when BARK_KEY is set. Failures are silent; the hook’s exit code stays clean.
  • No chmod — see the “gotchas” section below. The script isn’t executable; we invoke it as python3 path/to/notify.py.

2. The script: ~/.claude/hooks/notify.py

#!/usr/bin/env python3
"""Claude Code notification hook for macOS.

Local toast (osascript or terminal-notifier) + optional Bark push.

Usage: notify.py <Stop|Notification|...>

Reads JSON from stdin (Claude Code hook payload):
  cwd               -> current working directory
  transcript_path   -> path to JSONL transcript (Stop event)
  message           -> notification message (Notification event)

Environment variables (all optional):
  BARK_KEY            Bark device key. If unset, Bark push is skipped.
  BARK_SERVER         Bark server (default: https://api.day.app).
  BARK_GROUP          Notification group (default: ClaudeCode).
  BARK_SOUND          Notification sound (default: minuet).
  BARK_ICON           Custom icon URL.
  BARK_STOP_LEVEL     Level for Stop events (default: passive).
  BARK_NOTIFY_LEVEL   Level for Notification events (default: timeSensitive).
                      Valid: active | timeSensitive | passive | critical.
  CLAUDE_NOTIFY_OFF   If set to "1", suppress all notifications.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path

TITLE_PREFIX = "ClaudeCode"
MAX_BODY_LEN = 200


def read_stdin_json() -> dict:
    try:
        raw = sys.stdin.read()
    except Exception:
        return {}
    if not raw.strip():
        return {}
    try:
        return json.loads(raw)
    except Exception:
        return {}


def last_assistant_text(transcript_path: str) -> str:
    p = Path(transcript_path)
    if not p.is_file():
        return ""
    try:
        with p.open("r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()[-30:]
    except Exception:
        return ""
    for line in reversed(lines):
        try:
            entry = json.loads(line)
        except Exception:
            continue
        msg = entry.get("message") or {}
        if msg.get("role") != "assistant":
            continue
        content = msg.get("content")
        text = ""
        if isinstance(content, str):
            text = content
        elif isinstance(content, list):
            for block in content:
                if isinstance(block, dict) and block.get("type") == "text":
                    t = block.get("text", "")
                    if t.strip():
                        text = t
                        break
        if text.strip():
            return text
    return ""


def truncate(s: str, n: int) -> str:
    s = (s or "").strip()
    if len(s) <= n:
        return s
    return s[: n - 1] + "…"


def send_mac_notification(title: str, body: str) -> None:
    tn = shutil.which("terminal-notifier")
    if tn:
        try:
            subprocess.run(
                [tn, "-title", title, "-message", body,
                 "-sound", "Glass", "-group", "claude-code", "-ignoreDnD"],
                check=False, timeout=5,
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
            return
        except Exception:
            pass

    def esc(s: str) -> str:
        return s.replace("\\", "\\\\").replace('"', '\\"')

    script = (
        f'display notification "{esc(body)}" '
        f'with title "{esc(title)}" sound name "Glass"'
    )
    try:
        subprocess.run(
            ["osascript", "-e", script],
            check=False, timeout=5,
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except Exception:
        pass


def send_bark_push(title: str, body: str, event: str) -> None:
    key = os.environ.get("BARK_KEY", "").strip()
    if not key:
        return

    server = os.environ.get("BARK_SERVER", "https://api.day.app").rstrip("/")

    if event == "Notification":
        level = os.environ.get("BARK_NOTIFY_LEVEL", "timeSensitive")
    elif event == "Stop":
        level = os.environ.get("BARK_STOP_LEVEL", "passive")
    else:
        level = "active"

    payload = {
        "title": title,
        "body": body or " ",
        "group": os.environ.get("BARK_GROUP", "ClaudeCode"),
        "level": level,
        "sound": os.environ.get("BARK_SOUND", "minuet"),
    }
    if icon := os.environ.get("BARK_ICON", "").strip():
        payload["icon"] = icon

    try:
        req = urllib.request.Request(
            f"{server}/{key}",
            data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
            method="POST",
            headers={"Content-Type": "application/json; charset=utf-8"},
        )
        with urllib.request.urlopen(req, timeout=5) as r:
            r.read()
    except Exception:
        pass


def main() -> int:
    if os.environ.get("CLAUDE_NOTIFY_OFF") == "1":
        return 0

    event = sys.argv[1] if len(sys.argv) > 1 else "Stop"
    data = read_stdin_json()

    cwd = data.get("cwd") or ""
    project = os.path.basename(cwd) if cwd else ""

    if event == "Stop":
        title = f"{TITLE_PREFIX} - {project}" if project else TITLE_PREFIX
        body = ""
        transcript = data.get("transcript_path") or ""
        if transcript:
            body = last_assistant_text(transcript)
        if not body.strip():
            body = "Task completed, please review results."
    elif event == "Notification":
        title = f"{TITLE_PREFIX} - Needs Attention"
        if project:
            title = f"{title} - {project}"
        body = (data.get("message") or "").strip() \
            or "Claude is waiting for your input or approval."
    else:
        title = TITLE_PREFIX
        body = f"Event received: {event}"

    body = truncate(body, MAX_BODY_LEN)

    send_mac_notification(title, body)
    send_bark_push(title, body, event)
    return 0


if __name__ == "__main__":
    sys.exit(main())

A few details worth flagging:

  • Walking the transcript backwards — read the last 30 JSONL lines, iterate in reverse, take the first entry where role == "assistant" with non-empty text. The content field can be either a string or an array depending on SDK version; both shapes are handled.
  • Truncation uses — a single ellipsis character, not three ASCII dots. Saves two bytes for actual prose.
  • Bark level differs by eventStop defaults to passive (silent, just lands in the push list), Notification defaults to timeSensitive (pierces Focus mode). Long jobs finishing shouldn’t shriek; mid-task confirmations must.
  • Silent failure — Bark unreachable, osascript erroring, transcript missing — all caught and swallowed. A hook should never wedge the model.

3. Wiring it into ~/.claude/settings.json

Merge this block into settings.json, preserving your existing env / permissions / enabledPlugins etc.:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /Users/you/.claude/hooks/notify.py Notification",
            "timeout": 15
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /Users/you/.claude/hooks/notify.py Stop",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

matcher: "" is “match anything” — Stop and Notification don’t have sub-matchers anyway. The timeout: 15 seconds is a backstop; timing out doesn’t break the main flow.

Path must be absolute

The hook command’s working directory is unpredictable — it depends on which project Claude is sitting in at the time. ~/.claude/hooks/notify.py won’t tilde-expand here. Spell it out: /Users/<you>/.claude/hooks/notify.py.

4. Enabling Bark

Bark is a long-running iOS push tool. The free key comes from day.app . Install the app and the home screen shows a URL like https://api.day.app/abc123xyz/ — the middle slug is your device key.

Drop it into the env block of settings.json (hooks inherit env from there):

"env": {
  "BARK_KEY": "abc123xyz"
}

Quick reference for the optional knobs:

VariableDefaultPurpose
BARK_SERVERhttps://api.day.appOverride for self-hosted Bark
BARK_GROUPClaudeCodeNotification group (folds on phone)
BARK_SOUNDminuetSound name
BARK_STOP_LEVELpassiveLevel for “task finished” pushes
BARK_NOTIFY_LEVELtimeSensitiveLevel for “needs your input” pushes
CLAUDE_NOTIFY_OFF(unset)Set to 1 to mute everything temporarily

5. Verification

With BARK_KEY set, fire a manual Notification simulation:

BARK_KEY=your_key echo '{"cwd":"/tmp","message":"test"}' \
  | python3 ~/.claude/hooks/notify.py Notification

Expected:

  1. A macOS toast pops in the top-right corner.
  2. A Bark notification arrives on your phone, grouped under ClaudeCode, level timeSensitive.

To preview local-only behavior, just unset BARK_KEY for the call.

6. Gotchas

macOS notification permissions

osascript display notification posts under the identity of the terminal app that spawned it (Terminal / iTerm / Ghostty / Warp). If nothing appears at all: System Settings → Notifications → find the terminal in the list → allow notifications.

Hook not firing? The Claude Code settings watcher only watches directories that already had a settings file at session start. If .claude/ was created mid-session, edits may not reload. Open the /hooks menu once (forces a reload), or restart the Claude Code session.

chmod denied in your permissions? My settings include Bash(chmod:*) in permissions.deny, so the script can’t be chmod +x’d. Fix: invoke via python3 path/to/notify.py so the executable bit is irrelevant. Bonus — it’s more explicit and removes a hidden piece of state.

Body too long? The script caps at MAX_BODY_LEN = 200 (199 chars + ). macOS Notification Center will truncate too, but capping early avoids ugly mid-JSON breakage on the Bark side.

Want to dial it back? Stop fires every turn — easy to find noisy. Two ways out:

  • The Bark side already uses BARK_STOP_LEVEL = passive — silent push, lands in the list only.
  • For local silence on Stop too: add if event == "Stop": return to the top of send_mac_notification. Five-line change.

7. Where to take it next

Saving these for future me:

  • Telegram / Lark / Slack — rename send_bark_push to send_remote_push, dispatch on a REMOTE_KIND env var. Bark is just one of many.
  • Sharper Notification copy — currently I just pass through whatever message the model sent. Could branch on tool_name (if hook payload starts including it) for sharper titles.
  • Debounce a burst — multiple Stop events within 5s collapsed into one. Right now every turn rings.
  • Stats — append a line to ~/.claude/hooks/notify.log per fire. A week’s worth of “how many turns ran, how often the model got blocked on input” tells you something interesting.

You set this up once and forget about it. When the model finishes a turn or stalls waiting for input, a soft chime in the top-right and a buzz in your pocket — long jobs can finally run while you make a coffee.