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 https module
  • 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

1

Create a Discord Webhook

  1. Open Discord, go to your server
  2. Right-click a channel β†’ Edit Channel β†’ Integrations β†’ Webhooks β†’ New Webhook
  3. Name it "Claude Code" or anything you like
  4. Copy the webhook URL (keep it secret!)
2

Create the Custom Hooks Directory

macOS / Linux:

mkdir -p ~/.claude/custom/hooks
Windows (PowerShell)
New-Item -ItemType Directory -Force -Path $env:USERPROFILE\.claude\custom\hooks

The custom/ directory is ignored by ClaudeKit updates, so your hooks persist.

3

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
Windows

Save the script to %USERPROFILE%\.claude\custom\hooks\discord-phase-notify.cjs

Note: No chmod needed on Windows - Node.js scripts run directly.

4

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

5

Set Your Webhook URL

Add to ~/.claude/.env:

DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN
6

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
Windows (PowerShell)
'{"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
Windows (PowerShell)
'{"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
Windows (PowerShell)
'{"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:

Set up Discord notifications for Claude Code /plan and /code commands. Platform: Detect my OS and use appropriate paths/commands. Requirements: 1. Create the hooks directory: - macOS/Linux: ~/.claude/custom/hooks/ - Windows: %USERPROFILE%\.claude\custom\hooks\ 2. Create `discord-phase-notify.cjs` - a Node.js script that: - Uses native `https` module (no external dependencies) - Uses `os.homedir()` and `process.cwd()` for cross-platform paths - Reads hook payload from stdin (JSON with stop_reason, result, output, message fields) - Only detects these specific patterns (no generic patterns): * /plan command: "ExitPlanMode", "exiting plan mode" β†’ Blue notification * /code command: "⏸ Step 5", "WAITING for user approval" β†’ Yellow notification * /code command: "βœ“ Step 6: Finalize", "Git committed", "Phase workflow finished" β†’ Green notification - Sends Discord embed with title, project name, directory, timestamp - Loads DISCORD_WEBHOOK_URL from project scope first, then global: * Project: ./.claude/.env, ./.claude/hooks/.env * Global: ~/.claude/custom/hooks/.env, ~/.claude/hooks/.env, ~/.claude/.env - Always exits 0 (non-blocking) 3. Update settings.json (only Stop event, not SubagentStop): - macOS/Linux: ~/.claude/settings.json with `node $HOME/.claude/custom/hooks/discord-phase-notify.cjs` - Windows: %USERPROFILE%\.claude\settings.json with full path 4. Create .env file with DISCORD_WEBHOOK_URL=<webhook_url_placeholder> 5. Test with these payloads: - /plan: {"stop_reason": "ExitPlanMode - plan is ready"} - /code Step 5: {"stop_reason": "⏸ Step 5: WAITING for user approval"} - /code Step 6: {"stop_reason": "βœ“ Step 6: Finalize - Git committed"} The custom/ directory ensures the hook survives ClaudeKit updates.

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.