Never Miss a Phase: Discord Notifications
Get notified on Discord when Claude Code completes a phase or needs your attention
You kick off a multi-phase Claude Code session. Phase 1 starts running. You grab coffee. Check Slack. Get pulled into a meeting. Come back two hours later. Claude finished Phase 1 in 8 minutes and has been politely waiting for your approval ever since.
The Problem
- β’ Long-running Claude Code sessions with multi-phase plans require human approval between phases
- β’ No built-in notification system means Claude waits silently
- β’ Context switching kills productivity - you either watch Claude work or risk leaving it hanging
- β The solution: a custom hook that pings you on Discord when Claude needs attention
The Solution
A lightweight hook that:
- Detects phase completion, approval requests, and finalization using pattern matching
- Sends a Discord notification so you can step away confidently
- Works cross-platform (macOS, Linux, Windows) using native Node.js
httpsmodule - Lives in
~/.claude/custom/hooks/so it survives ClaudeKit updates - Only fires on meaningful keywords (not noisy like notifying on every stop)
Detection Patterns
| Type | Emoji | Color | Trigger Phrases |
|---|---|---|---|
| /plan Complete | π | #3498db | "ExitPlanMode", "exiting plan mode" |
| /code Step 5 | βΈ | #f1c40f | "βΈ Step 5", "WAITING for user approval" |
| /code Phase Complete | β | #2ecc71 | "β Step 6: Finalize", "Git committed", "Phase workflow finished" |
Setup Steps
Create a Discord Webhook
- Open Discord, go to your server
- Right-click a channel β Edit Channel β Integrations β Webhooks β New Webhook
- Name it "Claude Code" or anything you like
- Copy the webhook URL (keep it secret!)
Create the Custom Hooks Directory
macOS / Linux:
mkdir -p ~/.claude/custom/hooks New-Item -ItemType Directory -Force -Path $env:USERPROFILE\.claude\custom\hooks The custom/ directory is ignored by ClaudeKit updates, so your hooks persist.
Create the Notification Script
Create file: ~/.claude/custom/hooks/discord-phase-notify.cjs
#!/usr/bin/env node
/**
* Discord Phase Notification Hook (Minimal Version)
*
* Only notifies on high-signal events:
* 1. /plan command - ExitPlanMode when planning phase completes
* 2. /code command - Step 5 blocking gate waiting for user approval
* 3. /code command - Step 6 finalize when phase workflow finishes
*
* Location: ~/.claude/custom/hooks/ (survives CK updates)
* Events: Stop (main session only, not SubagentStop to reduce noise)
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const os = require('os');
// Strict detection patterns - only high-signal events
const PATTERNS = {
// /plan command: Plan ready for approval (ExitPlanMode tool called)
planReady: [
/ExitPlanMode/,
/exiting\s+plan\s+mode/i,
],
// /code command: Step 5 blocking gate - waiting for user approval
codeApproval: [
/βΈ\s*Step\s*5/,
/Step\s*5.*WAITING.*user\s*approval/i,
/WAITING\s+for\s+user\s+approval/i,
],
// /code command: Step 6 finalize - phase workflow finished
codeFinalize: [
/β\s*Step\s*6.*Finalize/,
/Step\s*6.*Git\s*committed/i,
/Phase\s+workflow\s+finished/i,
],
};
// Load environment variables (project scope first, then global)
function loadEnv() {
const cwd = process.cwd();
const envPaths = [
// Project scope first
path.join(cwd, '.claude', '.env'),
path.join(cwd, '.claude', 'hooks', '.env'),
// Global scope fallback
path.join(os.homedir(), '.claude', 'custom', 'hooks', '.env'),
path.join(os.homedir(), '.claude', 'hooks', '.env'),
path.join(os.homedir(), '.claude', '.env'),
];
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf-8');
content.split('\n').forEach(line => {
const match = line.match(/^([^=]+)=(.*)$/);
if (match && !process.env[match[1]]) {
process.env[match[1]] = match[2].replace(/^["']|["']$/g, '');
}
});
}
}
}
// Send Discord notification (cross-platform using native https)
function sendDiscord(title, message, color = 5763719) {
return new Promise((resolve) => {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
console.error('DISCORD_WEBHOOK_URL not set - skipping notification');
resolve(false);
return;
}
const cwd = process.cwd();
const project = path.basename(cwd);
const timestamp = new Date().toISOString();
const localTime = new Date().toLocaleTimeString('en-US', { hour12: false });
const payload = JSON.stringify({
embeds: [{
title,
description: message,
color,
timestamp,
footer: { text: `${project} β’ ${cwd}` },
fields: [
{ name: 'β° Time', value: localTime, inline: true },
{ name: 'π Project', value: project, inline: true },
],
}],
});
try {
const url = new URL(webhookUrl);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
timeout: 10000,
};
const req = https.request(options, (res) => {
resolve(res.statusCode >= 200 && res.statusCode < 300);
});
req.on('error', (e) => {
console.error(`Discord notification failed: ${e.message}`);
resolve(false);
});
req.on('timeout', () => {
req.destroy();
console.error('Discord notification timed out');
resolve(false);
});
req.write(payload);
req.end();
} catch (e) {
console.error(`Discord notification failed: ${e.message}`);
resolve(false);
}
});
}
// Detect notification type from text - strict matching
function detectType(text) {
if (!text) return null;
// /plan command: ExitPlanMode (plan ready for review)
for (const pattern of PATTERNS.planReady) {
if (pattern.test(text)) {
return { type: 'planReady', title: 'π /plan Complete', color: 3447003 }; // Blue
}
}
// /code command: Step 6 finalize (phase workflow finished) - check before Step 5
for (const pattern of PATTERNS.codeFinalize) {
if (pattern.test(text)) {
return { type: 'codeFinalize', title: 'β
/code Phase Complete', color: 5763719 }; // Green
}
}
// /code command: Step 5 blocking gate
for (const pattern of PATTERNS.codeApproval) {
if (pattern.test(text)) {
return { type: 'codeApproval', title: 'βΈ /code Step 5: Approval Needed', color: 15844367 }; // Yellow
}
}
return null;
}
// Extract relevant context from payload
function extractContext(payload) {
const parts = [];
if (payload.stop_reason) {
parts.push(payload.stop_reason.slice(0, 300));
}
return parts.join('\n') || 'Claude needs your attention';
}
// Main execution
async function main() {
try {
loadEnv();
const stdin = fs.readFileSync(0, 'utf-8').trim();
if (!stdin) process.exit(0);
const payload = JSON.parse(stdin);
const textParts = [
payload.stop_reason,
payload.result,
payload.output,
payload.message,
].filter(Boolean);
if (payload.transcript_path && fs.existsSync(payload.transcript_path)) {
try {
const transcript = fs.readFileSync(payload.transcript_path, 'utf-8');
textParts.push(transcript.slice(-3000));
} catch (e) {
// Ignore
}
}
const fullText = textParts.join(' ');
const detection = detectType(fullText);
if (detection) {
const context = extractContext(payload);
const sent = await sendDiscord(detection.title, context, detection.color);
if (sent) {
console.log(`Discord: ${detection.title}`);
}
}
process.exit(0);
} catch (error) {
console.error(`Hook error: ${error.message}`);
process.exit(0);
}
}
main(); Make executable (macOS/Linux):
chmod +x ~/.claude/custom/hooks/discord-phase-notify.cjs
Save the script to %USERPROFILE%\.claude\custom\hooks\discord-phase-notify.cjs
Note: No chmod needed on Windows - Node.js scripts run directly.
Configure Claude Code Hooks
Add to ~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node $HOME/.claude/custom/hooks/discord-phase-notify.cjs"
}
]
}
]
}
} Windows users: Replace $HOME with full path like C:\Users\YourName\.claude\custom\hooks\discord-phase-notify.cjs
Set Your Webhook URL
Add to ~/.claude/.env:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN Test
/plan Complete
Triggers when /plan command completes (ExitPlanMode):
macOS / Linux:
echo '{"stop_reason": "ExitPlanMode - plan is ready for your review"}' | node ~/.claude/custom/hooks/discord-phase-notify.cjs '{"stop_reason": "ExitPlanMode - plan is ready for your review"}' | node $env:USERPROFILE\.claude\custom\hooks\discord-phase-notify.cjs /code Step 5
Triggers when /code command reaches Step 5 blocking gate:
macOS / Linux:
echo '{"stop_reason": "βΈ Step 5: WAITING for user approval"}' | node ~/.claude/custom/hooks/discord-phase-notify.cjs '{"stop_reason": "βΈ Step 5: WAITING for user approval"}' | node $env:USERPROFILE\.claude\custom\hooks\discord-phase-notify.cjs /code Step 6
Triggers when /code command completes Step 6 finalization:
macOS / Linux:
echo '{"stop_reason": "β Step 6: Finalize - Status updated - Git committed"}' | node ~/.claude/custom/hooks/discord-phase-notify.cjs '{"stop_reason": "β Step 6: Finalize - Status updated - Git committed"}' | node $env:USERPROFILE\.claude\custom\hooks\discord-phase-notify.cjs Each test should trigger a different Discord notification with the corresponding emoji and color.
Alternative: Let LLMs Do Everything
Copy this prompt and let Claude Code set everything up for you:
Customize for Other Events
The hook listens to the Stop event. You can extend it or add more hook events:
Available Hook Events
| Event | Description |
|---|---|
| PreToolUse | Before a tool is executed |
| PostToolUse | After a tool completes |
| Stop | When Claude stops (main session) |
| SubagentStop | When a subagent completes (can be noisy) |
| Notification | When Claude sends a notification |
Adding Custom Patterns
Edit the PATTERNS object in the script:
// Add your custom patterns
const PATTERNS = {
// Existing patterns...
planReady: [/ExitPlanMode/, /exiting\s+plan\s+mode/i],
codeApproval: [/βΈ\s*Step\s*5/, /WAITING\s+for\s+user\s+approval/i],
codeFinalize: [/β\s*Step\s*6.*Finalize/, /Phase\s+workflow\s+finished/i],
// Custom: Notify when tests complete
testsComplete: [
/All tests passed/i,
/\d+ passed, 0 failed/,
],
// Custom: Notify on errors
errorOccurred: [
/Error:/i,
/Failed to/i,
/Exception/i,
],
};
// Update detectType() to handle new patterns
function detectType(text) {
// ... existing checks ...
for (const pattern of PATTERNS.testsComplete) {
if (pattern.test(text)) {
return { type: 'testsComplete', title: 'β
Tests Passed', color: 5763719 }; // Green
}
}
for (const pattern of PATTERNS.errorOccurred) {
if (pattern.test(text)) {
return { type: 'error', title: 'β Error Detected', color: 15548997 }; // Red
}
}
return null;
} Listen to Multiple Events
Add more events in settings.json:
{
"hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "node $HOME/.claude/custom/hooks/discord-phase-notify.cjs" }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "node $HOME/.claude/custom/hooks/discord-phase-notify.cjs" }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "node $HOME/.claude/custom/hooks/discord-phase-notify.cjs" }] }]
}
} SubagentStop fires frequently - use specific patterns to filter noise.
Discord embed colors are decimal values. Common: Green=5763719, Red=15548997, Blue=3447003, Yellow=15844367, Purple=10181046.