Wiring Claude Code into macOS Notifications + Bark Push
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
python3by default, so no jq / curl JSON-escaping ceremony. - Local toast prefers
terminal-notifier— use it if installed (clickable, supports group dedup); fall back toosascriptotherwise. Zero install required. - Bark is opt-in — only fires when
BARK_KEYis 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. Thecontentfield 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
leveldiffers by event —Stopdefaults topassive(silent, just lands in the push list),Notificationdefaults totimeSensitive(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:
| Variable | Default | Purpose |
|---|---|---|
BARK_SERVER | https://api.day.app | Override for self-hosted Bark |
BARK_GROUP | ClaudeCode | Notification group (folds on phone) |
BARK_SOUND | minuet | Sound name |
BARK_STOP_LEVEL | passive | Level for “task finished” pushes |
BARK_NOTIFY_LEVEL | timeSensitive | Level 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:
- A macOS toast pops in the top-right corner.
- A Bark notification arrives on your phone, grouped under
ClaudeCode, leveltimeSensitive.
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": returnto the top ofsend_mac_notification. Five-line change.
7. Where to take it next
Saving these for future me:
- Telegram / Lark / Slack — rename
send_bark_pushtosend_remote_push, dispatch on aREMOTE_KINDenv var. Bark is just one of many. - Sharper Notification copy — currently I just pass through whatever
messagethe model sent. Could branch ontool_name(if hook payload starts including it) for sharper titles. - Debounce a burst — multiple
Stopevents within 5s collapsed into one. Right now every turn rings. - Stats — append a line to
~/.claude/hooks/notify.logper 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.