Claude Code — Discord Notification Hook

Sends a Discord message when Claude Code finishes a task, using Claude’s own final message as the summary. Only fires for tasks longer than 2 minutes.


Final working script

Save as ~/claude-discord-notify.sh and chmod +x.

#!/bin/bash
 
INPUT=$(cat)
 
SESSION=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('session_id', 'unknown'))
" 2>/dev/null || echo "unknown")
 
TRANSCRIPT=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('transcript_path', ''))
" 2>/dev/null || echo "")
 
# Get last_assistant_message directly from hook JSON (no API call needed)
SUMMARY=$(echo "$INPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('last_assistant_message', '(no message)')[:4096])
" 2>/dev/null || echo "(no message)")
 
# Calculate duration from transcript timestamps (last user msg → now)
DURATION=0
if [ -f "$TRANSCRIPT" ]; then
  DURATION=$(python3 -c "
import json
from datetime import datetime, timezone
 
last_user_ts = None
with open('$TRANSCRIPT') as f:
    for line in f:
        try:
            d = json.loads(line)
            ts = d.get('timestamp')
            if ts and d.get('type') == 'user':
                last_user_ts = datetime.fromisoformat(ts.replace('Z', '+00:00'))
        except:
            pass
 
if last_user_ts:
    now = datetime.now(timezone.utc)
    print(int((now - last_user_ts).total_seconds()))
else:
    print(0)
")
fi
 
# Skip tasks under 2 minutes
if [ "$DURATION" -lt 120 ]; then
  exit 0
fi
 
DURATION_STR=$(python3 -c "
m, s = divmod($DURATION, 60)
print(f'{m}m {s}s' if m else f'{s}s')
")
 
curl -s -X POST "YOUR_DISCORD_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
    --arg summary "$SUMMARY" \
    --arg duration "$DURATION_STR" \
    --arg session "$SESSION" \
    '{
      username: "Claude Code",
      embeds: [{
        title: "✅ Task Completed",
        description: $summary,
        color: 2664261,
        fields: [{ name: "Duration", value: $duration, inline: true }],
        footer: { text: ("Session: " + $session) },
        timestamp: (now | todate)
      }]
    }')"

settings.json hook config

~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [{
          "type": "command",
          "command": "~/claude-discord-notify.sh"
        }]
      }
    ]
  }
}

Key facts about the hook JSON

Hook stdin contains:

{
  "session_id": "...",
  "transcript_path": "/home/user/.claude/projects/.../session.jsonl",
  "cwd": "/home/user/project",
  "hook_event_name": "Stop",
  "last_assistant_message": "Here's what I did: ..."
}

⚠️ There is no start_time field. Duration must be calculated from timestamps inside the .jsonl transcript file.


Known bug (open — 2026-05-12)

Problem: After switching to “last user message → now” duration calculation, the notification stopped firing entirely.

Suspected root cause: The transcript type field for user messages may be "human" not "user". The script filters d.get('type') == 'user' but if the actual value is "human", last_user_ts never gets set and DURATION stays 0, so the script always exits early.

Next debug step:

# Check what type values actually appear in the transcript
head -20 /path/to/session.jsonl | python3 -m json.tool | grep '"type"'

If the output shows "human" instead of "user", change the filter line to:

if ts and d.get('type') in ('user', 'human'):

Dependencies

# Required
pip install python3   # usually pre-installed
apt install jq curl   # usually pre-installed

Get Discord webhook URL

Discord → Channel Settings → Integrations → Webhooks → New Webhook → Copy URL.

⚠️ Never commit the webhook URL to git. Store in ~/.bashrc or a secrets file.