Chapter 26 | Stop hook: Notification Integration
Chapter 26: Stop Hook — Notification Integration
Learning Objectives
Have main Claude proactively notify you (Telegram / Slack / Email) after completing an output segment — so you can step away from your computer without losing progress.
Stop Hook Trigger Timing
sequenceDiagram
participant U as User
participant CC as Claude Code
participant MC as main Claude
participant Hook as Stop hook
participant TG as Telegram
U->>MC: /dev
MC->>MC: dispatch agents
MC->>U: Output segment (with ⚠️ marker)
Note over MC: Turn ends
CC->>Hook: Trigger Stop hook
(pass transcript_path)
Hook->>Hook: Read transcript
Find ⚠️ lines
Hook->>TG: POST /sendMessage
TG-->>U: 📱 Push NotificationInput JSON Parameters
{
"session_id": "...",
"transcript_path": "/path/to/transcript.jsonl",
"stop_hook_active": true
}
→ The key is transcript_path — allowing you to read the complete conversation history.
Marker Pattern
Our dev.md intentionally outputs lines with fixed prefixes:
⚠️ Group 7: escalating to developer-deep ...
✓ READY TO ARCHIVE — run /opsx:archive
✗ Group 3 stopped — manual decision required
→ The hook uses regex to grep these lines, only sending important events, not regular progress updates.
notify-escalation.py Full Example
#!/usr/bin/env python3
import json, os, re, sys, urllib.request
from pathlib import Path
PROJECT_ROOT = Path(os.environ.get("CLAUDE_PROJECT_DIR")).resolve()
NOTIFY_PATTERNS = [
re.compile(r"^⚠️.*$", re.MULTILINE),
re.compile(r"^✓ READY TO ARCHIVE.*$", re.MULTILINE),
re.compile(r"^✗ Group \d+ stopped.*$", re.MULTILINE),
]
SENT_LOG = PROJECT_ROOT / ".claude" / ".notify-sent"
CONFIG_FILE = PROJECT_ROOT / ".claude" / "telegram-notify.json"
def load_config():
# Prioritize environment variables
token = os.environ.get("TELEGRAM_BOT_TOKEN")
chat_id = os.environ.get("TELEGRAM_CHAT_ID")
if token and chat_id:
return token, chat_id
# Fallback to config file
if CONFIG_FILE.exists():
data = json.loads(CONFIG_FILE.read_text())
return data.get("bot_token"), str(data.get("chat_id"))
return None
def extract_markers(transcript_path):
found = []
for line in Path(transcript_path).open():
try:
msg = json.loads(line)
except: continue
content = msg.get("message", {}).get("content")
if isinstance(content, list):
text = "\n".join(b.get("text","") for b in content
if isinstance(b, dict) and b.get("type") == "text")
else:
continue
for pat in NOTIFY_PATTERNS:
for m in pat.findall(text):
found.append(m.strip())
return found
def send_telegram(token, chat_id, text):
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = json.dumps({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=payload,
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5)
return True
except Exception as e:
print(f"Telegram failed: {e}", file=sys.stderr)
return False
data = json.load(sys.stdin)
config = load_config()
if not config:
sys.exit(0) # No config = silent exit
markers = extract_markers(data.get("transcript_path", ""))
already = set(SENT_LOG.read_text().splitlines()) if SENT_LOG.exists() else set()
new = [m for m in markers if m not in already]
if not new:
sys.exit(0)
if send_telegram(config[0], config[1], "\n".join(new[-5:])):
with SENT_LOG.open("a") as f:
for m in new:
f.write(m + "\n")
sys.exit(0)
Deduplication is Crucial
Without deduplication: Every turn resends all historical markers
Your phone keeps vibrating
With deduplication (.notify-sent):
Saves already sent marker lines
Next time, only sends new ones
Full Notification Chain:
sequenceDiagram
participant MC as main Claude
participant CC as Claude Code
participant Hook as notify-escalation.py
participant Trans as transcript.jsonl
participant Sent as .notify-sent
participant TG as Telegram API
participant You as You
MC->>CC: Output "⚠️ Group 7: STUCK..."
Note over CC: Turn ends, triggering Stop hook
CC->>Hook: stdin: {transcript_path: ...}
Hook->>Trans: Read JSONL
Hook->>Hook: Regex match ⚠️/✓/✗ lines
Get all_markers
Hook->>Sent: Read sent list
Hook->>Hook: new = all_markers - sent
alt New events
Hook->>TG: POST /sendMessage
{chat_id, text: new}
TG-->>You: 📱 Push Notification
Hook->>Sent: Append new lines
Hook-->>CC: exit 0
else No new events
Hook-->>CC: exit 0 (silent)
end
Note over CC: Main flow unaffectedMulti-Channel Extension
# Slack webhook
def send_slack(webhook_url, text):
urllib.request.urlopen(...)
# Email
import smtplib
def send_email(to, subject, body):
...
# Discord
def send_discord(webhook_url, text):
...
→ Marker extraction logic remains the same, only the sending function changes.
Security Considerations
✅ Token in settings.local.json (gitignore)
✅ Token in environment variables (not in files)
❌ Token hardcoded in script (leaks on push)
❌ Chat ID in shared team files (privacy)
What if the Stop Hook Fails?
Hook exits non-zero → Claude Code displays a warning but **does not block the main flow**
Your turn still completes as intended
You just don't receive a notification
→ A failed Stop hook will not disrupt development. The worst case is you miss a notification.
Anti-Patterns
❌ Marker pattern too broad (triggers notification for every line)
→ Notification spam
❌ No deduplication
→ Same event pushed repeatedly
❌ Token hardcoded in script
→ Leaks as soon as it's pushed to Git
❌ Hook doesn't complete within 5 seconds
→ Slows down every Stop
❌ Hook modifies source code / spec itself
→ Out of bounds, will break the state machine
What You Can Do Now
- Write a Stop hook to connect to Telegram / Slack / any HTTP channel
- Use the marker pattern to send only important events
- Implement deduplication to avoid spamming